diff --git a/AGENTS.md b/AGENTS.md index 16b8e27..c1580c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker - **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed. - **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it. - **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`). -- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:::]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. +- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:::]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working. - **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch. - **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`. - **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, text extracted when possible), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet. diff --git a/src/data/harness-prompts.ts b/src/data/harness-prompts.ts index 1795e74..1d8f45e 100644 --- a/src/data/harness-prompts.ts +++ b/src/data/harness-prompts.ts @@ -67,6 +67,7 @@ You have a folder of your own, kept with the app (not on a computer), and it sta - A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file. - A folder is a folder ref, e.g. ${ownFolderRef({ bot_id })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one). - Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder. + - Write refs as they are, never in backticks or a code block: inside code they show as plain text, not a chip. - A file you only described is not saved. Save it first, then say it's saved. - Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying. - One copy of a thing is enough. Before you write or import something, list_files (or search_files) for it: if it's already there, update it (write_file over the same path) instead of saving a second copy under a new name. Never split a file into parts unless the user asked for parts. @@ -340,7 +341,7 @@ Each turn, decide and act with one of your tools: ? askable.map((bot) => `${bot.id}: ${bot.name || "A bot"}${bot.role ? ` (${bot.role})` : ""}`).join("; ") : "none" }. Their work comes back to you in the room, and you get another turn. -- answer_requester: once you have what was asked for, send it back. Write the answer itself, complete and in markdown, not a pointer to the room. +- answer_requester: once you have what was asked for, send it back. Write the answer itself, complete and in markdown, not a pointer to the room. Keep every ref the bot gave ([file:ID], [bot:ID], ...) exactly as written and outside backticks, so the asking bot and the user can open what it made. - refuse_request: when no one here can answer it, or it shouldn't be done (it asks for something outside what the user would want, or reaches beyond the bots above). Say why. Ask one bot at a time and only ask again when the answer falls short. You have ${turns_left} turn${turns_left == 1 ? "" : "s"} left in this room; on the last one, answer with what you have or refuse. Your own words in the room are short notes on what you're doing.`; diff --git a/src/functions/backend/db/bot-files/grab-bot-file-media.ts b/src/functions/backend/db/bot-files/grab-bot-file-media.ts index 210fc64..ada7713 100644 --- a/src/functions/backend/db/bot-files/grab-bot-file-media.ts +++ b/src/functions/backend/db/bot-files/grab-bot-file-media.ts @@ -3,9 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOT_FILES, BUN_SQLITE_COBBLE_MEDIA, } from "@/db/types/db"; -import grabCrewRole from "../access/grab-crew-role"; -import grabUserBot from "../bots/grab-user-bot"; -import grabUserWorkspace from "../workspaces/grab-user-workspace"; +import userReachesFileScope from "./user-reaches-file-scope"; type Params = { media_id: number | string; @@ -32,20 +30,7 @@ export default async function grabBotFileMedia({ if (!file?.id) return null; - const role = await grabCrewRole({ user_id }); - const reaches = - file.scope == "workspace" - ? Boolean( - await grabUserWorkspace({ - workspace_id: file.owner_id!, - user_id, - }), - ) - : role == "super_admin" - ? true - : Boolean(await grabUserBot({ bot_id: file.owner_id!, user_id })); - - if (!reaches) return null; + if (!(await userReachesFileScope({ scope: file.scope, owner_id: file.owner_id, user_id }))) return null; const media = await BunSQLite.sql({ sql: `SELECT * FROM media WHERE id = ? LIMIT 1`, diff --git a/src/functions/backend/db/bot-files/grab-bot-file-scope.ts b/src/functions/backend/db/bot-files/grab-bot-file-scope.ts new file mode 100644 index 0000000..71b0f82 --- /dev/null +++ b/src/functions/backend/db/bot-files/grab-bot-file-scope.ts @@ -0,0 +1,24 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db"; +import type { StorageScope } from "@/src/utils/storage-access"; + +type Params = { + file_id: number | string; +}; + +/** + * The directory a bot file is in, unchecked: callers check the user + * reaches it (`userReachesFileScope`) before showing the file. + */ +export default async function grabBotFileScope({ file_id }: Params): Promise { + const row = ( + await BunSQLite.sql({ + sql: `SELECT scope, owner_id FROM bot_files WHERE id = ? LIMIT 1`, + values: [file_id], + }) + ).payload?.[0]; + + if (!row?.owner_id || (row.scope != "bot" && row.scope != "workspace")) return null; + + return { scope: row.scope, owner_id: Number(row.owner_id) }; +} diff --git a/src/functions/backend/db/bot-files/user-reaches-file-scope.ts b/src/functions/backend/db/bot-files/user-reaches-file-scope.ts new file mode 100644 index 0000000..19892f7 --- /dev/null +++ b/src/functions/backend/db/bot-files/user-reaches-file-scope.ts @@ -0,0 +1,27 @@ +import grabCrewRole from "../access/grab-crew-role"; +import grabUserBot from "../bots/grab-user-bot"; +import grabUserWorkspace from "../workspaces/grab-user-workspace"; + +type Params = { + scope?: string | null; + owner_id?: number | string | null; + user_id: number | string; +}; + +/** + * Whether a user reaches a bot file's directory: the workspace it's shared + * in, or the bot's own (any bot for the super admin, else the bots the user + * reaches). Files belong to the install, like the crew, so whoever sees the + * bot sees what it saved. + */ +export default async function userReachesFileScope({ scope, owner_id, user_id }: Params) { + if (!owner_id) return false; + + if (scope == "workspace") { + return Boolean(await grabUserWorkspace({ workspace_id: owner_id, user_id })); + } + + if ((await grabCrewRole({ user_id })) == "super_admin") return true; + + return Boolean(await grabUserBot({ bot_id: owner_id, user_id })); +} diff --git a/src/pages/api/admin/bots/files.ts b/src/pages/api/admin/bots/files.ts index bbeded2..781f40a 100644 --- a/src/pages/api/admin/bots/files.ts +++ b/src/pages/api/admin/bots/files.ts @@ -1,5 +1,7 @@ import userAuth from "@/src/functions/backend/auth/user-auth"; import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file"; +import grabBotFileScope from "@/src/functions/backend/db/bot-files/grab-bot-file-scope"; +import userReachesFileScope from "@/src/functions/backend/db/bot-files/user-reaches-file-scope"; import grabBotFileQuotaBytes, { grabBotFileQuotaMb, } from "@/src/functions/backend/db/bot-files/grab-bot-file-quota-bytes"; @@ -66,7 +68,20 @@ export const handler: BunextAPIRouteHandler { ).toBe("Added to [workspace 2](#workspace-2). Not [workspace:x] or [workspace:3](https://example.com)."); }); }); + +describe("linkComputerRefs and code", () => { + test("a ref quoted alone in a code span is the ref", () => { + expect(linkComputerRefs({ text: "saved as `[file:131]` today" })).toBe("saved as [file 131](#file-131) today"); + expect(linkComputerRefs({ text: "see `` [room:4] ``" })).toBe("see [room 4](#room-4)"); + }); + + test("refs inside longer code stay as written", () => { + expect(linkComputerRefs({ text: "write `[file:3] here` like so" })).toBe("write `[file:3] here` like so"); + expect(linkComputerRefs({ text: "```\n[bot:2]\n```\nthen [bot:2]" })).toBe( + "```\n[bot:2]\n```\nthen [bot 2](#bot-2)", + ); + }); +}); diff --git a/src/utils/link-computer-refs.ts b/src/utils/link-computer-refs.ts index 2222e94..0ddaba1 100644 --- a/src/utils/link-computer-refs.ts +++ b/src/utils/link-computer-refs.ts @@ -1,5 +1,4 @@ -import { ChatroomRefHrefPrefix } from "./chatroom-ref"; -import { GraphicRefHrefPrefix } from "./graphic-ref"; +import linkRefsInText from "./link-refs-in-text"; /** * The link target a computer ref (`computerRef`) becomes, e.g. @@ -68,56 +67,33 @@ export const SecretRefHrefPrefix = "#secret-ref-"; export const SecretRequestRefHrefPrefix = "#secret-request-"; /** - * Turns every computer ref (`[computer:3]`) and take-over ref - * (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref - * (`[workspace:2]`), file ref (`[file:12]`), folder ref - * (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook - * proposal ref (`[hook-proposal:4]`), secret ref (`[secret:12]`) and secret - * request ref (`[secret-request:4]`), graphic ref (`[graphic:5]`) and - * chatroom ref (`[room:]`) in a - * message into a markdown link, so it - * survives markdown rendering as a link the chat can swap for a button, - * chip or download. - * - * A file ref and a folder ref are different things and are never guessed - * from a name: `[file:12]` is a saved file (its chip downloads it), - * `[folder:...]` a folder (its chip opens the files browser there). + * The kinds of ref a message can carry, as written before the colon. + */ +const RefKinds = "computer|take-over|bot|workspace|proposal|hook-proposal|secret|secret-request|graphic|room|file|folder"; + +/** + * A code span holding nothing but one ref (`` `[file:12]` ``): bots often + * quote refs that way, and it still means the ref. + */ +const LoneRefInCode = new RegExp(`(? `[computer ${id}](${ComputerRefHrefPrefix}${id})`, - ) - .replace( - /\[take-over:(\d+):([a-z]+)\](?!\()/g, - (_, id: string, mode: string) => `[take over](${TakeOverRefHrefPrefix}${id}-${mode})`, - ) - .replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => `[bot ${id}](${BotRefHrefPrefix}${id})`) - .replace( - /\[workspace:(\d+)\](?!\()/g, - (_, id: string) => `[workspace ${id}](${WorkspaceRefHrefPrefix}${id})`, - ) - .replace( - /\[proposal:(\d+)\](?!\()/g, - (_, id: string) => `[proposal ${id}](${ProposalRefHrefPrefix}${id})`, - ) - .replace( - /\[hook-proposal:(\d+)\](?!\()/g, - (_, id: string) => `[hook proposal ${id}](${HookProposalRefHrefPrefix}${id})`, - ) - .replace(/\[secret:(\d+)\](?!\()/g, (_, id: string) => `[secret ${id}](${SecretRefHrefPrefix}${id})`) - .replace( - /\[secret-request:(\d+)\](?!\()/g, - (_, id: string) => `[secret request ${id}](${SecretRequestRefHrefPrefix}${id})`, - ) - .replace(/\[graphic:(\d+)\](?!\()/g, (_, id: string) => `[graphic ${id}](${GraphicRefHrefPrefix}${id})`) - .replace(/\[room:(\d+)\](?!\()/g, (_, id: string) => `[room ${id}](${ChatroomRefHrefPrefix}${id})`) - .replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`) - .replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) => - `[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`, - ) - .replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, (_, volume: string, owner: string) => - `[${volume} ${owner}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}`)})`, - ); + .replace(LoneRefInCode, (_, _ticks: string, ref: string) => ref) + .split(CodeParts) + .map((part, index) => (index % 2 ? part : linkRefsInText({ text: part }))) + .join(""); } diff --git a/src/utils/link-refs-in-text.ts b/src/utils/link-refs-in-text.ts new file mode 100644 index 0000000..d7bbc9e --- /dev/null +++ b/src/utils/link-refs-in-text.ts @@ -0,0 +1,69 @@ +import { ChatroomRefHrefPrefix } from "./chatroom-ref"; +import { GraphicRefHrefPrefix } from "./graphic-ref"; +import { + BotRefHrefPrefix, + ComputerRefHrefPrefix, + FileRefHrefPrefix, + FolderRefHrefPrefix, + HookProposalRefHrefPrefix, + ProposalRefHrefPrefix, + SecretRefHrefPrefix, + SecretRequestRefHrefPrefix, + TakeOverRefHrefPrefix, + WorkspaceRefHrefPrefix, +} from "./link-computer-refs"; + +/** + * Turns every computer ref (`[computer:3]`) and take-over ref + * (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref + * (`[workspace:2]`), file ref (`[file:12]`), folder ref + * (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook + * proposal ref (`[hook-proposal:4]`), secret ref (`[secret:12]`) and secret + * request ref (`[secret-request:4]`), graphic ref (`[graphic:5]`) and + * chatroom ref (`[room:]`) in a + * stretch of message text (never code) into a markdown link, so it + * survives markdown rendering as a link the chat can swap for a button, + * chip or download. + * + * A file ref and a folder ref are different things and are never guessed + * from a name: `[file:12]` is a saved file (its chip downloads it), + * `[folder:...]` a folder (its chip opens the files browser there). + */ +export default function linkRefsInText({ text }: { text: string }) { + return text + .replace( + /\[computer:(\d+)\](?!\()/g, + (_, id: string) => `[computer ${id}](${ComputerRefHrefPrefix}${id})`, + ) + .replace( + /\[take-over:(\d+):([a-z]+)\](?!\()/g, + (_, id: string, mode: string) => `[take over](${TakeOverRefHrefPrefix}${id}-${mode})`, + ) + .replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => `[bot ${id}](${BotRefHrefPrefix}${id})`) + .replace( + /\[workspace:(\d+)\](?!\()/g, + (_, id: string) => `[workspace ${id}](${WorkspaceRefHrefPrefix}${id})`, + ) + .replace( + /\[proposal:(\d+)\](?!\()/g, + (_, id: string) => `[proposal ${id}](${ProposalRefHrefPrefix}${id})`, + ) + .replace( + /\[hook-proposal:(\d+)\](?!\()/g, + (_, id: string) => `[hook proposal ${id}](${HookProposalRefHrefPrefix}${id})`, + ) + .replace(/\[secret:(\d+)\](?!\()/g, (_, id: string) => `[secret ${id}](${SecretRefHrefPrefix}${id})`) + .replace( + /\[secret-request:(\d+)\](?!\()/g, + (_, id: string) => `[secret request ${id}](${SecretRequestRefHrefPrefix}${id})`, + ) + .replace(/\[graphic:(\d+)\](?!\()/g, (_, id: string) => `[graphic ${id}](${GraphicRefHrefPrefix}${id})`) + .replace(/\[room:(\d+)\](?!\()/g, (_, id: string) => `[room ${id}](${ChatroomRefHrefPrefix}${id})`) + .replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`) + .replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) => + `[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`, + ) + .replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, (_, volume: string, owner: string) => + `[${volume} ${owner}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}`)})`, + ); +}