From 489ceab43e1923dd10c9a3a0efae60b00efebd82 Mon Sep 17 00:00:00 2001 From: Cheskie Londinsky Date: Fri, 21 Aug 2026 11:19:37 -0400 Subject: [PATCH] feat: add Hermes-inspired continuity and autonomy tools --- README.md | 5 + agent-computer/src/index.ts | 78 + agent-computer/src/workspace.ts | 242 +- agent-computer/tests/workspace.test.ts | 42 + app/src/components/channels/channel-chat.tsx | 80 +- app/src/components/channels/compose-state.ts | 17 +- app/src/components/channels/composer/draft.ts | 2 + app/src/components/work/continuity-view.tsx | 975 +++ app/src/lib/channels/mutations.ts | 31 + app/src/lib/channels/start.ts | 5 +- app/src/lib/continuity/context-references.ts | 122 + app/src/lib/continuity/queries.ts | 140 + app/src/lib/plugins/queries.ts | 1 + app/src/lib/plugins/skill-commands.ts | 1 + app/src/lib/work/queries.ts | 14 + .../_authed/_app/channel/$channelId.tsx | 48 +- app/src/routes/_authed/_app/channel/new.tsx | 88 +- app/src/routes/_authed/_app/index.tsx | 2 +- app/src/routes/_authed/_app/work.tsx | 208 +- app/tests/compose-state.test.ts | 17 +- app/tests/context-references.test.ts | 26 + docs/architecture.md | 27 + docs/fork.md | 6 + docs/hermes-harvest.md | 43 + server/drizzle.config.ts | 1 + server/drizzle/0010_lonely_kid_colt.sql | 141 + server/drizzle/meta/0010_snapshot.json | 7230 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/app.ts | 11 + server/src/audit.ts | 12 + server/src/channels/routes.ts | 7 + server/src/computer/client.ts | 42 + server/src/computer/gateway.ts | 51 + server/src/computer/routes.ts | 45 + server/src/computer/schema.ts | 34 + server/src/continuity/routes.ts | 329 + server/src/continuity/store.ts | 1114 +++ server/src/db/schema/continuity.ts | 233 + server/src/db/schema/index.ts | 1 + server/src/db/schema/plugins.ts | 3 + server/src/db/schema/work.ts | 17 + server/src/index.ts | 108 +- server/src/plugins/store.ts | 47 +- server/src/runs/store.ts | 23 +- server/src/work/delegations.ts | 199 +- server/src/work/routes.ts | 72 + server/src/work/routines.ts | 103 + server/tests/channel-routes.test.ts | 11 + server/tests/routine-safeguards.test.ts | 26 + 49 files changed, 12010 insertions(+), 77 deletions(-) create mode 100644 app/src/components/work/continuity-view.tsx create mode 100644 app/src/lib/continuity/context-references.ts create mode 100644 app/src/lib/continuity/queries.ts create mode 100644 app/tests/context-references.test.ts create mode 100644 docs/hermes-harvest.md create mode 100644 server/drizzle/0010_lonely_kid_colt.sql create mode 100644 server/drizzle/meta/0010_snapshot.json create mode 100644 server/src/continuity/routes.ts create mode 100644 server/src/continuity/store.ts create mode 100644 server/src/db/schema/continuity.ts create mode 100644 server/tests/routine-safeguards.test.ts diff --git a/README.md b/README.md index 469d44c..c04b26c 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,9 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), - **Fast, visible computer use**: actions return the changed page and fresh controls together, Bot computers are prewarmed, and one reconnecting live stream powers the resizable watch/full-screen experience without duplicate screenshot polling. - **Take the wheel**: a Bot that hits a login wall or a 2FA prompt asks for help. Control is handed over in the same panel and recorded as `computer.help_requested`, `computer.control_taken` and `computer.control_released`. While a person is driving, Bot actions are refused rather than queued. Keyboard focus, paste, and direct file upload stay on the remote page and out of the model conversation. - **A conversation workspace**: `Ctrl/⌘ K` opens commands, channel search jumps between matching messages, unsent text survives a reload, and messages can carry files and durable reactions. A Bot can also pin a point-in-time screenshot into the transcript. +- **Multi-Bot Workrooms**: a channel can hold up to six coworkers while an explicit responder switch keeps tool, model, and computer ownership unambiguous. +- **Continuity you can inspect**: Context Vault searches an owner-scoped transcript projection, Bots can recover exact prior details with anchored search, and long threads receive extractive recovery snapshots instead of silently losing their middle. +- **Workspace Time Machine**: every Bot text write creates a pre-write checkpoint. File-selective rollback preserves later human edits by default and creates an inverse checkpoint so the rollback itself can be undone. - **Visible model context**: built-in Codex coworkers show their selected model, reasoning level, allowance windows, and reported token usage. Kayco does not invent a dollar cost when the connected subscription does not provide one. - **Secrets never enter the transcript**: the trail records that a secret was requested and how long it was, not what it said. - **Bring your own agent**: any AG-UI endpoint is a Bot, on a framework or hand written. Endpoints are validated with the same target checks used for browser navigation, and an auth header is stored write-only. @@ -147,6 +150,8 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), - **Shared project teams**: assign several coworkers to a project, open one team channel, and explicitly choose which coworker answers each turn. Computers, credentials, and browser sessions remain isolated per Bot. - **Portable team templates**: export a team's names and standing roles, then import them as new private coworkers. Templates never carry ids, endpoints, credentials, grants, messages, ownership, or visibility. - **Inspectable memory**: stable user, coworker, and project context is stored in PostgreSQL with scope, source, confidence, and pinning. People can inspect, edit, or remove it; relevant user and coworker memory is supplied to the active conversation. +- **Bounded autonomy programs**: reviewed tool programs sequence existing grants without creating a new execution path; nested handoffs enforce depth, parallelism, runtime, review, and stop-tree limits; deterministic routines can run an approved program without a model call. +- **Healthy, portable behavior**: skill hashes, versions, usage, pinning, archive, and rollback make learned behavior reviewable. Bot bundles move configuration while rejecting credentials, grants, sessions, transcripts, and memory. - **Permission-aware company knowledge**: the connector worker syncs Google Drive into lexical/vector chunks, keeps source ACLs beside them, and filters results in SQL before a Bot sees a citation. - **An audit trail you can export**: `/admin/audit` lists what was permitted, refused and failed and downloads a redacted SHA-256-chained evidence bundle. - **Operational readiness**: liveness and readiness endpoints surface database, model, task-lease and connector health without leaking details publicly. diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index e2a0de2..0395156 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -1150,6 +1150,84 @@ serve({ } } + if (url.pathname === "/files/checkpoints" && request.method === "POST") { + const body = (await request.json().catch(() => null)) as { + path?: unknown; + } | null; + try { + return json( + await workspace.checkpoints( + typeof body?.path === "string" && body.path.trim() + ? body.path.trim() + : undefined, + ), + ); + } catch (error) { + return json( + { error: describe(error, "Checkpoints could not be listed.") }, + fileStatus(error), + ); + } + } + + if ( + url.pathname === "/files/checkpoint-diff" && + request.method === "POST" + ) { + const body = (await request.json().catch(() => null)) as { + checkpointId?: unknown; + path?: unknown; + } | null; + try { + return json( + await workspace.checkpointDiff( + String(body?.checkpointId ?? ""), + typeof body?.path === "string" ? body.path : undefined, + ), + ); + } catch (error) { + return json( + { error: describe(error, "The checkpoint could not be compared.") }, + fileStatus(error), + ); + } + } + + if (url.pathname === "/files/rollback" && request.method === "POST") { + const body = (await request.json().catch(() => null)) as { + checkpointId?: unknown; + path?: unknown; + force?: unknown; + } | null; + try { + const checkpoints = await workspace.checkpoints( + typeof body?.path === "string" ? body.path : undefined, + ); + if ( + typeof body?.path !== "string" || + !checkpoints.checkpoints.some( + (checkpoint) => + checkpoint.id === String(body.checkpointId ?? "") && + checkpoint.path === body.path, + ) + ) { + throw new WorkspaceFileError( + "That checkpoint does not belong to the requested file.", + ); + } + return json( + await workspace.rollback(String(body?.checkpointId ?? ""), { + force: body?.force === true, + }), + ); + } catch (error) { + return json( + { error: describe(error, "The checkpoint could not be restored.") }, + fileStatus(error), + ); + } + } + // The current page as text, without navigating anywhere. // // Reading must be available after actions too. Returning page text only from `/navigate` would be enough if diff --git a/agent-computer/src/workspace.ts b/agent-computer/src/workspace.ts index 455525f..c4ac802 100644 --- a/agent-computer/src/workspace.ts +++ b/agent-computer/src/workspace.ts @@ -21,11 +21,13 @@ * can be tested against a temporary directory instead of being taken on trust. */ import { + copyFile, mkdir, readdir, readFile, realpath, stat, + unlink, writeFile, } from "node:fs/promises"; import { @@ -90,6 +92,20 @@ export const DEFAULT_WORKSPACE_LIMITS: WorkspaceLimits = { downloadBytes: 50 * 1024 * 1024, }; +const HISTORY_DIRECTORY = ".openbot-history"; +const MAX_CHECKPOINT_BYTES = 10 * 1024 * 1024; + +export type WorkspaceCheckpoint = { + id: string; + path: string; + operation: "write" | "append" | "rollback"; + existed: boolean; + beforeHash: string | null; + afterHash: string; + bytesBefore: number; + createdAt: string; +}; + export function createWorkspace( rootPath: string, limits: WorkspaceLimits = DEFAULT_WORKSPACE_LIMITS, @@ -109,6 +125,12 @@ export function createWorkspace( } const wanted = requested.trim(); + if (wanted.split(/[\\/]/)[0] === HISTORY_DIRECTORY) { + throw new WorkspacePathError( + "OpenBot's checkpoint history is not a workspace path.", + ); + } + if (isAbsolute(wanted)) { throw new WorkspacePathError( "Use a path relative to your workspace, not an absolute one.", @@ -186,6 +208,8 @@ export function createWorkspace( if (truncated) return; const found = await readdir(dir, { withFileTypes: true }); for (const item of found) { + // Checkpoint blobs are implementation data, not Bot files and never enter model context. + if (dir === root && item.name === HISTORY_DIRECTORY) continue; if (entries.length >= limits.listEntries) { truncated = true; return; @@ -248,7 +272,12 @@ export function createWorkspace( requested: string, contents: string, options: { append?: boolean } = {}, - ): Promise<{ path: string; bytes: number; appended: boolean }> { + ): Promise<{ + path: string; + bytes: number; + appended: boolean; + checkpointId: string; + }> { if (typeof contents !== "string") { throw new WorkspaceFileError("The contents to write must be text."); } @@ -260,12 +289,117 @@ export function createWorkspace( } const full = await resolvePath(requested, true); + const checkpoint = await captureCheckpoint( + rootPath, + full, + requested, + options.append ? "append" : "write", + ); await mkdir(dirname(full), { recursive: true }); await writeFile(full, contents, { encoding: "utf8", flag: options.append ? "a" : "w", }); - return { path: requested, bytes, appended: options.append === true }; + await finishCheckpoint(rootPath, checkpoint, full); + return { + path: requested, + bytes, + appended: options.append === true, + checkpointId: checkpoint.id, + }; + }, + + /** Recent pre-write checkpoints, newest first. Internal blobs never leave this process. */ + async checkpoints(requested?: string): Promise<{ + checkpoints: WorkspaceCheckpoint[]; + }> { + if (requested) await resolvePath(requested, true); + const manifests = await readCheckpointManifests(rootPath); + return { + checkpoints: manifests + .filter((checkpoint) => !requested || checkpoint.path === requested) + .slice(0, 100), + }; + }, + + /** Text comparison for one checkpoint, bounded by the ordinary read limit. */ + async checkpointDiff( + checkpointId: string, + expectedPath?: string, + ): Promise<{ + checkpoint: WorkspaceCheckpoint; + before: string; + current: string; + truncated: boolean; + changedSinceWrite: boolean; + }> { + const checkpoint = await readCheckpoint(rootPath, checkpointId); + if (expectedPath && checkpoint.path !== expectedPath) { + throw new WorkspaceFileError( + "That checkpoint does not belong to the requested file.", + ); + } + const full = await resolvePath(checkpoint.path, true); + const beforeBuffer = checkpoint.existed + ? await readFile(checkpointBlob(rootPath, checkpoint.id)).catch(() => + Buffer.alloc(0), + ) + : Buffer.alloc(0); + const currentBuffer = await readFile(full).catch(() => Buffer.alloc(0)); + const currentHash = await fileHash(full); + return { + checkpoint, + before: beforeBuffer.subarray(0, limits.readBytes).toString("utf8"), + current: currentBuffer.subarray(0, limits.readBytes).toString("utf8"), + truncated: + beforeBuffer.byteLength > limits.readBytes || + currentBuffer.byteLength > limits.readBytes, + changedSinceWrite: currentHash !== checkpoint.afterHash, + }; + }, + + /** + * Restore exactly one Bot-written file. A later human edit is preserved unless force is explicit. + * The rollback itself gets a checkpoint, so the person can undo the undo. + */ + async rollback( + checkpointId: string, + options: { force?: boolean } = {}, + ): Promise<{ + restored: true; + path: string; + checkpointId: string; + undoCheckpointId: string; + }> { + const checkpoint = await readCheckpoint(rootPath, checkpointId); + const full = await resolvePath(checkpoint.path, true); + const currentHash = await fileHash(full); + if (!options.force && currentHash !== checkpoint.afterHash) { + throw new WorkspaceFileError( + "That file changed after the Bot wrote it. Review the diff or explicitly force the rollback to avoid overwriting a person's work.", + ); + } + const inverse = await captureCheckpoint( + rootPath, + full, + checkpoint.path, + "rollback", + ); + if (checkpoint.existed) { + await mkdir(dirname(full), { recursive: true }); + await copyFile(checkpointBlob(rootPath, checkpoint.id), full); + } else { + await unlink(full).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + }); + } + await finishCheckpoint(rootPath, inverse, full); + return { + restored: true, + path: checkpoint.path, + checkpointId, + undoCheckpointId: inverse.id, + }; }, /** Save a browser download without trusting its suggested path or overwriting an existing file. */ @@ -310,6 +444,110 @@ export function createWorkspace( export type Workspace = ReturnType; +type PendingCheckpoint = Omit; + +async function captureCheckpoint( + rootPath: string, + full: string, + requested: string, + operation: WorkspaceCheckpoint["operation"], +): Promise { + const info = await stat(full).catch(() => null); + if (info?.isDirectory()) { + throw new WorkspaceFileError(`${requested} is a directory, not a file.`); + } + if (info && info.size > MAX_CHECKPOINT_BYTES) { + throw new WorkspaceFileError( + `That file is ${info.size} bytes, above the ${MAX_CHECKPOINT_BYTES}-byte checkpoint limit, so OpenBot will not overwrite it without a recovery point.`, + ); + } + const id = `${Date.now().toString(36)}-${crypto.randomUUID()}`; + const directory = checkpointDirectory(rootPath); + await mkdir(directory, { recursive: true }); + if (info) await copyFile(full, checkpointBlob(rootPath, id)); + return { + id, + path: requested, + operation, + existed: Boolean(info), + beforeHash: info ? await fileHash(full) : null, + bytesBefore: info?.size ?? 0, + createdAt: new Date().toISOString(), + }; +} + +async function finishCheckpoint( + rootPath: string, + checkpoint: PendingCheckpoint, + full: string, +) { + const complete: WorkspaceCheckpoint = { + ...checkpoint, + afterHash: await fileHash(full), + }; + await writeFile( + checkpointManifest(rootPath, checkpoint.id), + JSON.stringify(complete), + "utf8", + ); +} + +async function readCheckpointManifests(rootPath: string) { + const directory = checkpointDirectory(rootPath); + const files = await readdir(directory).catch(() => []); + const checkpoints = await Promise.all( + files + .filter((file) => file.endsWith(".json")) + .map(async (file) => { + try { + return JSON.parse( + await readFile(resolve(directory, file), "utf8"), + ) as WorkspaceCheckpoint; + } catch { + return null; + } + }), + ); + return checkpoints + .filter((value): value is WorkspaceCheckpoint => Boolean(value)) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); +} + +async function readCheckpoint(rootPath: string, checkpointId: string) { + if (!/^[a-z0-9-]{20,100}$/i.test(checkpointId)) { + throw new WorkspaceFileError("Checkpoint ID is invalid."); + } + try { + return JSON.parse( + await readFile(checkpointManifest(rootPath, checkpointId), "utf8"), + ) as WorkspaceCheckpoint; + } catch { + throw new WorkspaceFileError("That checkpoint does not exist."); + } +} + +async function fileHash(full: string) { + const contents = await readFile(full).catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + }); + if (contents === null) return "missing"; + const digest = await crypto.subtle.digest("SHA-256", contents); + return Buffer.from(digest).toString("hex"); +} + +function checkpointDirectory(rootPath: string) { + return resolve(rootPath, HISTORY_DIRECTORY, "checkpoints"); +} + +function checkpointManifest(rootPath: string, id: string) { + return resolve(checkpointDirectory(rootPath), `${id}.json`); +} + +function checkpointBlob(rootPath: string, id: string) { + return resolve(checkpointDirectory(rootPath), `${id}.before`); +} + /** Containment, as a path comparison that cannot be fooled by a shared prefix. */ function assertInside(root: string, candidate: string, shown?: string): void { const rel = relative(root, candidate); diff --git a/agent-computer/tests/workspace.test.ts b/agent-computer/tests/workspace.test.ts index ebd0d36..65f00dc 100644 --- a/agent-computer/tests/workspace.test.ts +++ b/agent-computer/tests/workspace.test.ts @@ -133,6 +133,48 @@ describe("browser downloads", () => { }); }); +describe("workspace Time Machine", () => { + test("diffs and selectively rolls back a Bot write, then undoes the rollback", async () => { + const ws = workspace(); + await ws.write("plan.md", "version one"); + const second = await ws.write("plan.md", "version two"); + + const diff = await ws.checkpointDiff(second.checkpointId, "plan.md"); + expect(diff.before).toBe("version one"); + expect(diff.current).toBe("version two"); + expect(diff.changedSinceWrite).toBe(false); + + const restored = await ws.rollback(second.checkpointId); + expect((await ws.read("plan.md")).text).toBe("version one"); + + await ws.rollback(restored.undoCheckpointId); + expect((await ws.read("plan.md")).text).toBe("version two"); + }); + + test("preserves a later human edit by default", async () => { + const ws = workspace(); + await ws.write("plan.md", "before"); + const checkpoint = await ws.write("plan.md", "after Bot"); + await writeFile(join(root, "plan.md"), "human edit", "utf8"); + + await expect(ws.rollback(checkpoint.checkpointId)).rejects.toThrow( + "changed after the Bot wrote it", + ); + expect((await ws.read("plan.md")).text).toBe("human edit"); + }); + + test("keeps checkpoint internals out of workspace paths and listings", async () => { + const ws = workspace(); + await ws.write("notes.md", "kept"); + expect((await ws.list()).entries.map((entry) => entry.path)).toEqual([ + "notes.md", + ]); + await expect( + ws.read(".openbot-history/checkpoints/anything"), + ).rejects.toThrow(WorkspacePathError); + }); +}); + describe("listing the workspace", () => { test("lists files and folders, recursively, relative to the root", async () => { const ws = workspace(); diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 2deef0a..009bf05 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -20,7 +20,10 @@ import { } from "@/components/channels/transcript-messages"; import { agentListQueryOptions } from "@/lib/agents/queries"; import { useMessageTimes } from "@/lib/channels/message-times"; -import { recordChannelActivityMutationOptions } from "@/lib/channels/mutations"; +import { + indexContinuityMessageMutationOptions, + recordChannelActivityMutationOptions, +} from "@/lib/channels/mutations"; import type { AgentChannel } from "@/lib/channels/queries"; import { useActiveBot } from "@/lib/copilot/active-bot"; import { @@ -31,6 +34,7 @@ import { import { ConversationProvider } from "@/lib/copilot/conversation"; import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history"; import { stoppedReason } from "@/lib/copilot/stopped-turn"; +import { resolveContextReferences } from "@/lib/continuity/context-references"; import { useSkillCommands } from "@/lib/plugins/skill-commands"; /** @@ -267,15 +271,31 @@ export function ChannelChat({ * Tell the roster what was just said. Failures here must not block the conversation. */ const recordActivity = useMutation(recordChannelActivityMutationOptions()); - const report = (text: string, agentId: string | null) => { + const indexMessage = useMutation(indexContinuityMessageMutationOptions()); + const report = ( + text: string, + agentId: string | null, + messageId: string, + role: "user" | "assistant", + ) => { const trimmed = text.trim(); if (!trimmed) return; + const occurredAt = new Date().toISOString(); recordActivity.mutate({ agentId, - at: new Date().toISOString(), + at: occurredAt, channelId: channel.id, text: trimmed, }); + indexMessage.mutate({ + channelId: channel.id, + threadId: channel.threadId, + messageId, + agentId, + role, + content: trimmed, + occurredAt, + }); }; const reportRef = useRef(report); reportRef.current = report; @@ -288,6 +308,7 @@ export function ChannelChat({ trimmed: string, skillInstructions: string[], attachments: readonly ComposerAttachment[], + skillIds: readonly string[] = [], ) => { // Wait briefly for the runtime agent instance before adding the message. if (!isReadyRef.current) { @@ -302,6 +323,18 @@ export function ChannelChat({ setRunError(null); awaitingReply.current = true; + const resolvedContext = await resolveContextReferences( + trimmed, + runtimeAgentId, + ); + if (resolvedContext.context) { + agent.addMessage({ + content: resolvedContext.context, + id: crypto.randomUUID(), + role: "system", + }); + } + /* * THE SKILL GOES IN FRONT OF THE MESSAGE, AS A SYSTEM TURN. A `/` chip is one token in the * composer; what it stands for is the instruction added here, ahead of what the person typed, so @@ -322,6 +355,7 @@ export function ChannelChat({ }); } + const userMessageId = crypto.randomUUID(); agent.addMessage({ /* * A string when it is only words, parts when files ride along — the projection on the other @@ -334,14 +368,31 @@ export function ChannelChat({ ...(trimmed ? [{ type: "text" as const, text: trimmed }] : []), ...attachments.map(attachmentInputPart), ], - id: crypto.randomUUID(), + id: userMessageId, role: "user", }); report( trimmed || `Shared ${attachments.length} file${attachments.length === 1 ? "" : "s"}.`, null, + userMessageId, + "user", ); + for (const skillId of skillIds) { + void fetch( + `/api/continuity/skills/${encodeURIComponent(skillId)}/usage`, + { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + agentId: runtimeAgentId, + channelId: channel.id, + outcome: "invoked", + }), + }, + ).catch(() => undefined); + } // Providers reject later turns if prior tool calls have no result; repair before sending. const repaired = repairUnansweredToolCalls(agent.messages); @@ -375,13 +426,14 @@ export function ChannelChat({ text: string, skillInstructions: string[] = [], attachments: readonly ComposerAttachment[] = [], + skillIds: readonly string[] = [], ) => { const trimmed = text.trim(); if (!trimmed && attachments.length === 0) return; setTurnsInFlight((count) => count + 1); try { - await deliver(trimmed, skillInstructions, attachments); + await deliver(trimmed, skillInstructions, attachments, skillIds); } finally { setTurnsInFlight((count) => count - 1); } @@ -407,7 +459,9 @@ export function ChannelChat({ .reverse() .find((message) => message.role === "assistant"); const content = typeof reply?.content === "string" ? reply.content : ""; - if (content) reportRef.current(content, runtimeAgentId); + if (content && reply?.id) { + reportRef.current(content, runtimeAgentId, reply.id, "assistant"); + } }, }); return () => subscription?.unsubscribe(); @@ -569,7 +623,19 @@ export function ChannelChat({ Boolean(instruction), ); - await say(draft.text, skillInstructions, draft.attachments ?? []); + const skillIds = draft.commandIds + .map( + (id) => + skillCommands.find((command) => command.id === id)?.sourceId, + ) + .filter((id): id is string => Boolean(id)); + + await say( + draft.text, + skillInstructions, + draft.attachments ?? [], + skillIds, + ); }} /** * Stop through the core so the abort signal reaches frontend tools; `say` repairs any diff --git a/app/src/components/channels/compose-state.ts b/app/src/components/channels/compose-state.ts index cdf3154..375aaa7 100644 --- a/app/src/components/channels/compose-state.ts +++ b/app/src/components/channels/compose-state.ts @@ -10,13 +10,12 @@ export type Recipient = { }; /** - * One coworker per channel. - * - * Matches the chat screen's current one-coworker render contract. + * A bounded Workroom roster. Six supports useful specialist teams without turning identity and + * tool ownership into an unreadable crowd. */ -export const MAX_RECIPIENTS = 1; +export const MAX_RECIPIENTS = 6; -/** Add a coworker, replacing the oldest once the channel recipient cap is reached. */ +/** Add a coworker while preserving the existing roster once the room cap is reached. */ export function addRecipient( current: readonly Recipient[], next: Recipient, @@ -24,7 +23,7 @@ export function addRecipient( if (current.some((recipient) => recipient.id === next.id)) { return [...current]; } - return [...current, next].slice(-MAX_RECIPIENTS); + return current.length >= MAX_RECIPIENTS ? [...current] : [...current, next]; } export function removeRecipient( @@ -39,5 +38,9 @@ export function canSend( recipients: readonly Recipient[], text: string, ): boolean { - return recipients.length === MAX_RECIPIENTS && text.trim().length > 0; + return ( + recipients.length > 0 && + recipients.length <= MAX_RECIPIENTS && + text.trim().length > 0 + ); } diff --git a/app/src/components/channels/composer/draft.ts b/app/src/components/channels/composer/draft.ts index 7284af3..a3fa5bc 100644 --- a/app/src/components/channels/composer/draft.ts +++ b/app/src/components/channels/composer/draft.ts @@ -87,6 +87,8 @@ export type CommandKind = "chip" | "prompt" | "action"; export type CommandOption = { id: string; + /** Stable source record for usage/provenance ledgers; not rendered into the draft. */ + sourceId?: string; name: string; description?: string; /** Defaults to `chip`. */ diff --git a/app/src/components/work/continuity-view.tsx b/app/src/components/work/continuity-view.tsx new file mode 100644 index 0000000..804f7cf --- /dev/null +++ b/app/src/components/work/continuity-view.tsx @@ -0,0 +1,975 @@ +import { + IconArchive, + IconBolt, + IconBox, + IconCheck, + IconClock, + IconFileDiff, + IconHistory, + IconPlus, + IconSearch, + IconShieldCheck, +} from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import type { AgentProfile } from "@/lib/agents/queries"; +import type { AgentChannel } from "@/lib/channels/queries"; +import { + continuityKeys, + continuityOverviewQueryOptions, + continuityRequest, + continuitySearchQueryOptions, + type ToolProgram, +} from "@/lib/continuity/queries"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +type ContinuitySection = + | "context" + | "history" + | "programs" + | "skills" + | "bundles"; + +export function ContinuityView({ + agents, + channels, +}: { + agents: AgentProfile[]; + channels: AgentChannel[]; +}) { + const [section, setSection] = useState("context"); + const overview = useQuery(continuityOverviewQueryOptions()); + const queryClient = useQueryClient(); + const [problem, setProblem] = useState(null); + const action = useMutation({ + mutationFn: (job: () => Promise) => job(), + onSuccess: () => { + setProblem(null); + void queryClient.invalidateQueries({ queryKey: continuityKeys.all }); + }, + onError: (error) => + setProblem(error instanceof Error ? error.message : "That did not work."), + }); + + if (overview.isPending) { + return ( +

Loading continuity…

+ ); + } + if (!overview.data) { + return ( +

+ Continuity could not be loaded. +

+ ); + } + + const data = overview.data; + const sections = [ + ["context", "Context Vault", IconSearch], + ["history", "Time Machine", IconHistory], + ["programs", "Tool programs", IconBolt], + ["skills", "Skill health", IconShieldCheck], + ["bundles", "Bot bundles", IconBox], + ] as const; + + return ( +
+
+ + + program.status === "approved") + .length + } + /> +
+ + {problem ? ( +

+ {problem} +

+ ) : null} + + {section === "context" ? ( + + ) : section === "history" ? ( + + ) : section === "programs" ? ( + + ) : section === "skills" ? ( + + ) : ( + + )} +
+ ); +} + +function ContextVault({ + channels, + snapshots, +}: { + channels: AgentChannel[]; + snapshots: Array<{ + id: string; + channelId: string; + summary: string; + sourceMessageCount: number; + tokenEstimate: number; + createdAt: string; + }>; +}) { + const [query, setQuery] = useState(""); + const [channelId, setChannelId] = useState(""); + const results = useQuery( + continuitySearchQueryOptions(query, channelId || undefined), + ); + return ( +
+ +
+
+ + setQuery(event.target.value)} + placeholder="Search decisions, errors, names…" + value={query} + /> +
+ + + {channels.map((channel) => ( + + ))} + +
+
+ {query.trim().length < 2 ? ( + Type at least two characters to search. + ) : results.isFetching ? ( + Searching the vault… + ) : results.data?.length ? ( + results.data.map((result) => ( +
+
+ + {result.role} · {new Date(result.occurredAt).toLocaleString()} + + +
+

+ {result.content} +

+
+ )) + ) : ( + No indexed messages match. + )} +
+ +
+ {snapshots.length ? ( + snapshots.map((snapshot) => ( +
+
+ + {new Date(snapshot.createdAt).toLocaleString()} ·{" "} + {snapshot.sourceMessageCount} messages · ~ + {snapshot.tokenEstimate} tokens +
+

+ {snapshot.summary} +

+
+ )) + ) : ( + No recovery snapshots yet. + )} +
+
+ ); +} + +type Checkpoint = { + id: string; + path: string; + operation: string; + bytesBefore: number; + createdAt: string; +}; + +function TimeMachine({ + action, + agents, +}: { + action: Action; + agents: AgentProfile[]; +}) { + const [agentId, setAgentId] = useState(agents[0]?.id ?? ""); + const activeAgentId = agents.some((agent) => agent.id === agentId) + ? agentId + : (agents[0]?.id ?? ""); + const [revision, setRevision] = useState(0); + const [diff, setDiff] = useState<{ + path: string; + before: string; + current: string; + changedSinceWrite: boolean; + } | null>(null); + const history = useQuery({ + enabled: Boolean(activeAgentId), + queryKey: ["continuity", "checkpoints", activeAgentId, revision], + queryFn: () => + computerPost<{ checkpoints: Checkpoint[] }>( + activeAgentId, + "/files/checkpoints", + {}, + ), + }); + return ( +
+ +
+ + {agents.map((agent) => ( + + ))} + + +
+ {diff ? ( +
+
+

{diff.path}

+ +
+ {diff.changedSinceWrite ? ( +

+ This file changed after the Bot wrote it; normal rollback will + preserve those edits. +

+ ) : null} +
+ {diff.before} + {diff.current} +
+
+ ) : null} +
+ {history.data?.checkpoints.length ? ( + history.data.checkpoints.map((checkpoint) => ( +
+
+

{checkpoint.path}

+

+ {checkpoint.operation} ·{" "} + {checkpoint.bytesBefore.toLocaleString()} bytes before ·{" "} + {new Date(checkpoint.createdAt).toLocaleString()} +

+
+
+ + +
+
+ )) + ) : ( + + No checkpoints yet. The next Bot file write creates one + automatically. + + )} +
+
+ ); +} + +function Programs({ + action, + agents, + programs, +}: { + action: Action; + agents: AgentProfile[]; + programs: ToolProgram[]; +}) { + return ( +
+ } + title="Governed tool programs" + description="Reviewed sequences call only already-granted tools. Each step keeps policy, approval, credential, and audit enforcement; only the final output returns to model context." + /> +
+ {programs.length ? ( + programs.map((program) => ( +
+
+
+ +

{program.name}

+
+ + v{program.revision} + +
+

+ {program.description || "No description"} +

+ + Program ID: {program.id} + +
    + {program.steps.map((step, index) => ( +
  1. + + {index + 1}. {step.tool} + +
    +                      {JSON.stringify(step.arguments, null, 2)}
    +                    
    +
  2. + ))} +
+ +
+ )) + ) : ( + No tool programs yet. + )} +
+
+ ); +} + +function ProgramDialog({ + action, + agents, +}: { + action: Action; + agents: AgentProfile[]; +}) { + const [open, setOpen] = useState(false); + const [agentId, setAgentId] = useState(agents[0]?.id ?? ""); + const activeAgentId = agents.some((agent) => agent.id === agentId) + ? agentId + : (agents[0]?.id ?? ""); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [steps, setSteps] = useState( + '[\n { "tool": "tool_name", "arguments": {} }\n]', + ); + return ( + + }> + New program + + + + New tool program + + Save as draft, inspect every fixed argument, then approve it. + + + + + + {agents.map((agent) => ( + + ))} + + + + setName(event.target.value)} + value={name} + /> + + + setDescription(event.target.value)} + value={description} + /> + + +