@saadnvd1/agent-os 0.3.672 → 0.3.678

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +2 -1
  2. package/app/api/bus/read/route.ts +34 -0
  3. package/app/api/files/upload-temp/route.test.ts +144 -0
  4. package/app/api/files/upload-temp/route.ts +52 -21
  5. package/app/api/search/conversations/context/route.ts +26 -0
  6. package/app/api/search/conversations/route.test.ts +139 -0
  7. package/app/api/search/conversations/route.ts +25 -0
  8. package/app/api/sessions/[id]/route.ts +2 -0
  9. package/app/api/sessions/route.ts +6 -2
  10. package/app/globals.css +25 -0
  11. package/components/Bookmarks/BookmarksDialog.tsx +2 -8
  12. package/components/Chat/ChatPanel.tsx +9 -2
  13. package/components/Chat/FileChip.tsx +67 -0
  14. package/components/Chat/Messages.tsx +8 -0
  15. package/components/Chat/Queue.tsx +6 -0
  16. package/components/Chat/composer/Attachments.tsx +16 -2
  17. package/components/Chat/composer/ComposerBody.tsx +35 -14
  18. package/components/Chat/composer/Toolbar.tsx +11 -8
  19. package/components/Chat/composer/editor.test.ts +1 -1
  20. package/components/Chat/composer/paste.ts +5 -4
  21. package/components/Chat/composer/useAttachFiles.ts +142 -0
  22. package/components/Chat/composer/useComposerEditor.ts +3 -3
  23. package/components/Chat/useDraft.ts +9 -2
  24. package/components/Chat/useGoToItem.ts +11 -0
  25. package/components/CommandPalette/CommandPalette.tsx +23 -156
  26. package/components/CommandPalette/CommandsBody.tsx +216 -0
  27. package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
  28. package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
  29. package/components/CommandPalette/conversations/Preview.tsx +129 -0
  30. package/components/CommandPalette/conversations/ResultList.tsx +91 -0
  31. package/components/CommandPalette/conversations/Snippet.tsx +24 -0
  32. package/components/CommandPalette/conversations/format.ts +30 -0
  33. package/components/CommandPalette/conversations/recent.test.ts +20 -0
  34. package/components/CommandPalette/conversations/recent.ts +35 -0
  35. package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
  36. package/components/Draft/DraftPanel.tsx +7 -4
  37. package/components/Terminal/index.tsx +6 -5
  38. package/data/chat/useChat.ts +10 -3
  39. package/data/client-config/queries.ts +18 -0
  40. package/data/search/index.ts +65 -0
  41. package/data/search/keys.ts +7 -0
  42. package/data/sessions/queries.ts +2 -1
  43. package/hooks/useDebouncedValue.ts +11 -0
  44. package/lib/agents/brief.ts +1 -0
  45. package/lib/attachments/check.ts +50 -0
  46. package/lib/attachments/message.ts +49 -0
  47. package/lib/attachments/policy.ts +116 -0
  48. package/lib/attachments/store.ts +92 -0
  49. package/lib/attachments/types.ts +8 -0
  50. package/lib/bus/condense.ts +242 -0
  51. package/lib/bus/read-scope.ts +109 -0
  52. package/lib/bus/read.ts +181 -0
  53. package/lib/chat/client-send.ts +6 -2
  54. package/lib/chat/driver.ts +4 -1
  55. package/lib/chat/drivers/claude.ts +2 -0
  56. package/lib/chat/events.ts +13 -1
  57. package/lib/chat/pending.ts +2 -1
  58. package/lib/chat/queued.ts +24 -7
  59. package/lib/chat/runner.ts +11 -2
  60. package/lib/chat/worker/host.ts +9 -3
  61. package/lib/chat/worker/protocol.ts +2 -0
  62. package/lib/config/client-config-types.ts +14 -3
  63. package/lib/config/client-config.ts +1 -1
  64. package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
  65. package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
  66. package/lib/file-upload.ts +61 -27
  67. package/lib/intake/intake.ts +2 -17
  68. package/lib/intake/sniff.ts +1 -1
  69. package/lib/orchestrator/read.ts +18 -4
  70. package/lib/palette/registry.ts +4 -0
  71. package/lib/resurrect/index.ts +2 -3
  72. package/lib/search/conversations/backfill.ts +117 -0
  73. package/lib/search/conversations/buildMatchQuery.ts +31 -0
  74. package/lib/search/conversations/index.ts +9 -0
  75. package/lib/search/conversations/messageContext.ts +45 -0
  76. package/lib/search/conversations/schema.ts +71 -0
  77. package/lib/search/conversations/searchConversations.ts +151 -0
  78. package/lib/search/conversations/types.ts +45 -0
  79. package/lib/sessions/launch.ts +12 -6
  80. package/lib/sidebar/row-actions.ts +19 -8
  81. package/lib/sidebar/row-menu.ts +1 -1
  82. package/lib/tasks/restorable.ts +16 -0
  83. package/package.json +1 -1
  84. package/scripts/bench-conversation-search.ts +73 -0
  85. package/server.ts +3 -0
  86. package/stores/chatJump.ts +10 -0
  87. package/stores/palette.ts +22 -4
  88. package/components/Chat/composer/useImageDrop.ts +0 -52
