summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/db
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/db
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz
dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/src/db')
-rw-r--r--packages/core/src/db/chunks.ts246
-rw-r--r--packages/core/src/db/index.ts177
-rw-r--r--packages/core/src/db/settings.ts22
-rw-r--r--packages/core/src/db/tabs.ts250
4 files changed, 0 insertions, 695 deletions
diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts
deleted file mode 100644
index b434a47..0000000
--- a/packages/core/src/db/chunks.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-import { randomUUID } from "node:crypto";
-import {
- explodeTurn,
- explodeUserText,
- groupRowsToMessages,
- type MessageRow,
-} from "../chunks/transform.js";
-import type {
- ChunkData,
- ChunkRow,
- ChunkRowDraft,
- TextData,
- UsageData,
- UsageStats,
-} from "../types/index.js";
-import { getDatabase } from "./index.js";
-
-// Re-export the DB-free transforms so existing barrel consumers
-// (`@dispatch/core`) keep importing them from here. The browser frontend deep-
-// imports them directly from `chunks/transform.js` to avoid the DB dependency.
-export { explodeTurn, explodeUserText, groupRowsToMessages, type MessageRow };
-
-// ─── Persistence ─────────────────────────────────────────────────
-
-function mapRow(row: Record<string, unknown>): ChunkRow {
- let data: ChunkData;
- try {
- data = JSON.parse(row.data_json as string) as ChunkData;
- } catch {
- data = { text: "" } as TextData;
- }
- return {
- id: row.id as string,
- tabId: row.tab_id as string,
- seq: row.seq as number,
- turnId: row.turn_id as string,
- step: row.step as number,
- role: row.role as ChunkRow["role"],
- type: row.type as ChunkRow["type"],
- data,
- createdAt: row.created_at as number,
- };
-}
-
-/**
- * Append one or more chunk-row drafts to a tab, assigning a monotonic per-tab
- * `seq` and a fresh id/timestamp to each. Returns the inserted rows in order.
- */
-export function appendChunks(tabId: string, drafts: ChunkRowDraft[]): ChunkRow[] {
- if (drafts.length === 0) return [];
- const db = getDatabase();
- const maxSeq = db
- .query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId")
- .get({ $tabId: tabId }) as { max_seq: number };
- let seq = (maxSeq?.max_seq ?? -1) + 1;
- const now = Date.now();
- const insert = db.query(
- `INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at)
- VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)`,
- );
- const out: ChunkRow[] = [];
- // Wrap the whole batch in one transaction: a turn's chunks are persisted in
- // a single `appendChunks` call, so this is one fsync per turn instead of one
- // per row — the chosen low-IO write strategy for constrained backends.
- const insertAll = db.transaction(() => {
- for (const draft of drafts) {
- const id = randomUUID();
- insert.run({
- $id: id,
- $tabId: tabId,
- $seq: seq,
- $turnId: draft.turnId,
- $step: draft.step,
- $role: draft.role,
- $type: draft.type,
- $dataJson: JSON.stringify(draft.data),
- $now: now,
- });
- out.push({
- id,
- tabId,
- seq,
- turnId: draft.turnId,
- step: draft.step,
- role: draft.role,
- type: draft.type,
- data: draft.data,
- createdAt: now,
- });
- seq++;
- }
- });
- insertAll();
- return out;
-}
-
-/**
- * Read chunk rows for a tab in `seq` order (ASC). Pagination mirrors the old
- * message pagination but at chunk granularity:
- * - no options → all rows;
- * - `before` → rows with `seq < before`, most-recent-first then reversed;
- * - `limit` → most recent `limit` rows, reversed to ASC.
- */
-export function getChunksForTab(
- tabId: string,
- options?: { limit?: number; before?: number },
-): ChunkRow[] {
- const db = getDatabase();
- if (!options) {
- const rows = db
- .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<Record<string, unknown>>;
- return rows.map(mapRow);
- }
- const { limit, before } = options;
- if (before !== undefined) {
- if (limit !== undefined) {
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit",
- )
- .all({ $tabId: tabId, $before: before, $limit: limit }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC",
- )
- .all({ $tabId: tabId, $before: before }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- if (limit !== undefined) {
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit",
- )
- .all({ $tabId: tabId, $limit: limit }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- const rows = db
- .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<Record<string, unknown>>;
- return rows.map(mapRow);
-}
-
-/**
- * Derived, grouped view of a tab's full history as messages. Used to
- * pre-populate the agent's in-memory `ChatMessage[]` history when an Agent is
- * (re)constructed. Always reads the full log (grouping a partial window would
- * be lossy for the rebuild path).
- */
-export function getMessagesForTab(tabId: string): MessageRow[] {
- return groupRowsToMessages(getChunksForTab(tabId));
-}
-
-export function getTotalChunkCount(tabId: string): number {
- const db = getDatabase();
- const row = db
- .query("SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'")
- .get({ $tabId: tabId }) as { count: number } | null;
- return row?.count ?? 0;
-}
-
-/**
- * Aggregate per-tab token/cache usage across ALL persisted `usage` chunk rows.
- *
- * Usage rows are written as an invisible side channel (one row per `usage`
- * AgentEvent) and are query-excluded from `getChunksForTab`/`getTotalChunkCount`,
- * so this aggregate is the read path. Because it sums server-side over every
- * row, it stays complete even after the frontend evicts/pages out old turns
- * (eviction is in-memory only). The return shape is structurally identical to
- * the frontend `CacheStats`, so reload can seed it directly.
- *
- * - cumulative `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens`
- * = SUM over all usage rows;
- * - `requests` = COUNT of usage rows;
- * - `last` = the highest-seq usage row's split (most recent request);
- * - `null` when the tab has no usage rows.
- *
- * Sums in JS after selecting the rows (mirroring `mapRow`) to avoid relying on
- * `json_extract` over the freeform `data_json`.
- */
-export function getUsageStatsForTab(tabId: string): UsageStats | null {
- const db = getDatabase();
- const rows = db
- .query("SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<{ data_json: string }>;
- if (rows.length === 0) return null;
-
- let inputTokens = 0;
- let outputTokens = 0;
- let cacheReadTokens = 0;
- let cacheWriteTokens = 0;
- let last: UsageData | null = null;
- for (const row of rows) {
- let u: UsageData;
- try {
- u = JSON.parse(row.data_json) as UsageData;
- } catch {
- continue;
- }
- inputTokens += u.inputTokens ?? 0;
- outputTokens += u.outputTokens ?? 0;
- cacheReadTokens += u.cacheReadTokens ?? 0;
- cacheWriteTokens += u.cacheWriteTokens ?? 0;
- last = {
- inputTokens: u.inputTokens ?? 0,
- outputTokens: u.outputTokens ?? 0,
- cacheReadTokens: u.cacheReadTokens ?? 0,
- cacheWriteTokens: u.cacheWriteTokens ?? 0,
- };
- }
-
- return {
- inputTokens,
- outputTokens,
- cacheReadTokens,
- cacheWriteTokens,
- requests: rows.length,
- last,
- };
-}
-
-export function clearChunksForTab(tabId: string): void {
- const db = getDatabase();
- db.query("DELETE FROM chunks WHERE tab_id = $tabId").run({ $tabId: tabId });
-}
-
-/**
- * Relocate every chunk row from one tab to another (compaction backup path).
- *
- * Used by conversation compaction to move the FULL pre-compaction history off
- * the canonical tab id (`fromTabId`) onto a freshly-created backup tab id
- * (`toTabId`), leaving the canonical id free to be re-seeded with the summary +
- * preserved tail. `seq` values are preserved (they remain per-tab monotonic for
- * the destination since it starts empty), as are turn ids, so the relocated
- * history groups identically under its new tab. Returns the number of rows
- * moved.
- */
-export function rekeyChunks(fromTabId: string, toTabId: string): number {
- const db = getDatabase();
- const result = db
- .query("UPDATE chunks SET tab_id = $to WHERE tab_id = $from")
- .run({ $from: fromTabId, $to: toTabId });
- return Number(result.changes ?? 0);
-}
diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts
deleted file mode 100644
index 93ec1f9..0000000
--- a/packages/core/src/db/index.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { Database } from "bun:sqlite";
-import { existsSync, mkdirSync } from "node:fs";
-import { homedir } from "node:os";
-import { isAbsolute, join } from "node:path";
-
-/**
- * Returns the directory for persistent Dispatch data, following XDG Base
- * Directory spec on Linux: `$XDG_DATA_HOME/dispatch` (defaults to
- * `~/.local/share/dispatch`).
- */
-function getDataDir(): string {
- const xdg = process.env.XDG_DATA_HOME;
- const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "share");
- return join(base, "dispatch");
-}
-
-let _db: Database | null = null;
-
-/**
- * Get (or create) the singleton SQLite database.
- *
- * - Creates the data directory if it doesn't exist.
- * - Creates `dispatch.db` if it doesn't exist.
- * - Enables WAL journal mode for concurrent read performance.
- */
-export function getDatabase(): Database {
- if (_db) return _db;
-
- const dir = getDataDir();
- if (!existsSync(dir)) {
- mkdirSync(dir, { recursive: true });
- }
-
- const dbPath = join(dir, "dispatch.db");
- _db = new Database(dbPath, { create: true });
-
- // WAL mode: better concurrent read performance, safe for single-writer
- _db.run("PRAGMA journal_mode = WAL;");
- // Recommended for WAL: normal synchronous is safe and faster
- _db.run("PRAGMA synchronous = NORMAL;");
- // Enable foreign keys
- _db.run("PRAGMA foreign_keys = ON;");
-
- // Create tables
- _db.run(`CREATE TABLE IF NOT EXISTS credentials (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- access_token TEXT NOT NULL,
- refresh_token TEXT NOT NULL,
- expires_at INTEGER NOT NULL,
- subscription_type TEXT,
- source_file TEXT,
- imported_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- // Wake schedule: 4 rows per marked hour (one per :00 / :15 / :30 / :45 probe
- // slot). The PK is (hour, slot_minute). Destructive migration off the legacy
- // single-row-per-hour schema: detect by absence of the `slot_minute` column
- // and drop the old table. Other tables (credentials, api_keys, usage_cache,
- // settings, tabs, chunks) are NOT touched.
- const legacyWakeSchema = (() => {
- try {
- const cols = _db.query("PRAGMA table_info(wake_schedule)").all() as Array<{ name: string }>;
- if (cols.length === 0) return false; // table doesn't exist yet
- return !cols.some((c) => c.name === "slot_minute");
- } catch {
- return false;
- }
- })();
- if (legacyWakeSchema) {
- _db.run("DROP TABLE IF EXISTS wake_schedule");
- }
- _db.run(`CREATE TABLE IF NOT EXISTS wake_schedule (
- hour INTEGER NOT NULL CHECK (hour BETWEEN 0 AND 23),
- slot_minute INTEGER NOT NULL CHECK (slot_minute IN (0, 15, 30, 45)),
- next_wake_at INTEGER NOT NULL,
- PRIMARY KEY (hour, slot_minute)
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS usage_cache (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- cached_at INTEGER NOT NULL,
- report_json TEXT NOT NULL
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS api_keys (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- api_key TEXT NOT NULL,
- imported_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS tabs (
- id TEXT PRIMARY KEY,
- title TEXT NOT NULL,
- key_id TEXT,
- model_id TEXT,
- parent_tab_id TEXT,
- status TEXT NOT NULL DEFAULT 'idle',
- is_open INTEGER NOT NULL DEFAULT 1,
- position INTEGER NOT NULL DEFAULT 0,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- try {
- _db.run("ALTER TABLE tabs ADD COLUMN parent_tab_id TEXT");
- } catch {
- // Column already exists — ignore
- }
-
- // ─── Append-only chunk log (replaces the old `messages` blob table) ──
- //
- // A conversation is stored as a flat, append-only stream of chunk rows
- // keyed by a per-tab monotonic `seq`. "Message" and "turn" are DERIVED
- // groupings (see db/chunks.ts), never stored containers. This is what
- // powers per-chunk frontend pagination AND the stable per-step wire
- // format that fixes Anthropic prompt-cache churn (see notes/plan-chunk-log.md).
- //
- // role : 'user' | 'assistant' | 'tool' | 'system'
- // type : 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'error' | 'system'
- // step : LLM round-trip index within a turn (user/system rows = 0)
- // data_json: the type-specific payload (see ChunkData in types)
- _db.run(`CREATE TABLE IF NOT EXISTS chunks (
- id TEXT PRIMARY KEY,
- tab_id TEXT NOT NULL,
- seq INTEGER NOT NULL,
- turn_id TEXT NOT NULL,
- step INTEGER NOT NULL DEFAULT 0,
- role TEXT NOT NULL,
- type TEXT NOT NULL,
- data_json TEXT NOT NULL,
- created_at INTEGER NOT NULL
- )`);
-
- _db.run(`CREATE INDEX IF NOT EXISTS idx_chunks_tab_seq ON chunks(tab_id, seq)`);
-
- // One-shot migration off the legacy `messages` blob model. Beta software,
- // no backward compatibility: the old chat history is destroyed (tabs +
- // messages), while settings / credentials / api_keys / usage_cache /
- // wake_schedule are preserved. Detect the old schema by the presence of
- // the `messages` table; once dropped, this branch never runs again.
- const hasLegacyMessages = _db
- .query("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'")
- .get() as { name: string } | null;
- if (hasLegacyMessages) {
- _db.run("DROP TABLE IF EXISTS messages");
- // Clear conversation containers too (fresh slate for the new model).
- _db.run("DELETE FROM tabs");
- _db.run("DELETE FROM chunks");
- }
-
- _db.run(`CREATE TABLE IF NOT EXISTS settings (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- )`);
-
- return _db;
-}
-
-/** Close the database connection (e.g. on shutdown). */
-export function closeDatabase(): void {
- if (_db) {
- _db.close();
- _db = null;
- }
-}
-
-/** Returns the path where the database file lives (or will live). */
-export function getDatabasePath(): string {
- if (_db) return _db.filename;
- const dir = getDataDir();
- return join(dir, "dispatch.db");
-}
diff --git a/packages/core/src/db/settings.ts b/packages/core/src/db/settings.ts
deleted file mode 100644
index f9d152e..0000000
--- a/packages/core/src/db/settings.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { getDatabase } from "./index.js";
-
-export function getSetting(key: string): string | null {
- const db = getDatabase();
- const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as {
- value: string;
- } | null;
- return row?.value ?? null;
-}
-
-export function setSetting(key: string, value: string): void {
- const db = getDatabase();
- db.query(
- `INSERT INTO settings (key, value) VALUES ($key, $value)
- ON CONFLICT(key) DO UPDATE SET value = $value`,
- ).run({ $key: key, $value: value });
-}
-
-export function deleteSetting(key: string): void {
- const db = getDatabase();
- db.query("DELETE FROM settings WHERE key = $key").run({ $key: key });
-}
diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts
deleted file mode 100644
index f719a01..0000000
--- a/packages/core/src/db/tabs.ts
+++ /dev/null
@@ -1,250 +0,0 @@
-import { getDatabase } from "./index.js";
-
-export interface TabRow {
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- status: string;
- isOpen: boolean;
- position: number;
- createdAt: number;
- updatedAt: number;
-}
-
-function rowToTab(row: Record<string, unknown>): TabRow {
- return {
- id: row.id as string,
- title: row.title as string,
- keyId: row.key_id as string | null,
- modelId: row.model_id as string | null,
- parentTabId: (row.parent_tab_id as string) ?? null,
- status: row.status as string,
- isOpen: (row.is_open as number) === 1,
- position: row.position as number,
- createdAt: row.created_at as number,
- updatedAt: row.updated_at as number,
- };
-}
-
-export function createTab(
- id: string,
- title: string,
- options?: { keyId?: string | null; modelId?: string | null; parentTabId?: string | null },
-): TabRow {
- const db = getDatabase();
- const now = Date.now();
- const maxPos = db
- .query("SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1")
- .get() as { max_pos: number };
- const position = (maxPos?.max_pos ?? -1) + 1;
- const keyId = options?.keyId ?? null;
- const modelId = options?.modelId ?? null;
- const parentTabId = options?.parentTabId ?? null;
- db.query(
- `INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)`,
- ).run({
- $id: id,
- $title: title,
- $keyId: keyId,
- $modelId: modelId,
- $parentTabId: parentTabId,
- $position: position,
- $now: now,
- });
- return {
- id,
- title,
- keyId,
- modelId,
- parentTabId,
- status: "idle",
- isOpen: true,
- position,
- createdAt: now,
- updatedAt: now,
- };
-}
-
-export function getTab(id: string): TabRow | null {
- const db = getDatabase();
- const row = db.query("SELECT * FROM tabs WHERE id = $id").get({ $id: id }) as Record<
- string,
- unknown
- > | null;
- return row ? rowToTab(row) : null;
-}
-
-export function listOpenTabs(): TabRow[] {
- const db = getDatabase();
- const rows = db
- .query("SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC")
- .all() as Array<Record<string, unknown>>;
- return rows.map(rowToTab);
-}
-
-export function updateTabTitle(id: string, title: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET title = $title, updated_at = $now WHERE id = $id").run({
- $id: id,
- $title: title,
- $now: Date.now(),
- });
-}
-
-export function updateTabModel(id: string, keyId: string | null, modelId: string | null): void {
- const db = getDatabase();
- db.query(
- "UPDATE tabs SET key_id = $keyId, model_id = $modelId, updated_at = $now WHERE id = $id",
- ).run({
- $id: id,
- $keyId: keyId,
- $modelId: modelId,
- $now: Date.now(),
- });
-}
-
-export function updateTabStatus(id: string, status: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET status = $status, updated_at = $now WHERE id = $id").run({
- $id: id,
- $status: status,
- $now: Date.now(),
- });
-}
-
-export function updateTabPositions(idsInOrder: string[]): void {
- const db = getDatabase();
- const now = Date.now();
- const update = db.query("UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id");
- // One transaction so a reorder is atomic: either every tab lands at its new
- // slot or none does, never a half-applied ordering.
- const applyAll = db.transaction(() => {
- idsInOrder.forEach((id, index) => {
- update.run({ $id: id, $position: index, $now: now });
- });
- });
- applyAll();
-}
-
-export function archiveTab(id: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({
- $id: id,
- $now: Date.now(),
- });
-}
-
-/**
- * Return the IDs of `rootId` plus every OPEN descendant tab, in leaf-first
- * order (children before their parent). Archived descendants
- * (`is_open = 0`) and their sub-trees are skipped — closing a parent
- * shouldn't drag archived branches back into view.
- *
- * The starting `rootId` is always included in the result, even if no row
- * with that id exists in the `tabs` table (graceful handling for stale
- * references).
- *
- * Order matters for the cascade-close path: callers archive descendants
- * leaf-first so foreign-key cleanup (messages, etc.) doesn't fail on
- * partially-deleted parents.
- *
- * Cycle-safe: a `visited` set guards against accidental `parent_tab_id`
- * loops that would otherwise spin forever.
- */
-export function getDescendantIds(rootId: string): string[] {
- const db = getDatabase();
- const visited = new Set<string>();
- const order: string[] = [];
- const queue: string[] = [rootId];
- while (queue.length > 0) {
- const id = queue.shift() as string;
- if (visited.has(id)) continue;
- visited.add(id);
- order.push(id);
- const children = db
- .query("SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1")
- .all({ $id: id }) as Array<{ id: string }>;
- for (const child of children) {
- if (!visited.has(child.id)) queue.push(child.id);
- }
- }
- return order.reverse();
-}
-
-/**
- * Minimum length of a tab-handle prefix accepted by `resolveTabPrefix`.
- * Mirrors the frontend's minimum DISPLAY length (4 hex chars). Anything
- * shorter is rejected as too broad — an agent must echo at least the 4-char
- * handle shown in the UI.
- */
-export const MIN_TAB_PREFIX_LENGTH = 4;
-
-/**
- * Outcome of resolving a short tab handle (a git-style prefix of a tab's
- * UUID) back to a concrete open tab.
- *
- * - `ok` — exactly one open tab matched; `tab` is it.
- * - `none` — no open tab matched (bad/stale handle, or too-short prefix).
- * - `ambiguous` — more than one open tab shares the prefix; `matches` lists
- * them so the caller can ask for one more character (the same
- * UX as `git checkout <ambiguous-sha>`).
- */
-export type ResolveTabPrefixResult =
- | { status: "ok"; tab: TabRow }
- | { status: "none" }
- | { status: "ambiguous"; matches: TabRow[] };
-
-/**
- * Resolve a short tab handle to a single OPEN tab by prefix match — the
- * git-short-hash model. The handle is NEVER stored: it is always derived from
- * (and matched against) the canonical lowercase UUID in `tabs.id`.
- *
- * Sanitization is mandatory because the SQLite `LIKE` operator treats `%` and
- * `_` as wildcards: an unsanitized prefix like `a%` would match broadly. We
- * lowercase the input (UUIDs are canonical lowercase; SQLite `LIKE` is also
- * ASCII-case-insensitive by default) and strip everything outside the UUID
- * alphabet `[0-9a-f-]` so no wildcard can survive into the query.
- *
- * A prefix shorter than `MIN_TAB_PREFIX_LENGTH` after sanitization returns
- * `none` rather than matching a large swath of tabs.
- *
- * Only OPEN tabs (`is_open = 1`) are addressable — a closed tab's UUID prefix
- * must not cause phantom ambiguity or resolve to a dead conversation.
- */
-export function resolveTabPrefix(prefix: string): ResolveTabPrefixResult {
- const sanitized = (prefix ?? "").toLowerCase().replace(/[^0-9a-f-]/g, "");
- if (sanitized.length < MIN_TAB_PREFIX_LENGTH) {
- return { status: "none" };
- }
- const db = getDatabase();
- const rows = db
- .query("SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC")
- .all({ $prefix: `${sanitized}%` }) as Array<Record<string, unknown>>;
- if (rows.length === 0) return { status: "none" };
- if (rows.length === 1) return { status: "ok", tab: rowToTab(rows[0] as Record<string, unknown>) };
- return { status: "ambiguous", matches: rows.map(rowToTab) };
-}
-
-/**
- * Compute the shortest unique prefix (minimum `MIN_TAB_PREFIX_LENGTH` chars)
- * that identifies `tabId` among the currently OPEN tabs — the backend twin of
- * the frontend's display helper. Used when a tool needs to echo a tab's own
- * handle (e.g. provenance prefixes, "available tabs" hints) without trusting a
- * value from the wire.
- *
- * Returns the full id if no shorter unique prefix exists (degenerate — only if
- * two open tabs share an entire id, which UUID uniqueness precludes).
- */
-export function shortestUniquePrefix(tabId: string): string {
- const db = getDatabase();
- const rows = db.query("SELECT id FROM tabs WHERE is_open = 1").all() as Array<{ id: string }>;
- const others = rows.map((r) => r.id).filter((id) => id !== tabId);
- for (let len = MIN_TAB_PREFIX_LENGTH; len < tabId.length; len++) {
- const candidate = tabId.slice(0, len);
- if (!others.some((id) => id.startsWith(candidate))) return candidate;
- }
- return tabId;
-}