diff options
Diffstat (limited to 'packages/conversation-store/src/store.ts')
| -rw-r--r-- | packages/conversation-store/src/store.ts | 2569 |
1 files changed, 1355 insertions, 1214 deletions
diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 2fd0a0c..41df92f 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -1,266 +1,304 @@ import { resolve as pathResolve } from "node:path"; import type { - ChatMessage, - Chunk, - ConversationMeta, - ConversationStatus, - Logger, - ReasoningEffort, - Role, - StorageNamespace, - StoredChunk, - TurnMetrics, + ChatMessage, + Chunk, + ConversationMeta, + ConversationStatus, + Logger, + ReasoningEffort, + Role, + StorageNamespace, + StoredChunk, + TurnMetrics, } from "@dispatch/kernel"; import { defineService } from "@dispatch/kernel"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import { - CONVERSATION_INDEX_KEY, - chunkKey, - chunkPrefix, - compactThresholdKey, - computerKey, - cwdKey, - metaKey, - metricsKey, - metricsPrefix, - metricsSeqKey, - modelKey, - parseSeq, - reasoningEffortKey, - seqKey, - workspaceKey, + CONVERSATION_INDEX_KEY, + chunkKey, + chunkPrefix, + compactThresholdKey, + computerKey, + cwdKey, + imageTranscriptionsKey, + metaKey, + metricsKey, + metricsPrefix, + metricsSeqKey, + modelKey, + parseSeq, + reasoningEffortKey, + seqKey, + VISION_SETTINGS_KEY, + workspaceKey, } from "./keys.js"; import { reconcileWithReport } from "./reconcile.js"; export interface ConversationStore { - readonly append: (conversationId: string, messages: readonly ChatMessage[]) => Promise<void>; - readonly load: (conversationId: string) => Promise<ChatMessage[]>; - /** - * Read the conversation's persisted chunks as a SELECTION + optional WINDOW, - * ascending by seq. The raw append-order log; NOT reconciled (a dangling - * tool-call is returned as-is — repair is a turn-path concern). - * - * - **Selection** — `sinceSeq` is an exclusive lower bound (`seq > sinceSeq`; - * omitted/`0`/non-positive/non-integer = from the start). When - * `window.beforeSeq` is given it is an exclusive upper bound - * (`seq < beforeSeq`). Together: `sinceSeq < seq < beforeSeq`. - * - **Window** — `window.limit` returns only the NEWEST `limit` chunks of the - * selection; the result STAYS ASCENDING by seq. A selection with ≤ `limit` - * chunks is returned whole (exact, not truncated). - * - **Omitted = unchanged** — `window` absent (or both its fields undefined) - * is byte-identical to the pre-windowing behavior, so existing callers that - * pass no third argument are unaffected. - * - **Garbage-in is forgiving** — a non-positive or non-integer `limit` (or - * `beforeSeq`) is treated as ABSENT (full selection); this method never - * throws on bad window input. The transport validates and 400s upstream. - * - * Seq numbering is 1-based and gap-free, so a client derives "older chunks - * exist" purely from the oldest returned `seq > 1`; there is deliberately no - * `earliestSeq`/high-water-mark API. - */ - readonly loadSince: ( - conversationId: string, - sinceSeq?: number, - window?: { readonly beforeSeq?: number; readonly limit?: number }, - ) => Promise<readonly StoredChunk[]>; - readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise<void>; - readonly loadMetrics: (conversationId: string) => Promise<readonly TurnMetrics[]>; - /** The persisted working directory for a conversation, or null if never set. */ - readonly getCwd: (conversationId: string) => Promise<string | null>; - /** Persist (upsert) the working directory for a conversation. */ - readonly setCwd: (conversationId: string, cwd: string) => Promise<void>; - /** Clear (delete) the persisted working directory for a conversation. */ - readonly clearCwd: (conversationId: string) => Promise<void>; - /** - * The persisted computer (SSH config `Host` alias) for a conversation, or - * `null` if never set (local). The computer analog of `getCwd`. - */ - readonly getComputerId: (conversationId: string) => Promise<string | null>; - /** - * Persist (upsert) the computer for a conversation. Passing `null` clears - * the persisted selection (idempotent) — `null` is the "local" sentinel - * (no SSH), so it must NOT linger to shadow the workspace default. Mirrors - * `setModel`'s clear-on-sentinel pattern (the computer analog of `setCwd`). - */ - readonly setComputerId: (conversationId: string, alias: string | null) => Promise<void>; - /** Clear (delete) the persisted computer for a conversation. */ - readonly clearComputerId: (conversationId: string) => Promise<void>; - /** The persisted reasoning-effort level for a conversation, or null if never set. */ - readonly getReasoningEffort: (conversationId: string) => Promise<ReasoningEffort | null>; - /** Persist (upsert) the reasoning-effort level for a conversation. */ - readonly setReasoningEffort: (conversationId: string, effort: ReasoningEffort) => Promise<void>; - /** The persisted model name for a conversation, or null if never set. */ - readonly getModel: (conversationId: string) => Promise<string | null>; - /** - * Persist (upsert) the model name for a conversation (a model name in - * `<credentialName>/<model>` form). Passing an empty string clears the - * persisted selection (idempotent) — this is how transport-http clears via - * `PUT /conversations/:id/model` with a `null` body. - */ - readonly setModel: (conversationId: string, model: string) => Promise<void>; - /** - * List all known conversations, sorted by `lastActivityAt` descending (most - * recent first). Metadata (createdAt, lastActivityAt, title) is tracked - * automatically on append; title defaults to the first user message. - */ - readonly listConversations: (filter?: { - readonly status?: readonly ConversationStatus[]; - readonly workspaceId?: string; - }) => Promise<readonly ConversationMeta[]>; - /** Single conversation metadata, or null if unknown. */ - readonly getConversationMeta: (conversationId: string) => Promise<ConversationMeta | null>; - /** Set/update the human-readable title for a conversation. */ - readonly setConversationTitle: (conversationId: string, title: string) => Promise<void>; - /** Get the lifecycle status of a conversation, or null if unknown. */ - readonly getConversationStatus: (conversationId: string) => Promise<ConversationStatus | null>; - /** Set the lifecycle status of a conversation. Creates a minimal metadata row if missing. */ - readonly setConversationStatus: ( - conversationId: string, - status: ConversationStatus, - ) => Promise<void>; - /** - * Replace the entire conversation history with the given messages. Deletes - * all existing chunks, resets the seq counter, and appends the new messages. - * Used by compaction to replace old history with a summary + recent messages. - * Metadata (createdAt, title, status) is preserved. - */ - readonly replaceHistory: ( - conversationId: string, - messages: readonly ChatMessage[], - ) => Promise<void>; - /** - * Fork (copy) the full conversation history from `sourceId` to `targetId`. - * Copies all chunks, metadata, cwd, reasoning-effort, and model. The - * target's status is set to "closed" (it's an archive) and `compactedFrom` - * is set to `sourceId`. Used by compaction to preserve the pre-compaction - * history non-destructively before replacing it with a summary. - */ - readonly forkHistory: (sourceId: string, targetId: string) => Promise<void>; - /** Get the compact percent (0-100, 0 = manual only), or null if unset. */ - readonly getCompactPercent: (conversationId: string) => Promise<number | null>; - /** Set the compact percent (0-100, 0 = manual only). */ - readonly setCompactPercent: (conversationId: string, percent: number) => Promise<void>; - /** - * Set the `compactedFrom` field on a conversation's metadata, pointing to - * the archive conversation that holds the pre-compaction history. - */ - readonly setCompactedFrom: (conversationId: string, newConversationId: string) => Promise<void>; - /** - * Returns the workspace, or synthesizes `"default"` if `id === "default"` - * and it was never persisted (title `"default"`, defaultCwd `null`, - * timestamps `0`). Returns `null` for any other non-existent id. - */ - readonly getWorkspace: (id: string) => Promise<Workspace | null>; - /** - * Create-on-miss: if absent, create with `title = opts.title ?? id`, - * `defaultCwd = opts.defaultCwd ?? null`, `createdAt/lastActivityAt = now`. - * If present, return as-is (ignore `opts`). The `"default"` workspace is - * always returned as-is (never re-created). This is the `PUT - * /workspaces/:id` handler. - */ - readonly ensureWorkspace: ( - id: string, - opts?: { - readonly title?: string; - readonly defaultCwd?: string | null; - readonly defaultComputerId?: string | null; - }, - ) => Promise<Workspace>; - /** Rename a workspace. Creates the workspace if missing. */ - readonly setWorkspaceTitle: (id: string, title: string) => Promise<Workspace>; - /** Set/clear a workspace's default cwd. Creates the workspace if missing. */ - readonly setWorkspaceDefaultCwd: (id: string, defaultCwd: string | null) => Promise<Workspace>; - /** - * Set/clear a workspace's default computer (SSH alias). Creates the - * workspace if missing. The computer analog of `setWorkspaceDefaultCwd`. - * `null` = local (no SSH). - */ - readonly setWorkspaceDefaultComputerId: ( - id: string, - defaultComputerId: string | null, - ) => Promise<Workspace>; - /** - * Delete a workspace: (1) find all conversations with `workspaceId === id`, - * (2) set each to `status = "closed"` and reassign `workspaceId = "default"`, - * (3) delete the workspace entity. Returns `closedCount`. Throws if `id - * === "default"`. - */ - readonly deleteWorkspace: (id: string) => Promise<{ closedCount: number }>; - /** - * All workspaces sorted by `lastActivityAt` descending. Each entry includes - * `conversationCount`. Always includes `"default"` (synthesized if not - * persisted, with the count of legacy/unassigned conversations). - */ - readonly listWorkspaces: () => Promise<readonly WorkspaceEntry[]>; - /** - * Returns the conversation's workspaceId, or `"default"` if the - * conversation has no workspaceId persisted (or doesn't exist). - */ - readonly getWorkspaceId: (conversationId: string) => Promise<string>; - /** - * Persist the conversation's workspace assignment. If the conversation - * doesn't exist yet, create a minimal metadata row (like - * `setConversationStatus` does). - */ - readonly setWorkspaceId: (conversationId: string, workspaceId: string) => Promise<void>; - /** - * Resolve the effective working directory for a conversation: - * - * 1. **Absolute conversation cwd** — an explicit per-conversation cwd - * (`getCwd`, or `overrideCwd` when provided) that starts with `/` - * overrides outright. - * 2. **Relative conversation cwd** — an explicit cwd that does NOT start - * with `/` is resolved against the workspace `defaultCwd` (or - * `serverDefaultCwd` when the workspace has no `defaultCwd`) via - * `path.resolve`. - * 3. **No conversation cwd** — the workspace `defaultCwd` is used. - * 4. **Neither set** — the `serverDefaultCwd` (defaulting to - * `process.cwd()` at construction time) is used. - * - * The workspace is resolved via `getWorkspaceId` (falling back to - * `"default"`) + `getWorkspace`. - * - * @param overrideCwd — an explicit cwd to resolve INSTEAD of the persisted - * `getCwd` value. When provided (not `undefined`), it is fed through the - * same algorithm above (absolute → returned as-is; relative → resolved - * against the workspace `defaultCwd`). Used by the session-orchestrator - * for a per-turn cwd override (sent by the client on `chat.send`) so a - * transient relative cwd is resolved the same way a persisted one is, - * instead of being resolved against `process.cwd()`. When omitted, the - * persisted `getCwd` is read as today. - */ - readonly getEffectiveCwd: ( - conversationId: string, - overrideCwd?: string, - ) => Promise<string | null>; - /** - * Resolve the effective computer (SSH alias) for a conversation — the - * computer analog of `getEffectiveCwd`. Resolution ladder: - * - * 1. **overrideAlias** — an explicit per-turn alias (from `chat.send`) - * wins outright, EVEN when `null` (explicitly local for this turn — it - * does NOT fall through). - * 2. **Persisted per-conversation `computerId`** — `getComputerId`. - * 3. **Workspace `defaultComputerId`** — resolved via `getWorkspaceId` - * (falling back to `"default"`) + `getWorkspace`. - * 4. **None of the above** — `null` (LOCAL: no SSH, today's behavior). - * - * Returns the alias STRING (or `null`); it does NOT validate the alias - * exists in `~/.ssh/config` (validation happens at connect time — a stale - * alias yields a clear connect error rather than silently falling back to - * local). - * - * @param overrideAlias — an explicit alias to resolve INSTEAD of the - * persisted `getComputerId` value. When provided (not `undefined`), it - * is returned as-is (string or `null`), short-circuiting the rest of the - * ladder. Used by the session-orchestrator for a per-turn computer - * override (sent by the client on `chat.send`). When omitted, the - * persisted `getComputerId` is read as today. - */ - readonly getEffectiveComputer: ( - conversationId: string, - overrideAlias?: string | null, - ) => Promise<string | null>; + readonly append: (conversationId: string, messages: readonly ChatMessage[]) => Promise<void>; + readonly load: (conversationId: string) => Promise<ChatMessage[]>; + /** + * Read the conversation's persisted chunks as a SELECTION + optional WINDOW, + * ascending by seq. The raw append-order log; NOT reconciled (a dangling + * tool-call is returned as-is — repair is a turn-path concern). + * + * - **Selection** — `sinceSeq` is an exclusive lower bound (`seq > sinceSeq`; + * omitted/`0`/non-positive/non-integer = from the start). When + * `window.beforeSeq` is given it is an exclusive upper bound + * (`seq < beforeSeq`). Together: `sinceSeq < seq < beforeSeq`. + * - **Window** — `window.limit` returns only the NEWEST `limit` chunks of the + * selection; the result STAYS ASCENDING by seq. A selection with ≤ `limit` + * chunks is returned whole (exact, not truncated). + * - **Omitted = unchanged** — `window` absent (or both its fields undefined) + * is byte-identical to the pre-windowing behavior, so existing callers that + * pass no third argument are unaffected. + * - **Garbage-in is forgiving** — a non-positive or non-integer `limit` (or + * `beforeSeq`) is treated as ABSENT (full selection); this method never + * throws on bad window input. The transport validates and 400s upstream. + * + * Seq numbering is 1-based and gap-free, so a client derives "older chunks + * exist" purely from the oldest returned `seq > 1`; there is deliberately no + * `earliestSeq`/high-water-mark API. + */ + readonly loadSince: ( + conversationId: string, + sinceSeq?: number, + window?: { readonly beforeSeq?: number; readonly limit?: number }, + ) => Promise<readonly StoredChunk[]>; + readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise<void>; + readonly loadMetrics: (conversationId: string) => Promise<readonly TurnMetrics[]>; + /** The persisted working directory for a conversation, or null if never set. */ + readonly getCwd: (conversationId: string) => Promise<string | null>; + /** Persist (upsert) the working directory for a conversation. */ + readonly setCwd: (conversationId: string, cwd: string) => Promise<void>; + /** Clear (delete) the persisted working directory for a conversation. */ + readonly clearCwd: (conversationId: string) => Promise<void>; + /** + * The persisted computer (SSH config `Host` alias) for a conversation, or + * `null` if never set (local). The computer analog of `getCwd`. + */ + readonly getComputerId: (conversationId: string) => Promise<string | null>; + /** + * Persist (upsert) the computer for a conversation. Passing `null` clears + * the persisted selection (idempotent) — `null` is the "local" sentinel + * (no SSH), so it must NOT linger to shadow the workspace default. Mirrors + * `setModel`'s clear-on-sentinel pattern (the computer analog of `setCwd`). + */ + readonly setComputerId: (conversationId: string, alias: string | null) => Promise<void>; + /** Clear (delete) the persisted computer for a conversation. */ + readonly clearComputerId: (conversationId: string) => Promise<void>; + /** The persisted reasoning-effort level for a conversation, or null if never set. */ + readonly getReasoningEffort: (conversationId: string) => Promise<ReasoningEffort | null>; + /** Persist (upsert) the reasoning-effort level for a conversation. */ + readonly setReasoningEffort: (conversationId: string, effort: ReasoningEffort) => Promise<void>; + /** The persisted model name for a conversation, or null if never set. */ + readonly getModel: (conversationId: string) => Promise<string | null>; + /** + * Persist (upsert) the model name for a conversation (a model name in + * `<credentialName>/<model>` form). Passing an empty string clears the + * persisted selection (idempotent) — this is how transport-http clears via + * `PUT /conversations/:id/model` with a `null` body. + */ + readonly setModel: (conversationId: string, model: string) => Promise<void>; + /** + * List all known conversations, sorted by `lastActivityAt` descending (most + * recent first). Metadata (createdAt, lastActivityAt, title) is tracked + * automatically on append; title defaults to the first user message. + */ + readonly listConversations: (filter?: { + readonly status?: readonly ConversationStatus[]; + readonly workspaceId?: string; + }) => Promise<readonly ConversationMeta[]>; + /** Single conversation metadata, or null if unknown. */ + readonly getConversationMeta: (conversationId: string) => Promise<ConversationMeta | null>; + /** Set/update the human-readable title for a conversation. */ + readonly setConversationTitle: (conversationId: string, title: string) => Promise<void>; + /** Get the lifecycle status of a conversation, or null if unknown. */ + readonly getConversationStatus: (conversationId: string) => Promise<ConversationStatus | null>; + /** Set the lifecycle status of a conversation. Creates a minimal metadata row if missing. */ + readonly setConversationStatus: ( + conversationId: string, + status: ConversationStatus, + ) => Promise<void>; + /** + * Replace the entire conversation history with the given messages. Deletes + * all existing chunks, resets the seq counter, and appends the new messages. + * Used by compaction to replace old history with a summary + recent messages. + * Metadata (createdAt, title, status) is preserved. + */ + readonly replaceHistory: ( + conversationId: string, + messages: readonly ChatMessage[], + ) => Promise<void>; + /** + * Fork (copy) the full conversation history from `sourceId` to `targetId`. + * Copies all chunks, metadata, cwd, reasoning-effort, and model. The + * target's status is set to "closed" (it's an archive) and `compactedFrom` + * is set to `sourceId`. Used by compaction to preserve the pre-compaction + * history non-destructively before replacing it with a summary. + */ + readonly forkHistory: (sourceId: string, targetId: string) => Promise<void>; + /** Get the compact percent (0-100, 0 = manual only), or null if unset. */ + readonly getCompactPercent: (conversationId: string) => Promise<number | null>; + /** Set the compact percent (0-100, 0 = manual only). */ + readonly setCompactPercent: (conversationId: string, percent: number) => Promise<void>; + /** + * Get the per-conversation image transcription cache: a map of image URL → + * transcription text. Used by the vision handoff to avoid re-transcribing + * old images that were compacted to text on a previous turn. Returns an + * empty map when none are cached. + */ + readonly getImageTranscriptions: (conversationId: string) => Promise<ReadonlyMap<string, string>>; + /** + * Upsert a single image transcription into the per-conversation cache. + * Merges with any existing transcriptions (does NOT replace the whole map). + */ + readonly setImageTranscription: ( + conversationId: string, + imageUrl: string, + transcription: string, + ) => Promise<void>; + /** + * Get the global vision settings (image compaction limit + compaction model). + * The limit defaults to 10 when never set; the compaction model defaults to + * null (auto-select). Shared across ALL conversations and vision models. + */ + readonly getVisionSettings: () => Promise<{ + readonly imageLimit: number; + readonly compactionModel: string | null; + }>; + /** Set the global vision image compaction limit (0 = disabled). */ + readonly setVisionImageLimit: (limit: number) => Promise<void>; + /** Set the global vision compaction model (null = auto-select). */ + readonly setVisionCompactionModel: (model: string | null) => Promise<void>; + /** + * Set the `compactedFrom` field on a conversation's metadata, pointing to + * the archive conversation that holds the pre-compaction history. + */ + readonly setCompactedFrom: (conversationId: string, newConversationId: string) => Promise<void>; + /** + * Returns the workspace, or synthesizes `"default"` if `id === "default"` + * and it was never persisted (title `"default"`, defaultCwd `null`, + * timestamps `0`). Returns `null` for any other non-existent id. + */ + readonly getWorkspace: (id: string) => Promise<Workspace | null>; + /** + * Create-on-miss: if absent, create with `title = opts.title ?? id`, + * `defaultCwd = opts.defaultCwd ?? null`, `createdAt/lastActivityAt = now`. + * If present, return as-is (ignore `opts`). The `"default"` workspace is + * always returned as-is (never re-created). This is the `PUT + * /workspaces/:id` handler. + */ + readonly ensureWorkspace: ( + id: string, + opts?: { + readonly title?: string; + readonly defaultCwd?: string | null; + readonly defaultComputerId?: string | null; + }, + ) => Promise<Workspace>; + /** Rename a workspace. Creates the workspace if missing. */ + readonly setWorkspaceTitle: (id: string, title: string) => Promise<Workspace>; + /** Set/clear a workspace's default cwd. Creates the workspace if missing. */ + readonly setWorkspaceDefaultCwd: (id: string, defaultCwd: string | null) => Promise<Workspace>; + /** + * Set/clear a workspace's default computer (SSH alias). Creates the + * workspace if missing. The computer analog of `setWorkspaceDefaultCwd`. + * `null` = local (no SSH). + */ + readonly setWorkspaceDefaultComputerId: ( + id: string, + defaultComputerId: string | null, + ) => Promise<Workspace>; + /** + * Star or unstar a workspace. Creates the workspace if missing (like + * `setWorkspaceTitle`). Starred workspaces receive PRIORITY in the + * concurrency limiter queue — their agents jump ahead of agents from + * non-starred workspaces (oldest-agent-first within each group). + */ + readonly setWorkspaceStarred: (id: string, starred: boolean) => Promise<Workspace>; + /** + * Delete a workspace: (1) find all conversations with `workspaceId === id`, + * (2) set each to `status = "closed"` and reassign `workspaceId = "default"`, + * (3) delete the workspace entity. Returns `closedCount`. Throws if `id + * === "default"`. + */ + readonly deleteWorkspace: (id: string) => Promise<{ closedCount: number }>; + /** + * All workspaces sorted by `lastActivityAt` descending. Each entry includes + * `conversationCount`. Always includes `"default"` (synthesized if not + * persisted, with the count of legacy/unassigned conversations). + */ + readonly listWorkspaces: () => Promise<readonly WorkspaceEntry[]>; + /** + * Returns the conversation's workspaceId, or `"default"` if the + * conversation has no workspaceId persisted (or doesn't exist). + */ + readonly getWorkspaceId: (conversationId: string) => Promise<string>; + /** + * Persist the conversation's workspace assignment. If the conversation + * doesn't exist yet, create a minimal metadata row (like + * `setConversationStatus` does). + */ + readonly setWorkspaceId: (conversationId: string, workspaceId: string) => Promise<void>; + /** + * Resolve the effective working directory for a conversation: + * + * 1. **Absolute conversation cwd** — an explicit per-conversation cwd + * (`getCwd`, or `overrideCwd` when provided) that starts with `/` + * overrides outright. + * 2. **Relative conversation cwd** — an explicit cwd that does NOT start + * with `/` is resolved against the workspace `defaultCwd` (or + * `serverDefaultCwd` when the workspace has no `defaultCwd`) via + * `path.resolve`. + * 3. **No conversation cwd** — the workspace `defaultCwd` is used. + * 4. **Neither set** — the `serverDefaultCwd` (defaulting to + * `process.cwd()` at construction time) is used. + * + * The workspace is resolved via `getWorkspaceId` (falling back to + * `"default"`) + `getWorkspace`. + * + * @param overrideCwd — an explicit cwd to resolve INSTEAD of the persisted + * `getCwd` value. When provided (not `undefined`), it is fed through the + * same algorithm above (absolute → returned as-is; relative → resolved + * against the workspace `defaultCwd`). Used by the session-orchestrator + * for a per-turn cwd override (sent by the client on `chat.send`) so a + * transient relative cwd is resolved the same way a persisted one is, + * instead of being resolved against `process.cwd()`. When omitted, the + * persisted `getCwd` is read as today. + */ + readonly getEffectiveCwd: ( + conversationId: string, + overrideCwd?: string, + ) => Promise<string | null>; + /** + * Resolve the effective computer (SSH alias) for a conversation — the + * computer analog of `getEffectiveCwd`. Resolution ladder: + * + * 1. **overrideAlias** — an explicit per-turn alias (from `chat.send`) + * wins outright, EVEN when `null` (explicitly local for this turn — it + * does NOT fall through). + * 2. **Persisted per-conversation `computerId`** — `getComputerId`. + * 3. **Workspace `defaultComputerId`** — resolved via `getWorkspaceId` + * (falling back to `"default"`) + `getWorkspace`. + * 4. **None of the above** — `null` (LOCAL: no SSH, today's behavior). + * + * Returns the alias STRING (or `null`); it does NOT validate the alias + * exists in `~/.ssh/config` (validation happens at connect time — a stale + * alias yields a clear connect error rather than silently falling back to + * local). + * + * @param overrideAlias — an explicit alias to resolve INSTEAD of the + * persisted `getComputerId` value. When provided (not `undefined`), it + * is returned as-is (string or `null`), short-circuiting the rest of the + * ladder. Used by the session-orchestrator for a per-turn computer + * override (sent by the client on `chat.send`). When omitted, the + * persisted `getComputerId` is read as today. + */ + readonly getEffectiveComputer: ( + conversationId: string, + overrideAlias?: string | null, + ) => Promise<string | null>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); @@ -270,9 +308,9 @@ export const conversationStoreHandle = defineService<ConversationStore>("convers * non-positive / non-integer / undefined input. Keeps `loadSince` total. */ function positiveInt(value: number | undefined): number | undefined { - if (value === undefined) return undefined; - if (!Number.isInteger(value) || value <= 0) return undefined; - return value; + if (value === undefined) return undefined; + if (!Number.isInteger(value) || value <= 0) return undefined; + return value; } /** @@ -285,16 +323,16 @@ function positiveInt(value: number | undefined): number | undefined { * ever passed (omitted / `0` / non-negative integers). */ function sinceSeqBase(value: number | undefined): number { - if (value === undefined) return 0; - if (!Number.isInteger(value) || value < 0) return 0; - return value; + if (value === undefined) return 0; + if (!Number.isInteger(value) || value < 0) return 0; + return value; } interface PersistedChunkEntry { - readonly chunk: Chunk; - readonly role: Role; - readonly msgIdx: number; - readonly chunkIdx: number; + readonly chunk: Chunk; + readonly role: Role; + readonly msgIdx: number; + readonly chunkIdx: number; } /** @@ -302,16 +340,16 @@ interface PersistedChunkEntry { * Maps to `ConversationMeta` (from `@dispatch/wire`) by adding the `id`. */ interface ConversationMetaRow { - readonly createdAt: number; - readonly lastActivityAt: number; - readonly title: string; - readonly status: ConversationStatus; - readonly compactedFrom?: string; - /** - * The workspace this conversation belongs to. Absent on legacy rows - * (read as `"default"`). Persisted only when explicitly assigned. - */ - readonly workspaceId?: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; + readonly status: ConversationStatus; + readonly compactedFrom?: string; + /** + * The workspace this conversation belongs to. Absent on legacy rows + * (read as `"default"`). Persisted only when explicitly assigned. + */ + readonly workspaceId?: string; } /** @@ -319,16 +357,22 @@ interface ConversationMetaRow { * is the key, so it is not duplicated in the row. */ interface WorkspaceRow { - readonly title: string; - readonly defaultCwd: string | null; - /** - * The workspace's default computer (SSH config `Host` alias) — the computer - * analog of `defaultCwd`. `null` = local (no SSH). Conversations in this - * workspace inherit it when they set no `computerId` of their own. - */ - readonly defaultComputerId: string | null; - readonly createdAt: number; - readonly lastActivityAt: number; + readonly title: string; + readonly defaultCwd: string | null; + /** + * The workspace's default computer (SSH config `Host` alias) — the computer + * analog of `defaultCwd`. `null` = local (no SSH). Conversations in this + * workspace inherit it when they set no `computerId` of their own. + */ + readonly defaultComputerId: string | null; + /** + * Whether the workspace is starred by the user. Starred workspaces receive + * PRIORITY in the concurrency limiter queue. Defaults to `false` on legacy + * rows (normalized by `parseWorkspaceRow`). + */ + readonly starred: boolean; + readonly createdAt: number; + readonly lastActivityAt: number; } /** Maximum title length (in characters) before truncation with an ellipsis. */ @@ -344,15 +388,15 @@ const TITLE_MAX = 80; * persisting. */ export function extractTitle(messages: readonly ChatMessage[]): string { - for (const msg of messages) { - if (msg.role !== "user") continue; - for (const chunk of msg.chunks) { - if (chunk.type === "text") { - return chunk.text.length > TITLE_MAX ? `${chunk.text.slice(0, TITLE_MAX)}…` : chunk.text; - } - } - } - return "Untitled"; + for (const msg of messages) { + if (msg.role !== "user") continue; + for (const chunk of msg.chunks) { + if (chunk.type === "text") { + return chunk.text.length > TITLE_MAX ? `${chunk.text.slice(0, TITLE_MAX)}…` : chunk.text; + } + } + } + return "Untitled"; } /** @@ -360,44 +404,44 @@ export function extractTitle(messages: readonly ChatMessage[]): string { * parse / shape failure so callers can treat a corrupt row as missing. */ function parseMetaRow(raw: string): ConversationMetaRow | null { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - if ( - typeof parsed !== "object" || - parsed === null || - typeof (parsed as ConversationMetaRow).createdAt !== "number" || - typeof (parsed as ConversationMetaRow).lastActivityAt !== "number" || - typeof (parsed as ConversationMetaRow).title !== "string" - ) { - return null; - } - const row = parsed as ConversationMetaRow; - const status: ConversationStatus = - row.status === "active" || row.status === "closed" ? row.status : "idle"; - return { - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - title: row.title, - status, - ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), - ...(row.workspaceId !== undefined ? { workspaceId: row.workspaceId } : {}), - }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as ConversationMetaRow).createdAt !== "number" || + typeof (parsed as ConversationMetaRow).lastActivityAt !== "number" || + typeof (parsed as ConversationMetaRow).title !== "string" + ) { + return null; + } + const row = parsed as ConversationMetaRow; + const status: ConversationStatus = + row.status === "active" || row.status === "closed" ? row.status : "idle"; + return { + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + title: row.title, + status, + ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), + ...(row.workspaceId !== undefined ? { workspaceId: row.workspaceId } : {}), + }; } function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { - return { - id, - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - title: row.title, - status: row.status, - workspaceId: row.workspaceId ?? "default", - ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), - }; + return { + id, + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + title: row.title, + status: row.status, + workspaceId: row.workspaceId ?? "default", + ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), + }; } /** @@ -406,7 +450,7 @@ function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { * to validate before hitting the store. Pure (input → boolean). */ export function isValidWorkspaceSlug(id: string): boolean { - return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(id); + return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(id); } /** The always-present, non-deletable default workspace id. */ @@ -417,897 +461,994 @@ const DEFAULT_WORKSPACE_ID = "default"; * shape failure so callers can treat a corrupt row as missing. */ function parseWorkspaceRow(raw: string): WorkspaceRow | null { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - if ( - typeof parsed !== "object" || - parsed === null || - typeof (parsed as WorkspaceRow).title !== "string" || - typeof (parsed as WorkspaceRow).createdAt !== "number" || - typeof (parsed as WorkspaceRow).lastActivityAt !== "number" - ) { - return null; - } - const row = parsed as WorkspaceRow; - // `defaultCwd` may be null OR a string; treat anything else as null. - const defaultCwd = typeof row.defaultCwd === "string" ? row.defaultCwd : null; - // `defaultComputerId` may be null OR a string; treat anything else as null - // (mirrors `defaultCwd`). Absent on legacy rows → null (local). - const defaultComputerId = - typeof row.defaultComputerId === "string" ? row.defaultComputerId : null; - return { - title: row.title, - defaultCwd, - defaultComputerId, - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as WorkspaceRow).title !== "string" || + typeof (parsed as WorkspaceRow).createdAt !== "number" || + typeof (parsed as WorkspaceRow).lastActivityAt !== "number" + ) { + return null; + } + const row = parsed as WorkspaceRow; + // `defaultCwd` may be null OR a string; treat anything else as null. + const defaultCwd = typeof row.defaultCwd === "string" ? row.defaultCwd : null; + // `defaultComputerId` may be null OR a string; treat anything else as null + // (mirrors `defaultCwd`). Absent on legacy rows → null (local). + const defaultComputerId = + typeof row.defaultComputerId === "string" ? row.defaultComputerId : null; + // `starred` may be absent on legacy rows; treat anything non-boolean as false. + const starred = row.starred === true; + return { + title: row.title, + defaultCwd, + defaultComputerId, + starred, + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + }; } function toWorkspace(id: string, row: WorkspaceRow): Workspace { - return { - id, - title: row.title, - defaultCwd: row.defaultCwd, - defaultComputerId: row.defaultComputerId, - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - }; + return { + id, + title: row.title, + defaultCwd: row.defaultCwd, + defaultComputerId: row.defaultComputerId, + starred: row.starred === true, + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + }; } export function createConversationStore( - storage: StorageNamespace, - logger?: Logger, - now: () => number = Date.now, - serverDefaultCwd: string = process.cwd(), + storage: StorageNamespace, + logger?: Logger, + now: () => number = Date.now, + serverDefaultCwd: string = process.cwd(), ): ConversationStore { - /** - * Add `conversationId` to the persisted index (idempotent). The store is - * not highly concurrent — the session-orchestrator serializes turns per - * conversation — so a simple read-modify-write suffices; `listConversations` - * deduplicates on read in case of a race on this update. - */ - async function ensureInIndex(conversationId: string): Promise<void> { - const raw = await storage.get(CONVERSATION_INDEX_KEY); - let ids: string[]; - if (raw === null) { - ids = []; - } else { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - parsed = []; - } - ids = Array.isArray(parsed) ? (parsed.filter((v) => typeof v === "string") as string[]) : []; - } - if (ids.includes(conversationId)) return; - ids.push(conversationId); - await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(ids)); - } - - /** - * Read a persisted {@link WorkspaceRow} by id, or `null` if absent/corrupt. - */ - async function readWorkspaceRow(id: string): Promise<WorkspaceRow | null> { - const raw = await storage.get(workspaceKey(id)); - if (raw === null) return null; - return parseWorkspaceRow(raw); - } - - /** - * Bump a workspace's `lastActivityAt` to `ts`. Creates the workspace row on - * miss (with `title = id`, `defaultCwd = null`, `createdAt/lastActivityAt - * = ts`) so that the first activity in any workspace — including the - * synthesized `"default"` — is recorded. Does NOT touch `title` or - * `defaultCwd` on an existing row. - */ - async function bumpWorkspaceLastActivityAt(workspaceId: string, ts: number): Promise<void> { - const existing = await readWorkspaceRow(workspaceId); - const row: WorkspaceRow = - existing === null - ? { - title: workspaceId, - defaultCwd: null, - defaultComputerId: null, - createdAt: ts, - lastActivityAt: ts, - } - : { - title: existing.title, - defaultCwd: existing.defaultCwd, - defaultComputerId: existing.defaultComputerId, - createdAt: existing.createdAt, - lastActivityAt: ts, - }; - await storage.set(workspaceKey(workspaceId), JSON.stringify(row)); - } - - return { - async append(conversationId, messages) { - const raw = await storage.get(seqKey(conversationId)); - let seq = parseSeq(raw) + 1; - - for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) { - const msg = messages[msgIdx]; - if (msg === undefined) continue; - for (let chunkIdx = 0; chunkIdx < msg.chunks.length; chunkIdx++) { - const chunk = msg.chunks[chunkIdx]; - if (chunk === undefined) continue; - const entry: PersistedChunkEntry = { - chunk, - role: msg.role, - msgIdx, - chunkIdx, - }; - await storage.set(chunkKey(conversationId, seq), JSON.stringify(entry)); - seq++; - } - } - - await storage.set(seqKey(conversationId), String(seq - 1)); - - // Metadata upsert: track createdAt/lastActivityAt/title and keep the - // conversation discoverable in the index. - const ts = now(); - let conversationWorkspaceId = DEFAULT_WORKSPACE_ID; - const metaRaw = await storage.get(metaKey(conversationId)); - if (metaRaw === null) { - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: extractTitle(messages), - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - } else { - const existing = parseMetaRow(metaRaw); - if (existing === null) { - // Corrupt row — rewrite from scratch using this append. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: extractTitle(messages), - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - } else { - conversationWorkspaceId = existing.workspaceId ?? DEFAULT_WORKSPACE_ID; - const title = - existing.title === "Untitled" || existing.title === "" - ? extractTitle(messages) - : existing.title; - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: ts, - title, - status: existing.status, - ...(existing.compactedFrom !== undefined - ? { compactedFrom: existing.compactedFrom } - : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - } - } - // Bump the owning workspace's lastActivityAt to this append's time. - await bumpWorkspaceLastActivityAt(conversationWorkspaceId, ts); - }, - - async load(conversationId) { - const prefix = chunkPrefix(conversationId); - const keys = await storage.keys(prefix); - const sorted = [...keys].sort(); - - const messages: ChatMessage[] = []; - let currentChunks: Chunk[] = []; - let currentRole: Role | undefined; - let currentMsgIdx = -1; - - for (const key of sorted) { - const value = await storage.get(key); - if (value === null) continue; - let entry: PersistedChunkEntry; - try { - entry = JSON.parse(value) as PersistedChunkEntry; - } catch (err) { - // "Never leave the system broken": a single corrupt/unparseable - // row must not brick the whole conversation. Skip it (append-only - // storage untouched) and let reconcile run on the rest. loadSince - // is intentionally NOT hardened here — it is the raw FE read path. - if (logger !== undefined) { - logger.warn("skipping corrupt chunk row", { - conversationId, - key, - error: err instanceof Error ? err.message : String(err), - }); - } - continue; - } - - if (entry.msgIdx !== currentMsgIdx) { - if (currentMsgIdx >= 0 && currentRole !== undefined) { - messages.push({ role: currentRole, chunks: currentChunks }); - } - currentChunks = []; - currentRole = entry.role; - currentMsgIdx = entry.msgIdx; - } - - currentChunks.push(entry.chunk); - } - - if (currentMsgIdx >= 0 && currentRole !== undefined) { - messages.push({ role: currentRole, chunks: currentChunks }); - } - - const { messages: repaired, report } = reconcileWithReport(messages); - - const hasReconcileActivity = - report.repairedCount > 0 || - report.strippedErrorChunks > 0 || - report.droppedEmptyMessages > 0; - if (hasReconcileActivity && logger !== undefined) { - const child = logger.child({ conversationId }); - const span = child.span("reconcile.repair", { - repairedCount: report.repairedCount, - firstRepairedToolCallId: report.repairedToolCallIds[0] ?? null, - strippedErrorChunks: report.strippedErrorChunks, - droppedEmptyMessages: report.droppedEmptyMessages, - }); - span.end(); - } - - return repaired; - }, - - async loadSince(conversationId, sinceSeq, window) { - const prefix = chunkPrefix(conversationId); - const keys = await storage.keys(prefix); - const sorted = [...keys].sort(); - - const result: StoredChunk[] = []; - const minSeq = sinceSeqBase(sinceSeq); - // Forgiving: a non-positive / non-integer bound is treated as ABSENT. - const beforeSeq = positiveInt(window?.beforeSeq); - const limit = positiveInt(window?.limit); - - for (const key of sorted) { - const seq = parseSeq(key.split(":").pop() ?? null); - if (seq <= minSeq) continue; - if (beforeSeq !== undefined && seq >= beforeSeq) continue; - const value = await storage.get(key); - if (value === null) continue; - const entry = JSON.parse(value) as PersistedChunkEntry; - result.push({ seq, role: entry.role, chunk: entry.chunk }); - } - - // Window: keep only the NEWEST `limit` chunks, still ascending by seq. - if (limit !== undefined && result.length > limit) { - return result.slice(result.length - limit); - } - - return result; - }, - - async appendMetrics(conversationId, metrics) { - const raw = await storage.get(metricsSeqKey(conversationId)); - const ordinal = parseSeq(raw) + 1; - await storage.set(metricsKey(conversationId, ordinal), JSON.stringify(metrics)); - await storage.set(metricsSeqKey(conversationId), String(ordinal)); - }, - - async loadMetrics(conversationId) { - const prefix = metricsPrefix(conversationId); - const keys = await storage.keys(prefix); - const sorted = [...keys].sort(); - - const result: TurnMetrics[] = []; - for (const key of sorted) { - const value = await storage.get(key); - if (value === null) continue; - result.push(JSON.parse(value) as TurnMetrics); - } - - return result; - }, - - async getCwd(conversationId) { - return await storage.get(cwdKey(conversationId)); - }, - - async setCwd(conversationId, cwd) { - await storage.set(cwdKey(conversationId), cwd); - if (logger !== undefined) { - logger.debug("cwd set", { conversationId }); - } - }, - - async clearCwd(conversationId) { - // Idempotent: deleting an already-absent key is a no-op (no error). - await storage.delete(cwdKey(conversationId)); - if (logger !== undefined) { - logger.debug("cwd cleared", { conversationId }); - } - }, - - async getComputerId(conversationId) { - return await storage.get(computerKey(conversationId)); - }, - - async setComputerId(conversationId, alias) { - // `null` is the "local" sentinel: clear the persisted key so it does - // NOT linger to shadow the workspace defaultComputerId. Idempotent - // (deleting an already-absent key is a no-op). Mirrors `setModel`'s - // clear-on-sentinel pattern. - if (alias === null) { - await storage.delete(computerKey(conversationId)); - if (logger !== undefined) { - logger.debug("computer cleared", { conversationId }); - } - return; - } - await storage.set(computerKey(conversationId), alias); - if (logger !== undefined) { - logger.debug("computer set", { conversationId }); - } - }, - - async clearComputerId(conversationId) { - // Idempotent: deleting an already-absent key is a no-op (no error). - await storage.delete(computerKey(conversationId)); - if (logger !== undefined) { - logger.debug("computer cleared", { conversationId }); - } - }, - - async getReasoningEffort(conversationId) { - return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null; - }, - - async setReasoningEffort(conversationId, effort) { - await storage.set(reasoningEffortKey(conversationId), effort); - if (logger !== undefined) { - logger.debug("reasoning-effort set", { conversationId }); - } - }, - - async getModel(conversationId) { - return await storage.get(modelKey(conversationId)); - }, - - async setModel(conversationId, model) { - if (model === "") { - // Idempotent clear: an empty model clears the persisted - // selection. Deleting an already-absent key is a no-op. - await storage.delete(modelKey(conversationId)); - if (logger !== undefined) { - logger.debug("model cleared", { conversationId }); - } - return; - } - await storage.set(modelKey(conversationId), model); - if (logger !== undefined) { - logger.debug("model set", { conversationId }); - } - }, - async listConversations(filter) { - const raw = await storage.get(CONVERSATION_INDEX_KEY); - if (raw === null) return []; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return []; - } - if (!Array.isArray(parsed)) return []; - // Deduplicate (in case of a race on the index update) while preserving - // first-seen order. - const seen = new Set<string>(); - const ids: string[] = []; - for (const v of parsed) { - if (typeof v !== "string" || seen.has(v)) continue; - seen.add(v); - ids.push(v); - } - - const statusFilter = filter?.status; - const workspaceFilter = filter?.workspaceId; - const metas: ConversationMeta[] = []; - for (const id of ids) { - const metaRaw = await storage.get(metaKey(id)); - if (metaRaw === null) continue; - const row = parseMetaRow(metaRaw); - if (row === null) continue; - if (statusFilter !== undefined && !statusFilter.includes(row.status)) continue; - if (workspaceFilter !== undefined) { - const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; - if (wsId !== workspaceFilter) continue; - } - metas.push(toMeta(id, row)); - } - // Sort by lastActivityAt descending (most recent first). Stable sort - // keeps first-seen (index) order for ties. - return metas.sort((a, b) => b.lastActivityAt - a.lastActivityAt); - }, - - async getConversationMeta(conversationId) { - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) return null; - const row = parseMetaRow(raw); - if (row === null) return null; - return toMeta(conversationId, row); - }, - - async setConversationTitle(conversationId, title) { - const ts = now(); - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) { - // Title set before any message was appended — create a minimal row. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title, - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const existing = parseMetaRow(raw); - if (existing === null) { - // Corrupt row — rewrite from scratch with this title. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title, - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - // Preserve createdAt + lastActivityAt + status; update only the title. - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title, - status: existing.status, - ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - }, - - async getConversationStatus(conversationId) { - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) return null; - const row = parseMetaRow(raw); - if (row === null) return null; - return row.status; - }, - - async setConversationStatus(conversationId, status) { - const ts = now(); - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) { - // Status set before any message was appended — create a minimal row. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const existing = parseMetaRow(raw); - if (existing === null) { - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title: existing.title, - status, - ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - }, - - async replaceHistory(conversationId, messages) { - // Delete all existing chunks. - const keys = await storage.keys(chunkPrefix(conversationId)); - for (const k of keys) { - await storage.delete(k); - } - // Reset the seq counter so the new messages start from seq 1. - await storage.set(seqKey(conversationId), "0"); - // Append the new messages (re-uses the append logic for seq - // numbering + metadata upsert). - await this.append(conversationId, messages); - }, - - async forkHistory(sourceId, targetId) { - // Copy all chunks from source to target, re-numbered from seq 1. - const keys = await storage.keys(chunkPrefix(sourceId)); - const sorted = [...keys].sort(); - let seq = 1; - for (const key of sorted) { - const value = await storage.get(key); - if (value === null) continue; - await storage.set(chunkKey(targetId, seq), value); - seq++; - } - await storage.set(seqKey(targetId), String(Math.max(seq - 1, 0))); - - // Copy metadata with archive title + closed status. - // Inherit compactedFrom from the source so archives chain: - // A → Y → X (each archive points to the previous one). - const metaRaw = await storage.get(metaKey(sourceId)); - if (metaRaw !== null) { - const existing = parseMetaRow(metaRaw); - if (existing !== null) { - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title: `Archive: ${existing.title}`, - status: "closed", - ...(existing.compactedFrom !== undefined - ? { compactedFrom: existing.compactedFrom } - : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(targetId), JSON.stringify(row)); - } - } - await ensureInIndex(targetId); - - // Copy cwd + reasoning-effort + model + computer (so the archive is self-contained). - const cwd = await storage.get(cwdKey(sourceId)); - if (cwd !== null) await storage.set(cwdKey(targetId), cwd); - const effort = await storage.get(reasoningEffortKey(sourceId)); - if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort); - const model = await storage.get(modelKey(sourceId)); - if (model !== null) await storage.set(modelKey(targetId), model); - const computerId = await storage.get(computerKey(sourceId)); - if (computerId !== null) await storage.set(computerKey(targetId), computerId); - }, - - async getCompactPercent(conversationId) { - const raw = await storage.get(compactThresholdKey(conversationId)); - if (raw === null) return null; - const n = Number.parseInt(raw, 10); - return Number.isNaN(n) ? null : n; - }, - - async setCompactPercent(conversationId, percent) { - await storage.set(compactThresholdKey(conversationId), String(percent)); - if (logger !== undefined) { - logger.debug("compact-percent set", { conversationId, percent }); - } - }, - - async setCompactedFrom(conversationId, newConversationId) { - const raw = await storage.get(metaKey(conversationId)); - const existing = raw !== null ? parseMetaRow(raw) : null; - const ts = now(); - const row: ConversationMetaRow = existing ?? { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status: "idle", - }; - await storage.set( - metaKey(conversationId), - JSON.stringify({ ...row, compactedFrom: newConversationId }), - ); - }, - - async getWorkspace(id) { - const row = await readWorkspaceRow(id); - if (row !== null) return toWorkspace(id, row); - // Synthesize the always-present "default" workspace when it was - // never persisted (title "default", defaultCwd null, defaultComputerId - // null [local], timestamps 0). - if (id === DEFAULT_WORKSPACE_ID) { - return { - id: DEFAULT_WORKSPACE_ID, - title: DEFAULT_WORKSPACE_ID, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - } - return null; - }, - - async ensureWorkspace(id, opts) { - const existing = await readWorkspaceRow(id); - if (existing !== null) return toWorkspace(id, existing); - // Absent — create with defaults. The synthesized "default" is also - // materialized here when first explicitly ensured. - const ts = now(); - const row: WorkspaceRow = { - title: opts?.title ?? id, - defaultCwd: opts?.defaultCwd ?? null, - defaultComputerId: opts?.defaultComputerId ?? null, - createdAt: ts, - lastActivityAt: ts, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async setWorkspaceTitle(id, title) { - const existing = await readWorkspaceRow(id); - const ts = now(); - const base = - existing === null - ? { - title: id, - defaultCwd: null as string | null, - defaultComputerId: null as string | null, - createdAt: ts, - lastActivityAt: ts, - } - : existing; - const row: WorkspaceRow = { - title, - defaultCwd: base.defaultCwd, - defaultComputerId: base.defaultComputerId, - createdAt: base.createdAt, - lastActivityAt: base.lastActivityAt, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async setWorkspaceDefaultCwd(id, defaultCwd) { - const existing = await readWorkspaceRow(id); - const ts = now(); - const base = - existing === null - ? { - title: id, - defaultCwd: null as string | null, - defaultComputerId: null as string | null, - createdAt: ts, - lastActivityAt: ts, - } - : existing; - const row: WorkspaceRow = { - title: base.title, - defaultCwd, - defaultComputerId: base.defaultComputerId, - createdAt: base.createdAt, - lastActivityAt: base.lastActivityAt, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - const existing = await readWorkspaceRow(id); - const ts = now(); - const base = - existing === null - ? { - title: id, - defaultCwd: null as string | null, - defaultComputerId: null as string | null, - createdAt: ts, - lastActivityAt: ts, - } - : existing; - const row: WorkspaceRow = { - title: base.title, - defaultCwd: base.defaultCwd, - defaultComputerId, - createdAt: base.createdAt, - lastActivityAt: base.lastActivityAt, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async deleteWorkspace(id) { - if (id === DEFAULT_WORKSPACE_ID) { - throw new Error('The "default" workspace cannot be deleted.'); - } - // (1) Find all conversations with workspaceId === id, (2) set each - // to status "closed" and reassign workspaceId to "default". - let closedCount = 0; - const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); - if (indexRaw !== null) { - let parsed: unknown; - try { - parsed = JSON.parse(indexRaw); - } catch { - parsed = []; - } - const ids = Array.isArray(parsed) - ? (parsed.filter((v) => typeof v === "string") as string[]) - : []; - for (const convId of ids) { - const metaRaw = await storage.get(metaKey(convId)); - if (metaRaw === null) continue; - const row = parseMetaRow(metaRaw); - if (row === null) continue; - const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; - if (wsId !== id) continue; - const updated: ConversationMetaRow = { - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - title: row.title, - status: "closed", - ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), - workspaceId: DEFAULT_WORKSPACE_ID, - }; - await storage.set(metaKey(convId), JSON.stringify(updated)); - closedCount++; - } - } - // (3) Delete the workspace entity. - await storage.delete(workspaceKey(id)); - return { closedCount }; - }, - - async listWorkspaces() { - // Collect persisted workspace rows via the `workspace:` key prefix. - const wsPrefix = "workspace:"; - const wsKeys = await storage.keys(wsPrefix); - const byId = new Map<string, Workspace>(); - for (const key of wsKeys) { - // Key shape: `workspace:<id>`. Strip the prefix to recover the id. - const id = key.slice(wsPrefix.length); - if (id.length === 0) continue; - const raw = await storage.get(key); - if (raw === null) continue; - const row = parseWorkspaceRow(raw); - if (row === null) continue; - byId.set(id, toWorkspace(id, row)); - } - // Always include "default" (synthesized if not persisted). - if (!byId.has(DEFAULT_WORKSPACE_ID)) { - byId.set(DEFAULT_WORKSPACE_ID, { - id: DEFAULT_WORKSPACE_ID, - title: DEFAULT_WORKSPACE_ID, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }); - } - // Count conversations per workspace by scanning the index + meta. - const counts = new Map<string, number>(); - for (const id of byId.keys()) counts.set(id, 0); - const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); - if (indexRaw !== null) { - let parsed: unknown; - try { - parsed = JSON.parse(indexRaw); - } catch { - parsed = []; - } - const ids = Array.isArray(parsed) - ? (parsed.filter((v) => typeof v === "string") as string[]) - : []; - for (const convId of ids) { - const metaRaw = await storage.get(metaKey(convId)); - if (metaRaw === null) continue; - const row = parseMetaRow(metaRaw); - if (row === null) continue; - const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; - counts.set(wsId, (counts.get(wsId) ?? 0) + 1); - } - } - const entries: WorkspaceEntry[] = []; - for (const [id, ws] of byId) { - entries.push({ ...ws, conversationCount: counts.get(id) ?? 0 }); - } - // Sort by lastActivityAt descending (most recent first). Stable sort - // keeps insertion order for ties. - return entries.sort((a, b) => b.lastActivityAt - a.lastActivityAt); - }, - - async getWorkspaceId(conversationId) { - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) return DEFAULT_WORKSPACE_ID; - const row = parseMetaRow(raw); - if (row === null) return DEFAULT_WORKSPACE_ID; - return row.workspaceId ?? DEFAULT_WORKSPACE_ID; - }, - - async setWorkspaceId(conversationId, workspaceId) { - const ts = now(); - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) { - // Conversation doesn't exist yet — create a minimal metadata row - // (like setConversationStatus does), with the workspace assigned. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status: "idle", - workspaceId, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const existing = parseMetaRow(raw); - if (existing === null) { - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status: "idle", - workspaceId, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title: existing.title, - status: existing.status, - ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), - workspaceId, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - }, - - async getEffectiveCwd(conversationId, overrideCwd) { - const workspaceId = await this.getWorkspaceId(conversationId); - const workspace = await this.getWorkspace(workspaceId); - const workspaceCwd = workspace?.defaultCwd ?? null; - // When an explicit override is given, resolve IT instead of the - // persisted cwd — it is always a string, never null. - const conversationCwd = - overrideCwd !== undefined ? overrideCwd : await this.getCwd(conversationId); - - if (conversationCwd === null) { - return workspaceCwd ?? serverDefaultCwd; - } - if (conversationCwd.startsWith("/")) { - return conversationCwd; - } - return pathResolve(workspaceCwd ?? serverDefaultCwd, conversationCwd); - }, - - async getEffectiveComputer(conversationId, overrideAlias) { - const workspaceId = await this.getWorkspaceId(conversationId); - const workspace = await this.getWorkspace(workspaceId); - const workspaceComputerId = workspace?.defaultComputerId ?? null; - // When an explicit override is given, it wins outright — even `null` - // (explicitly local for this turn) does NOT fall through to the - // persisted / workspace values. - if (overrideAlias !== undefined) { - return overrideAlias; - } - // Persisted per-conversation computerId → workspace defaultComputerId → null (LOCAL). - const computerId = await this.getComputerId(conversationId); - return computerId ?? workspaceComputerId; - }, - }; + /** + * Add `conversationId` to the persisted index (idempotent). The store is + * not highly concurrent — the session-orchestrator serializes turns per + * conversation — so a simple read-modify-write suffices; `listConversations` + * deduplicates on read in case of a race on this update. + */ + async function ensureInIndex(conversationId: string): Promise<void> { + const raw = await storage.get(CONVERSATION_INDEX_KEY); + let ids: string[]; + if (raw === null) { + ids = []; + } else { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = []; + } + ids = Array.isArray(parsed) ? (parsed.filter((v) => typeof v === "string") as string[]) : []; + } + if (ids.includes(conversationId)) return; + ids.push(conversationId); + await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(ids)); + } + + /** + * Read a persisted {@link WorkspaceRow} by id, or `null` if absent/corrupt. + */ + async function readWorkspaceRow(id: string): Promise<WorkspaceRow | null> { + const raw = await storage.get(workspaceKey(id)); + if (raw === null) return null; + return parseWorkspaceRow(raw); + } + + /** + * Bump a workspace's `lastActivityAt` to `ts`. Creates the workspace row on + * miss (with `title = id`, `defaultCwd = null`, `createdAt/lastActivityAt + * = ts`) so that the first activity in any workspace — including the + * synthesized `"default"` — is recorded. Does NOT touch `title` or + * `defaultCwd` on an existing row. + */ + async function bumpWorkspaceLastActivityAt(workspaceId: string, ts: number): Promise<void> { + const existing = await readWorkspaceRow(workspaceId); + const row: WorkspaceRow = + existing === null + ? { + title: workspaceId, + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: ts, + lastActivityAt: ts, + } + : { + title: existing.title, + defaultCwd: existing.defaultCwd, + defaultComputerId: existing.defaultComputerId, + starred: existing.starred, + createdAt: existing.createdAt, + lastActivityAt: ts, + }; + await storage.set(workspaceKey(workspaceId), JSON.stringify(row)); + } + + return { + async append(conversationId, messages) { + const raw = await storage.get(seqKey(conversationId)); + let seq = parseSeq(raw) + 1; + + for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) { + const msg = messages[msgIdx]; + if (msg === undefined) continue; + for (let chunkIdx = 0; chunkIdx < msg.chunks.length; chunkIdx++) { + const chunk = msg.chunks[chunkIdx]; + if (chunk === undefined) continue; + const entry: PersistedChunkEntry = { + chunk, + role: msg.role, + msgIdx, + chunkIdx, + }; + await storage.set(chunkKey(conversationId, seq), JSON.stringify(entry)); + seq++; + } + } + + await storage.set(seqKey(conversationId), String(seq - 1)); + + // Metadata upsert: track createdAt/lastActivityAt/title and keep the + // conversation discoverable in the index. + const ts = now(); + let conversationWorkspaceId = DEFAULT_WORKSPACE_ID; + const metaRaw = await storage.get(metaKey(conversationId)); + if (metaRaw === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: extractTitle(messages), + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + } else { + const existing = parseMetaRow(metaRaw); + if (existing === null) { + // Corrupt row — rewrite from scratch using this append. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: extractTitle(messages), + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + } else { + conversationWorkspaceId = existing.workspaceId ?? DEFAULT_WORKSPACE_ID; + const title = + existing.title === "Untitled" || existing.title === "" + ? extractTitle(messages) + : existing.title; + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: ts, + title, + status: existing.status, + ...(existing.compactedFrom !== undefined + ? { compactedFrom: existing.compactedFrom } + : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + } + } + // Bump the owning workspace's lastActivityAt to this append's time. + await bumpWorkspaceLastActivityAt(conversationWorkspaceId, ts); + }, + + async load(conversationId) { + const prefix = chunkPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const messages: ChatMessage[] = []; + let currentChunks: Chunk[] = []; + let currentRole: Role | undefined; + let currentMsgIdx = -1; + + for (const key of sorted) { + const value = await storage.get(key); + if (value === null) continue; + let entry: PersistedChunkEntry; + try { + entry = JSON.parse(value) as PersistedChunkEntry; + } catch (err) { + // "Never leave the system broken": a single corrupt/unparseable + // row must not brick the whole conversation. Skip it (append-only + // storage untouched) and let reconcile run on the rest. loadSince + // is intentionally NOT hardened here — it is the raw FE read path. + if (logger !== undefined) { + logger.warn("skipping corrupt chunk row", { + conversationId, + key, + error: err instanceof Error ? err.message : String(err), + }); + } + continue; + } + + // A message boundary is detected when EITHER the msgIdx changes OR the + // role changes. The msgIdx alone is insufficient because append() assigns + // it as a LOCAL index (reset to 0 for each call) — so consecutive + // single-message appends (e.g. the orchestrator's per-step persistence: + // append([user]) then append([assistant]) then append([user])...) all + // share msgIdx=0 and would collapse into one message without the role + // check. The role check restores correct boundaries for the common + // alternating user/assistant/tool pattern. + if (entry.msgIdx !== currentMsgIdx || entry.role !== currentRole) { + if (currentMsgIdx >= 0 && currentRole !== undefined) { + messages.push({ role: currentRole, chunks: currentChunks }); + } + currentChunks = []; + currentRole = entry.role; + currentMsgIdx = entry.msgIdx; + } + + currentChunks.push(entry.chunk); + } + + if (currentMsgIdx >= 0 && currentRole !== undefined) { + messages.push({ role: currentRole, chunks: currentChunks }); + } + + const { messages: repaired, report } = reconcileWithReport(messages); + + const hasReconcileActivity = + report.repairedCount > 0 || + report.strippedErrorChunks > 0 || + report.droppedEmptyMessages > 0; + if (hasReconcileActivity && logger !== undefined) { + const child = logger.child({ conversationId }); + const span = child.span("reconcile.repair", { + repairedCount: report.repairedCount, + firstRepairedToolCallId: report.repairedToolCallIds[0] ?? null, + strippedErrorChunks: report.strippedErrorChunks, + droppedEmptyMessages: report.droppedEmptyMessages, + }); + span.end(); + } + + return repaired; + }, + + async loadSince(conversationId, sinceSeq, window) { + const prefix = chunkPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const result: StoredChunk[] = []; + const minSeq = sinceSeqBase(sinceSeq); + // Forgiving: a non-positive / non-integer bound is treated as ABSENT. + const beforeSeq = positiveInt(window?.beforeSeq); + const limit = positiveInt(window?.limit); + + for (const key of sorted) { + const seq = parseSeq(key.split(":").pop() ?? null); + if (seq <= minSeq) continue; + if (beforeSeq !== undefined && seq >= beforeSeq) continue; + const value = await storage.get(key); + if (value === null) continue; + const entry = JSON.parse(value) as PersistedChunkEntry; + result.push({ seq, role: entry.role, chunk: entry.chunk }); + } + + // Window: keep only the NEWEST `limit` chunks, still ascending by seq. + if (limit !== undefined && result.length > limit) { + return result.slice(result.length - limit); + } + + return result; + }, + + async appendMetrics(conversationId, metrics) { + const raw = await storage.get(metricsSeqKey(conversationId)); + const ordinal = parseSeq(raw) + 1; + await storage.set(metricsKey(conversationId, ordinal), JSON.stringify(metrics)); + await storage.set(metricsSeqKey(conversationId), String(ordinal)); + }, + + async loadMetrics(conversationId) { + const prefix = metricsPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const result: TurnMetrics[] = []; + for (const key of sorted) { + const value = await storage.get(key); + if (value === null) continue; + result.push(JSON.parse(value) as TurnMetrics); + } + + return result; + }, + + async getCwd(conversationId) { + return await storage.get(cwdKey(conversationId)); + }, + + async setCwd(conversationId, cwd) { + await storage.set(cwdKey(conversationId), cwd); + if (logger !== undefined) { + logger.debug("cwd set", { conversationId }); + } + }, + + async clearCwd(conversationId) { + // Idempotent: deleting an already-absent key is a no-op (no error). + await storage.delete(cwdKey(conversationId)); + if (logger !== undefined) { + logger.debug("cwd cleared", { conversationId }); + } + }, + + async getComputerId(conversationId) { + return await storage.get(computerKey(conversationId)); + }, + + async setComputerId(conversationId, alias) { + // `null` is the "local" sentinel: clear the persisted key so it does + // NOT linger to shadow the workspace defaultComputerId. Idempotent + // (deleting an already-absent key is a no-op). Mirrors `setModel`'s + // clear-on-sentinel pattern. + if (alias === null) { + await storage.delete(computerKey(conversationId)); + if (logger !== undefined) { + logger.debug("computer cleared", { conversationId }); + } + return; + } + await storage.set(computerKey(conversationId), alias); + if (logger !== undefined) { + logger.debug("computer set", { conversationId }); + } + }, + + async clearComputerId(conversationId) { + // Idempotent: deleting an already-absent key is a no-op (no error). + await storage.delete(computerKey(conversationId)); + if (logger !== undefined) { + logger.debug("computer cleared", { conversationId }); + } + }, + + async getReasoningEffort(conversationId) { + return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null; + }, + + async setReasoningEffort(conversationId, effort) { + await storage.set(reasoningEffortKey(conversationId), effort); + if (logger !== undefined) { + logger.debug("reasoning-effort set", { conversationId }); + } + }, + + async getModel(conversationId) { + return await storage.get(modelKey(conversationId)); + }, + + async setModel(conversationId, model) { + if (model === "") { + // Idempotent clear: an empty model clears the persisted + // selection. Deleting an already-absent key is a no-op. + await storage.delete(modelKey(conversationId)); + if (logger !== undefined) { + logger.debug("model cleared", { conversationId }); + } + return; + } + await storage.set(modelKey(conversationId), model); + if (logger !== undefined) { + logger.debug("model set", { conversationId }); + } + }, + async listConversations(filter) { + const raw = await storage.get(CONVERSATION_INDEX_KEY); + if (raw === null) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + // Deduplicate (in case of a race on the index update) while preserving + // first-seen order. + const seen = new Set<string>(); + const ids: string[] = []; + for (const v of parsed) { + if (typeof v !== "string" || seen.has(v)) continue; + seen.add(v); + ids.push(v); + } + + const statusFilter = filter?.status; + const workspaceFilter = filter?.workspaceId; + const metas: ConversationMeta[] = []; + for (const id of ids) { + const metaRaw = await storage.get(metaKey(id)); + if (metaRaw === null) continue; + const row = parseMetaRow(metaRaw); + if (row === null) continue; + if (statusFilter !== undefined && !statusFilter.includes(row.status)) continue; + if (workspaceFilter !== undefined) { + const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; + if (wsId !== workspaceFilter) continue; + } + metas.push(toMeta(id, row)); + } + // Sort by lastActivityAt descending (most recent first). Stable sort + // keeps first-seen (index) order for ties. + return metas.sort((a, b) => b.lastActivityAt - a.lastActivityAt); + }, + + async getConversationMeta(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return null; + const row = parseMetaRow(raw); + if (row === null) return null; + return toMeta(conversationId, row); + }, + + async setConversationTitle(conversationId, title) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Title set before any message was appended — create a minimal row. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title, + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + // Corrupt row — rewrite from scratch with this title. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title, + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + // Preserve createdAt + lastActivityAt + status; update only the title. + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title, + status: existing.status, + ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async getConversationStatus(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return null; + const row = parseMetaRow(raw); + if (row === null) return null; + return row.status; + }, + + async setConversationStatus(conversationId, status) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Status set before any message was appended — create a minimal row. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: existing.title, + status, + ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async replaceHistory(conversationId, messages) { + // Delete all existing chunks. + const keys = await storage.keys(chunkPrefix(conversationId)); + for (const k of keys) { + await storage.delete(k); + } + // Reset the seq counter so the new messages start from seq 1. + await storage.set(seqKey(conversationId), "0"); + // Append the new messages (re-uses the append logic for seq + // numbering + metadata upsert). + await this.append(conversationId, messages); + }, + + async forkHistory(sourceId, targetId) { + // Copy all chunks from source to target, re-numbered from seq 1. + const keys = await storage.keys(chunkPrefix(sourceId)); + const sorted = [...keys].sort(); + let seq = 1; + for (const key of sorted) { + const value = await storage.get(key); + if (value === null) continue; + await storage.set(chunkKey(targetId, seq), value); + seq++; + } + await storage.set(seqKey(targetId), String(Math.max(seq - 1, 0))); + + // Copy metadata with archive title + closed status. + // Inherit compactedFrom from the source so archives chain: + // A → Y → X (each archive points to the previous one). + const metaRaw = await storage.get(metaKey(sourceId)); + if (metaRaw !== null) { + const existing = parseMetaRow(metaRaw); + if (existing !== null) { + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: `Archive: ${existing.title}`, + status: "closed", + ...(existing.compactedFrom !== undefined + ? { compactedFrom: existing.compactedFrom } + : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(targetId), JSON.stringify(row)); + } + } + await ensureInIndex(targetId); + + // Copy cwd + reasoning-effort + model + computer (so the archive is self-contained). + const cwd = await storage.get(cwdKey(sourceId)); + if (cwd !== null) await storage.set(cwdKey(targetId), cwd); + const effort = await storage.get(reasoningEffortKey(sourceId)); + if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort); + const model = await storage.get(modelKey(sourceId)); + if (model !== null) await storage.set(modelKey(targetId), model); + const computerId = await storage.get(computerKey(sourceId)); + if (computerId !== null) await storage.set(computerKey(targetId), computerId); + }, + + async getCompactPercent(conversationId) { + const raw = await storage.get(compactThresholdKey(conversationId)); + if (raw === null) return null; + const n = Number.parseInt(raw, 10); + return Number.isNaN(n) ? null : n; + }, + + async setCompactPercent(conversationId, percent) { + await storage.set(compactThresholdKey(conversationId), String(percent)); + if (logger !== undefined) { + logger.debug("compact-percent set", { conversationId, percent }); + } + }, + + async getImageTranscriptions(conversationId) { + const raw = await storage.get(imageTranscriptionsKey(conversationId)); + if (raw === null) return new Map(); + try { + const obj = JSON.parse(raw) as Record<string, string>; + return new Map(Object.entries(obj)); + } catch { + return new Map(); + } + }, + + async setImageTranscription(conversationId, imageUrl, transcription) { + const existing = await this.getImageTranscriptions(conversationId); + const merged = new Map(existing); + merged.set(imageUrl, transcription); + const obj: Record<string, string> = {}; + for (const [k, v] of merged) obj[k] = v; + await storage.set(imageTranscriptionsKey(conversationId), JSON.stringify(obj)); + }, + + async getVisionSettings() { + const raw = await storage.get(VISION_SETTINGS_KEY); + if (raw === null) return { imageLimit: 10, compactionModel: null }; + try { + const obj = JSON.parse(raw) as { imageLimit?: number; compactionModel?: string | null }; + return { + imageLimit: typeof obj.imageLimit === "number" ? obj.imageLimit : 10, + compactionModel: obj.compactionModel ?? null, + }; + } catch { + return { imageLimit: 10, compactionModel: null }; + } + }, + + async setVisionImageLimit(limit) { + const current = await this.getVisionSettings(); + const obj = { imageLimit: limit, compactionModel: current.compactionModel }; + await storage.set(VISION_SETTINGS_KEY, JSON.stringify(obj)); + }, + + async setVisionCompactionModel(model) { + const current = await this.getVisionSettings(); + const obj = { imageLimit: current.imageLimit, compactionModel: model }; + await storage.set(VISION_SETTINGS_KEY, JSON.stringify(obj)); + }, + + async setCompactedFrom(conversationId, newConversationId) { + const raw = await storage.get(metaKey(conversationId)); + const existing = raw !== null ? parseMetaRow(raw) : null; + const ts = now(); + const row: ConversationMetaRow = existing ?? { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status: "idle", + }; + await storage.set( + metaKey(conversationId), + JSON.stringify({ ...row, compactedFrom: newConversationId }), + ); + }, + + async getWorkspace(id) { + const row = await readWorkspaceRow(id); + if (row !== null) return toWorkspace(id, row); + // Synthesize the always-present "default" workspace when it was + // never persisted (title "default", defaultCwd null, defaultComputerId + // null [local], starred false, timestamps 0). + if (id === DEFAULT_WORKSPACE_ID) { + return { + id: DEFAULT_WORKSPACE_ID, + title: DEFAULT_WORKSPACE_ID, + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 0, + lastActivityAt: 0, + }; + } + return null; + }, + + async ensureWorkspace(id, opts) { + const existing = await readWorkspaceRow(id); + if (existing !== null) return toWorkspace(id, existing); + // Absent — create with defaults. The synthesized "default" is also + // materialized here when first explicitly ensured. + const ts = now(); + const row: WorkspaceRow = { + title: opts?.title ?? id, + defaultCwd: opts?.defaultCwd ?? null, + defaultComputerId: opts?.defaultComputerId ?? null, + starred: false, + createdAt: ts, + lastActivityAt: ts, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceTitle(id, title) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + starred: false as boolean, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title, + defaultCwd: base.defaultCwd, + defaultComputerId: base.defaultComputerId, + starred: base.starred, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceDefaultCwd(id, defaultCwd) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + starred: false as boolean, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title: base.title, + defaultCwd, + defaultComputerId: base.defaultComputerId, + starred: base.starred, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + starred: false as boolean, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title: base.title, + defaultCwd: base.defaultCwd, + defaultComputerId, + starred: base.starred, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceStarred(id, starred) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title: base.title, + defaultCwd: base.defaultCwd, + defaultComputerId: base.defaultComputerId, + starred, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + if (logger !== undefined) { + logger.debug("workspace starred set", { workspaceId: id, starred }); + } + return toWorkspace(id, row); + }, + + async deleteWorkspace(id) { + if (id === DEFAULT_WORKSPACE_ID) { + throw new Error('The "default" workspace cannot be deleted.'); + } + // (1) Find all conversations with workspaceId === id, (2) set each + // to status "closed" and reassign workspaceId to "default". + let closedCount = 0; + const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); + if (indexRaw !== null) { + let parsed: unknown; + try { + parsed = JSON.parse(indexRaw); + } catch { + parsed = []; + } + const ids = Array.isArray(parsed) + ? (parsed.filter((v) => typeof v === "string") as string[]) + : []; + for (const convId of ids) { + const metaRaw = await storage.get(metaKey(convId)); + if (metaRaw === null) continue; + const row = parseMetaRow(metaRaw); + if (row === null) continue; + const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; + if (wsId !== id) continue; + const updated: ConversationMetaRow = { + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + title: row.title, + status: "closed", + ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), + workspaceId: DEFAULT_WORKSPACE_ID, + }; + await storage.set(metaKey(convId), JSON.stringify(updated)); + closedCount++; + } + } + // (3) Delete the workspace entity. + await storage.delete(workspaceKey(id)); + return { closedCount }; + }, + + async listWorkspaces() { + // Collect persisted workspace rows via the `workspace:` key prefix. + const wsPrefix = "workspace:"; + const wsKeys = await storage.keys(wsPrefix); + const byId = new Map<string, Workspace>(); + for (const key of wsKeys) { + // Key shape: `workspace:<id>`. Strip the prefix to recover the id. + const id = key.slice(wsPrefix.length); + if (id.length === 0) continue; + const raw = await storage.get(key); + if (raw === null) continue; + const row = parseWorkspaceRow(raw); + if (row === null) continue; + byId.set(id, toWorkspace(id, row)); + } + // Always include "default" (synthesized if not persisted). + if (!byId.has(DEFAULT_WORKSPACE_ID)) { + byId.set(DEFAULT_WORKSPACE_ID, { + id: DEFAULT_WORKSPACE_ID, + title: DEFAULT_WORKSPACE_ID, + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 0, + lastActivityAt: 0, + }); + } + // Count conversations per workspace by scanning the index + meta. + const counts = new Map<string, number>(); + for (const id of byId.keys()) counts.set(id, 0); + const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); + if (indexRaw !== null) { + let parsed: unknown; + try { + parsed = JSON.parse(indexRaw); + } catch { + parsed = []; + } + const ids = Array.isArray(parsed) + ? (parsed.filter((v) => typeof v === "string") as string[]) + : []; + for (const convId of ids) { + const metaRaw = await storage.get(metaKey(convId)); + if (metaRaw === null) continue; + const row = parseMetaRow(metaRaw); + if (row === null) continue; + const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; + counts.set(wsId, (counts.get(wsId) ?? 0) + 1); + } + } + const entries: WorkspaceEntry[] = []; + for (const [id, ws] of byId) { + entries.push({ ...ws, conversationCount: counts.get(id) ?? 0 }); + } + // Sort by lastActivityAt descending (most recent first). Stable sort + // keeps insertion order for ties. + return entries.sort((a, b) => b.lastActivityAt - a.lastActivityAt); + }, + + async getWorkspaceId(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return DEFAULT_WORKSPACE_ID; + const row = parseMetaRow(raw); + if (row === null) return DEFAULT_WORKSPACE_ID; + return row.workspaceId ?? DEFAULT_WORKSPACE_ID; + }, + + async setWorkspaceId(conversationId, workspaceId) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Conversation doesn't exist yet — create a minimal metadata row + // (like setConversationStatus does), with the workspace assigned. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status: "idle", + workspaceId, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status: "idle", + workspaceId, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: existing.title, + status: existing.status, + ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), + workspaceId, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async getEffectiveCwd(conversationId, overrideCwd) { + const workspaceId = await this.getWorkspaceId(conversationId); + const workspace = await this.getWorkspace(workspaceId); + const workspaceCwd = workspace?.defaultCwd ?? null; + // When an explicit override is given, resolve IT instead of the + // persisted cwd — it is always a string, never null. + const conversationCwd = + overrideCwd !== undefined ? overrideCwd : await this.getCwd(conversationId); + + if (conversationCwd === null) { + return workspaceCwd ?? serverDefaultCwd; + } + if (conversationCwd.startsWith("/")) { + return conversationCwd; + } + return pathResolve(workspaceCwd ?? serverDefaultCwd, conversationCwd); + }, + + async getEffectiveComputer(conversationId, overrideAlias) { + const workspaceId = await this.getWorkspaceId(conversationId); + const workspace = await this.getWorkspace(workspaceId); + const workspaceComputerId = workspace?.defaultComputerId ?? null; + // When an explicit override is given, it wins outright — even `null` + // (explicitly local for this turn) does NOT fall through to the + // persisted / workspace values. + if (overrideAlias !== undefined) { + return overrideAlias; + } + // Persisted per-conversation computerId → workspace defaultComputerId → null (LOCAL). + const computerId = await this.getComputerId(conversationId); + return computerId ?? workspaceComputerId; + }, + }; } |