@@ -0,0 +1,14 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261019T1200_chat_queue_files",
5
+ up: (db) => {
6
+ // Files attached to a queued message, as a JSON list of references to
7
+ // the session's attachments folder (never the bytes).
8
+ const cols = db.prepare(`PRAGMA table_info(chat_queue)`).all() as {
9
+ name: string;
10
+ }[];
11
+ if (!cols.some((c) => c.name === "files"))
12
+ db.exec(`ALTER TABLE chat_queue ADD COLUMN files TEXT`);
13
+ },
14
+ } satisfies Migration;
@@ -0,0 +1,12 @@
1
+ import type { Migration } from "../migration-types";
2
+ import { createConversationSearch } from "../../search/conversations/schema";
3
+
4
+ export default {
5
+ key: "20261019T1500_conversation_search",
6
+ up: (db) => {
7
+ // The index and the triggers that keep it current. Existing chats are
8
+ // indexed in the background after startup (lib/search/conversations/backfill.ts),
9
+ // never in this migration, so a big database doesn't hold up the boot.
10
+ createConversationSearch(db);
11
+ },
12
+ } satisfies Migration;
@@ -1,34 +1,68 @@
1
- /**
2
- * Upload a file to temporary storage and return its path.
3
- * Converts the file to base64 and POSTs to /api/files/upload-temp.
4
- *
5
- * @param file - The file to upload
6
- * @returns The path to the uploaded file, or null if upload failed
7
- */
8
- export async function uploadFileToTemp(file: File): Promise<string | null> {
9
- const buffer = await file.arrayBuffer();
10
- const base64 = btoa(
11
- new Uint8Array(buffer).reduce(
12
- (data, byte) => data + String.fromCharCode(byte),
13
- ""
14
- )
15
- );
1
+ import type { AttachmentKind } from "./attachments/policy";
2
+ import type { ChatFile } from "./attachments/types";
16
3
 
17
- const res = await fetch("/api/files/upload-temp", {
18
- method: "POST",
19
- headers: { "Content-Type": "application/json" },
20
- body: JSON.stringify({
21
- filename: file.name || `file-${Date.now()}`,
22
- base64,
23
- mimeType: file.type || "application/octet-stream",
24
- }),
4
+ export type UploadResult =
5
+ | { ok: true; file: ChatFile; kind: AttachmentKind }
6
+ | { ok: false; error: string };
7
+
8
+ function toBase64(file: File): Promise<string> {
9
+ return new Promise((resolve, reject) => {
10
+ const reader = new FileReader();
11
+ reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
12
+ reader.onerror = () => reject(reader.error);
13
+ reader.readAsDataURL(file);
25
14
  });
15
+ }
26
16
 
27
- const data = await res.json();
28
- if (data.path) {
29
- return data.path;
17
+ /**
18
+ * Upload a file through /api/files/upload-temp. With a `sessionId` (or
19
+ * "draft") the server checks it against its attachment limits and keeps it in
20
+ * that session's attachments folder; without one it goes to temp storage.
21
+ * The error is the server's own message, ready to show.
22
+ */
23
+ export async function uploadFile(
24
+ file: File,
25
+ sessionId?: string
26
+ ): Promise<UploadResult> {
27
+ try {
28
+ const res = await fetch("/api/files/upload-temp", {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify({
32
+ filename: file.name || `file-${Date.now()}`,
33
+ base64: await toBase64(file),
34
+ mimeType: file.type || "application/octet-stream",
35
+ sessionId,
36
+ }),
37
+ });
38
+ const data = await res.json().catch(() => ({}));
39
+ if (res.ok && data.path)
40
+ return {
41
+ ok: true,
42
+ kind: data.kind,
43
+ file: {
44
+ path: data.path,
45
+ name: data.name,
46
+ size: data.size,
47
+ mediaType: data.mediaType,
48
+ },
49
+ };
50
+ return {
51
+ ok: false,
52
+ error: data.error ?? `Couldn't upload ${file.name} (${res.status})`,
53
+ };
54
+ } catch {
55
+ return { ok: false, error: `Couldn't upload ${file.name}` };
30
56
  }
57
+ }
31
58
 
32
- console.error("Upload failed:", data.error);
59
+ /** The saved path, or null when the upload failed (terminal and picker). */
60
+ export async function uploadFileToTemp(
61
+ file: File,
62
+ sessionId?: string
63
+ ): Promise<string | null> {
64
+ const r = await uploadFile(file, sessionId);
65
+ if (r.ok) return r.file.path;
66
+ console.error("Upload failed:", r.error);
33
67
  return null;
34
68
  }
@@ -8,6 +8,7 @@ import { getWorkspace } from "../workspaces";
8
8
  import { INTAKE_LIMITS, MAX_INLINE_IMAGE_BYTES } from "./limits";
9
9
  import { buildIntakeMessage, type IntakeMeta } from "./message";
10
10
  import { sniff } from "./sniff";
11
+ import { safeName } from "../attachments/store";
11
12
 
12
13
  export class IntakeError extends Error {
13
14
  constructor(
@@ -39,23 +40,7 @@ const day = (t: number) => new Date(t).toISOString().slice(0, 10);
39
40
  const workspaceRoot = (workspaceId: string) =>
40
41
  path.join(dataRoot(), "intake", segment(workspaceId));
41
42
 
42
- /** A phone's filename as one safe path segment. */
43
- export function safeName(name: string, taken: Set<string>, n: number): string {
44
- let base = path.basename(name.replace(/\\/g, "/"));
45
- base = base
46
- .replace(/[^a-zA-Z0-9.-]/g, "_")
47
- .replace(/^\.+/, "")
48
- .slice(0, 80);
49
- if (!base) base = `file-${n}`;
50
- let out = base;
51
- for (let i = 2; taken.has(out); i++) {
52
- const dot = base.lastIndexOf(".");
53
- out =
54
- dot > 0 ? `${base.slice(0, dot)}-${i}${base.slice(dot)}` : `${base}-${i}`;
55
- }
56
- taken.add(out);
57
- return out;
58
- }
43
+ export { safeName };
59
44
 
60
45
  function checkMeta(meta: IntakeMeta) {
61
46
  if (!meta || typeof meta !== "object") throw new IntakeError(400, "Bad meta");
@@ -9,7 +9,7 @@ const BLOCKED_NAME =
9
9
  const starts = (b: Buffer, sig: number[], at = 0) =>
10
10
  sig.every((v, i) => b[at + i] === v);
11
11
 
12
- function isText(b: Buffer): boolean {
12
+ export function isText(b: Buffer): boolean {
13
13
  const head = b.subarray(0, 8192);
14
14
  if (head.includes(0)) return false;
15
15
  try {
@@ -10,6 +10,7 @@ import { hostLink } from "../hosts/remote-api";
10
10
  import { peerPane } from "../hosts/peer-sessions";
11
11
  import { workspaceSessions } from "./facts";
12
12
  import { untrusted } from "./untrusted";
13
+ import { grepText } from "../bus/condense";
13
14
  import { heldChatTail, isHeldRemote } from "../tasks/contract/held-calls";
14
15
  import {
15
16
  findExternalSession,
@@ -55,7 +56,10 @@ export function findWorkspaceSession(
55
56
  // A session AgentOS didn't start: its screen, read the way the sidebar
56
57
  // reads one (a linked machine's only through its AgentOS, which doesn't
57
58
  // serve a screen it didn't start).
58
- async function readExternal(s: ExternalSession, n: number): Promise<string> {
59
+ export async function readExternal(
60
+ s: ExternalSession,
61
+ n: number
62
+ ): Promise<string> {
59
63
  const head = `${s.ref} (external terminal on ${s.host}, not started by AgentOS)`;
60
64
  if (hostLink(s.hostId))
61
65
  return `${head}: its screen is on that machine and isn't served to this one.`;
@@ -127,6 +131,16 @@ export async function readSession(
127
131
  if (!external) throw error;
128
132
  return readExternal(external, n);
129
133
  }
134
+ return readResolved(s, n);
135
+ }
136
+
137
+ // One session's recent conversation or screen, whichever it has.
138
+ export async function readResolved(
139
+ s: Session,
140
+ n: number,
141
+ grep?: string
142
+ ): Promise<string> {
143
+ const cut = (text: string) => tail(grep ? grepText(text, grep) : text, n);
130
144
  const link = hostLink(s.host_id);
131
145
  if (s.view === "chat" && link) {
132
146
  // Its orchestrator's task is read through the machine that holds it.
@@ -137,12 +151,12 @@ export async function readSession(
137
151
  }
138
152
  if (s.view === "chat") {
139
153
  const text = chatText(lastItems(s.id, n * 2));
140
- const body = text ? untrusted(s.name, tail(text, n)) : "(no messages yet)";
154
+ const body = text ? untrusted(s.name, cut(text)) : "(no messages yet)";
141
155
  return `${s.name} (chat), last ${n} lines:\n${body}`;
142
156
  }
143
157
  if (link) {
144
158
  const lines = await peerPane(link, s.id);
145
- const body = lines ? tail(lines.join("\n"), n) : "";
159
+ const body = lines ? cut(lines.join("\n")) : "";
146
160
  return `${s.name} (terminal on ${link.hostName}), last ${n} lines:\n${body ? untrusted(s.name, body) : "(couldn't be read)"}`;
147
161
  }
148
162
  await statusDetector.refreshCache();
@@ -152,6 +166,6 @@ export async function readSession(
152
166
  s.host_id || "local",
153
167
  `tmux capture-pane -t ${shellQuote(`=${s.tmux_name}:`)} -p -J -S -${n}`
154
168
  );
155
- const body = tail(pane, n);
169
+ const body = cut(pane);
156
170
  return `${s.name} (terminal), last ${n} lines:\n${body ? untrusted(s.name, body) : "(empty)"}`;
157
171
  }
@@ -12,6 +12,10 @@ export interface PaletteCommand {
12
12
  keywords?: string[];
13
13
  // Shown beside it, e.g. ⌘K.
14
14
  hint?: string;
15
+ // A line under the title (what a conversation hit said).
16
+ detail?: string;
17
+ // Running it leaves the palette open (it changes the palette itself).
18
+ keepOpen?: boolean;
15
19
  icon?: unknown;
16
20
  run: () => void;
17
21
  }
@@ -46,6 +46,7 @@ import { planFromTranscript } from "./plan";
46
46
  import { dropSnapshot, restoreWorktree, type RestoredAs } from "./snapshot";
47
47
  import { restoreSwept, sweptNote } from "./swept";
48
48
  import { forgetSwept } from "../worktree-sweep/store";
49
+ import { isRestorableTask } from "../tasks/restorable";
49
50
  import { setPr, setTask } from "../db/session-parts";
50
51
 
51
52
  export interface ResurrectOptions {
@@ -66,8 +67,6 @@ export interface ResurrectOutcome {
66
67
  const row = (id: string) =>
67
68
  queries.getSession(db).get(id) as Session | undefined;
68
69
 
69
- const ENDED_TASK = new Set(["dropped", "done"]);
70
-
71
70
  // Restores in flight here, so two clicks can't both build a worktree.
72
71
  const g = globalThis as unknown as { __agentosResurrecting?: Set<string> };
73
72
  const inFlight = (g.__agentosResurrecting ??= new Set());
@@ -113,7 +112,7 @@ export async function resurrectSession(
113
112
  throw new Error(`${s.name} runs on ${peer.hostName}: restore it there.`);
114
113
  if (isMirror(s)) return onItsMachine(s);
115
114
 
116
- const endedTask = !!s.task_status && ENDED_TASK.has(s.task_status);
115
+ const endedTask = isRestorableTask(s.task_status);
117
116
  if (s.task_status === "merged")
118
117
  throw new Error(
119
118
  `${s.name} was merged: its work is in the base branch. Start a new task to continue it.`
@@ -0,0 +1,117 @@
1
+ import type Database from "better-sqlite3";
2
+ import { getDb } from "@/lib/db";
3
+ import { SCHEMA_VERSION, SEARCHED_KINDS } from "./schema";
4
+
5
+ const BATCH = 500;
6
+
7
+ interface State {
8
+ version: number;
9
+ cursor: number;
10
+ backfill_to: number;
11
+ done: number;
12
+ }
13
+
14
+ const stateOf = (db: Database.Database): State | undefined =>
15
+ db.prepare(`SELECT * FROM chat_search_state WHERE id = 1`).get() as
16
+ | State
17
+ | undefined;
18
+
19
+ // Starts the index over: the stored version is not this build's.
20
+ function reset(db: Database.Database): void {
21
+ db.transaction(() => {
22
+ db.exec(`DELETE FROM chat_search; DELETE FROM chat_search_keys`);
23
+ db.prepare(
24
+ `UPDATE chat_search_state SET version = ?, cursor = 0, done = 0,
25
+ backfill_to = (SELECT COALESCE(MAX(rowid), 0) FROM chat_items)
26
+ WHERE id = 1`
27
+ ).run(SCHEMA_VERSION);
28
+ })();
29
+ }
30
+
31
+ // Indexes one batch of the chats that were already there when the index was
32
+ // made, in rowid order, and records where it got to in the same transaction:
33
+ // stopped at any point, the next call carries on. Returns true when finished.
34
+ export function backfillStep(db: Database.Database, batch = BATCH): boolean {
35
+ const state = stateOf(db);
36
+ if (!state) return true;
37
+ if (state.version !== SCHEMA_VERSION) reset(db);
38
+ else if (state.done) return true;
39
+ const { cursor, backfill_to } = stateOf(db)!;
40
+ const kinds = SEARCHED_KINDS.map(() => "?").join(", ");
41
+ return db.transaction(() => {
42
+ // Rowids are not dense (tool calls are most rows), so the batch is
43
+ // counted in rows of the kinds searched and the cursor moves to the last.
44
+ const rows = db
45
+ .prepare(
46
+ `SELECT rowid AS rid, session_id, item_id,
47
+ json_extract(data, '$.text') AS text
48
+ FROM chat_items
49
+ WHERE rowid > ? AND rowid <= ? AND kind IN (${kinds})
50
+ ORDER BY rowid LIMIT ?`
51
+ )
52
+ .all(cursor, backfill_to, ...SEARCHED_KINDS, batch) as {
53
+ rid: number;
54
+ session_id: string;
55
+ item_id: string;
56
+ text: string | null;
57
+ }[];
58
+ const key = db.prepare(
59
+ `SELECT id FROM chat_search_keys WHERE session_id = ? AND item_id = ?`
60
+ );
61
+ const del = db.prepare(`DELETE FROM chat_search WHERE rowid = ?`);
62
+ const mk = db.prepare(
63
+ `INSERT OR IGNORE INTO chat_search_keys (session_id, item_id) VALUES (?, ?)`
64
+ );
65
+ const put = db.prepare(
66
+ `INSERT INTO chat_search (rowid, body) VALUES (?, ?)`
67
+ );
68
+ for (const r of rows) {
69
+ if (!r.text) continue;
70
+ const have = key.get(r.session_id, r.item_id) as
71
+ | { id: number }
72
+ | undefined;
73
+ if (have) del.run(have.id);
74
+ else mk.run(r.session_id, r.item_id);
75
+ const id = (key.get(r.session_id, r.item_id) as { id: number }).id;
76
+ put.run(id, r.text);
77
+ }
78
+ const finished = rows.length < batch;
79
+ const next = finished ? backfill_to : rows[rows.length - 1].rid;
80
+ db.prepare(
81
+ `UPDATE chat_search_state SET cursor = ?, done = ? WHERE id = 1`
82
+ ).run(next, finished ? 1 : 0);
83
+ return finished;
84
+ })();
85
+ }
86
+
87
+ // 0..1 while the first index is being built; null once it is complete.
88
+ export function indexProgress(): number | null {
89
+ const s = stateOf(getDb());
90
+ if (!s || (s.done && s.version === SCHEMA_VERSION)) return null;
91
+ return s.backfill_to ? Math.min(1, s.cursor / s.backfill_to) : 0;
92
+ }
93
+
94
+ let running = false;
95
+
96
+ // Runs batches until done, giving the event loop back between them, so a
97
+ // large history never holds up a request or the boot.
98
+ export function startConversationBackfill(): void {
99
+ if (running) return;
100
+ running = true;
101
+ const t0 = Date.now();
102
+ const tick = () => {
103
+ try {
104
+ if (backfillStep(getDb())) {
105
+ running = false;
106
+ console.log(`Conversation search index ready (${Date.now() - t0} ms)`);
107
+ return;
108
+ }
109
+ } catch (error) {
110
+ running = false;
111
+ console.error("Indexing conversations for search failed:", error);
112
+ return;
113
+ }
114
+ setImmediate(tick);
115
+ };
116
+ setImmediate(tick);
117
+ }
@@ -0,0 +1,31 @@
1
+ export interface MatchQuery {
2
+ // An FTS5 MATCH expression.
3
+ match: string;
4
+ // The words typed, lowercased, for marking a preview.
5
+ terms: string[];
6
+ }
7
+
8
+ const HAS_WORD = /[\p{L}\p{N}]/u;
9
+ const WORDS = /[\p{L}\p{N}_]+/gu;
10
+ // A quoted phrase (possibly still being typed) or a bare word.
11
+ const TOKEN = /"([^"]*)(")?|[^\s"]+/g;
12
+
13
+ // What was typed as an FTS5 expression. Every piece goes in as a quoted
14
+ // string, so FTS syntax typed by hand (AND, NEAR, *, :, -, parentheses) is
15
+ // plain text. Bare words match as prefixes, so a word being typed finds its
16
+ // completions; a closed "phrase" is exact, an open one is a prefix on its last
17
+ // word. Null when nothing searchable was typed.
18
+ export function buildMatchQuery(input: string): MatchQuery | null {
19
+ const parts: string[] = [];
20
+ const terms: string[] = [];
21
+ for (const m of input.matchAll(TOKEN)) {
22
+ const quoted = m[1] !== undefined;
23
+ const text = (quoted ? m[1] : m[0]).trim();
24
+ if (!HAS_WORD.test(text)) continue;
25
+ const prefix = !quoted || !m[2];
26
+ parts.push(`"${text}"${prefix ? "*" : ""}`);
27
+ for (const w of text.toLowerCase().match(WORDS) ?? []) terms.push(w);
28
+ }
29
+ if (!parts.length || terms.join("").length < 2) return null;
30
+ return { match: parts.join(" "), terms };
31
+ }
@@ -0,0 +1,9 @@
1
+ export { buildMatchQuery } from "./buildMatchQuery";
2
+ export { searchConversations } from "./searchConversations";
3
+ export { getMessageContext } from "./messageContext";
4
+ export {
5
+ startConversationBackfill,
6
+ backfillStep,
7
+ indexProgress,
8
+ } from "./backfill";
9
+ export type * from "./types";
@@ -0,0 +1,45 @@
1
+ import { getDb } from "@/lib/db";
2
+ import type { ContextItem } from "./types";
3
+
4
+ interface Row {
5
+ item_id: string;
6
+ kind: "user" | "assistant";
7
+ text: string | null;
8
+ created_at: number | null;
9
+ seq: number;
10
+ }
11
+
12
+ const COLS = `item_id, kind, seq, json_extract(data, '$.text') AS text,
13
+ json_extract(data, '$.createdAt') AS created_at`;
14
+
15
+ // A message and the ones said just before and after it, in order. Null when
16
+ // the message is gone. Only text: images and files stay out of a preview.
17
+ export function getMessageContext(
18
+ sessionId: string,
19
+ itemId: string,
20
+ around = 3
21
+ ): ContextItem[] | null {
22
+ const db = getDb();
23
+ const hit = db
24
+ .prepare(
25
+ `SELECT ${COLS} FROM chat_items WHERE session_id = ? AND item_id = ?`
26
+ )
27
+ .get(sessionId, itemId) as Row | undefined;
28
+ if (!hit) return null;
29
+ const side = (op: "<" | ">", order: "ASC" | "DESC") =>
30
+ db
31
+ .prepare(
32
+ `SELECT ${COLS} FROM chat_items
33
+ WHERE session_id = ? AND kind IN ('user', 'assistant') AND seq ${op} ?
34
+ ORDER BY seq ${order} LIMIT ?`
35
+ )
36
+ .all(sessionId, hit.seq, around) as Row[];
37
+ const rows = [...side("<", "DESC").reverse(), hit, ...side(">", "ASC")];
38
+ return rows.map((r) => ({
39
+ id: r.item_id,
40
+ role: r.kind,
41
+ text: r.text ?? "",
42
+ createdAt: r.created_at,
43
+ hit: r.item_id === itemId,
44
+ }));
45
+ }
@@ -0,0 +1,71 @@
1
+ import type Database from "better-sqlite3";
2
+
3
+ // Bump to rebuild the index from scratch (a different set of kinds, a new
4
+ // tokenizer); the next start's backfill does it.
5
+ export const SCHEMA_VERSION = 1;
6
+
7
+ // The item kinds whose text is searched. Tool calls and their output are
8
+ // most of the rows and mostly noise.
9
+ export const SEARCHED_KINDS = ["user", "assistant"] as const;
10
+
11
+ const KINDS_SQL = SEARCHED_KINDS.map((k) => `'${k}'`).join(", ");
12
+
13
+ // An item worth indexing: a searched kind with some text.
14
+ const eligible = `new.kind IN (${KINDS_SQL}) AND COALESCE(json_extract(new.data, '$.text'), '') <> ''`;
15
+
16
+ // The item's own key: the FTS rowid. chat_items has no integer primary key,
17
+ // so its rowids can be renumbered by a VACUUM.
18
+ const keyOf = (row: "new" | "old") =>
19
+ `(SELECT id FROM chat_search_keys WHERE session_id = ${row}.session_id AND item_id = ${row}.item_id)`;
20
+
21
+ const reindexItem = `
22
+ DELETE FROM chat_search WHERE rowid = ${keyOf("old")};
23
+ DELETE FROM chat_search_keys WHERE session_id = old.session_id AND item_id = old.item_id;
24
+ INSERT INTO chat_search_keys (session_id, item_id)
25
+ SELECT new.session_id, new.item_id WHERE ${eligible};
26
+ INSERT INTO chat_search (rowid, body)
27
+ SELECT ${keyOf("new")}, json_extract(new.data, '$.text') WHERE ${eligible};`;
28
+
29
+ // Every writer of chat_items (the server, the chat worker, an import) is
30
+ // indexed by these, so no code path can forget.
31
+ export function createConversationSearch(db: Database.Database): void {
32
+ db.exec(`
33
+ CREATE TABLE IF NOT EXISTS chat_search_keys (
34
+ id INTEGER PRIMARY KEY,
35
+ session_id TEXT NOT NULL,
36
+ item_id TEXT NOT NULL,
37
+ UNIQUE (session_id, item_id)
38
+ );
39
+ CREATE VIRTUAL TABLE IF NOT EXISTS chat_search USING fts5(
40
+ body,
41
+ tokenize = "unicode61 remove_diacritics 2 tokenchars '_'",
42
+ prefix = '2 3'
43
+ );
44
+ CREATE TABLE IF NOT EXISTS chat_search_state (
45
+ id INTEGER PRIMARY KEY CHECK (id = 1),
46
+ version INTEGER NOT NULL,
47
+ cursor INTEGER NOT NULL DEFAULT 0,
48
+ backfill_to INTEGER NOT NULL DEFAULT 0,
49
+ done INTEGER NOT NULL DEFAULT 0
50
+ );
51
+ INSERT OR IGNORE INTO chat_search_state (id, version, backfill_to, done)
52
+ SELECT 1, ${SCHEMA_VERSION}, COALESCE(MAX(rowid), 0), MAX(rowid) IS NULL
53
+ FROM chat_items;
54
+ CREATE TRIGGER IF NOT EXISTS chat_search_ai AFTER INSERT ON chat_items
55
+ BEGIN
56
+ DELETE FROM chat_search WHERE rowid = ${keyOf("new")};
57
+ INSERT OR IGNORE INTO chat_search_keys (session_id, item_id)
58
+ SELECT new.session_id, new.item_id WHERE ${eligible};
59
+ INSERT INTO chat_search (rowid, body)
60
+ SELECT ${keyOf("new")}, json_extract(new.data, '$.text') WHERE ${eligible};
61
+ END;
62
+ CREATE TRIGGER IF NOT EXISTS chat_search_au AFTER UPDATE OF data ON chat_items
63
+ BEGIN${reindexItem}
64
+ END;
65
+ CREATE TRIGGER IF NOT EXISTS chat_search_ad AFTER DELETE ON chat_items
66
+ BEGIN
67
+ DELETE FROM chat_search WHERE rowid = ${keyOf("old")};
68
+ DELETE FROM chat_search_keys WHERE session_id = old.session_id AND item_id = old.item_id;
69
+ END;
70
+ `);
71
+ }