conductor-remote 1.40.0 → 1.42.0

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.
@@ -0,0 +1,333 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { parseMessage } from "./transcript.js";
5
+ /**
6
+ * Full-text search over the chat history, in a sidecar DB the relay owns.
7
+ *
8
+ * Two facts decide the whole shape of this file.
9
+ *
10
+ * **Prose is 1.2% of the transcript.** Measured over this Mac's 1.7M
11
+ * `session_messages` rows (3,106 MB of `content`): tool_result output is 799 MB,
12
+ * `type:"system"` frames 522 MB, tool_use arguments 162 MB, thinking 77 MB — and
13
+ * what a person would ever search for, the assistant's own words plus the prompts
14
+ * they typed, is **38 MB**. So this indexes prose only. A grep of the raw column
15
+ * would be a 3 GB scan to search 38 MB, and it would rank a file dump the agent
16
+ * happened to `cat` above the sentence that explained the decision.
17
+ *
18
+ * **`node:sqlite` ships FTS5.** Porter stemming, `bm25()`, `snippet()`, `NEAR()`
19
+ * all work on the bundled SQLite (3.51.2), so the index costs no runtime
20
+ * dependency — which the tarball rule requires (see CLAUDE.md ▸ Traps). Measured
21
+ * on the full history: 7.6s to build, 111,079 chunks, queries in 1–7ms.
22
+ *
23
+ * The index is **never** written into `conductor.db`. That handle is read-only and
24
+ * stays that way; this opens its own file under the relay's state dir, and it is
25
+ * disposable — delete it and the next start rebuilds it.
26
+ */
27
+ /** Bump to force a rebuild: a tokenizer or extraction change makes every stored chunk wrong. */
28
+ const SCHEMA_VERSION = 1;
29
+ /**
30
+ * Source rows advanced per tick. The cursor moves by *scanned* rowid rather than
31
+ * matched rowid, so a caught-up index re-scans nothing — get that wrong and every
32
+ * idle poll re-reads the 3 GB tail looking for rows it already rejected.
33
+ */
34
+ const WINDOW_ROWS = 4000;
35
+ /** Yield to the event loop between batches: the backfill is ~19ms of blocking work per window. */
36
+ const BACKFILL_PAUSE_MS = 5;
37
+ /** Once caught up, look for new messages at about the rate a chat produces them. */
38
+ const IDLE_POLL_MS = 15_000;
39
+ /** A pathological single message can't be allowed to dominate the index. */
40
+ const MAX_CHUNK_CHARS = 64_000;
41
+ /** How many chunks a query ranks before they are folded into workspaces. */
42
+ const CHUNK_LIMIT = 300;
43
+ /**
44
+ * Snippet highlight markers. Control characters rather than brackets: they survive
45
+ * JSON, they need no escaping on the way to the phone, and no transcript contains
46
+ * them, so `web/src/lib/format.ts` can split on them without a parser. The client
47
+ * must not render them literally — see `splitSnippet`.
48
+ */
49
+ export const HIT_OPEN = '\u0001';
50
+ export const HIT_CLOSE = '\u0002';
51
+ /**
52
+ * Turn a phone query into an FTS5 MATCH expression.
53
+ *
54
+ * Every token is quoted, because FTS5 reads `-`, `*`, `:`, `(`, `AND`, `NEAR` and
55
+ * friends as syntax: an unquoted apostrophe or hyphen is a *parse error*, not a
56
+ * poor result, so a raw query would fail rather than under-match. Tokens are OR'd
57
+ * because someone reaching for a workspace on a phone is recalling it, not
58
+ * filtering it — BM25 is what puts the message matching four of four words above
59
+ * the one matching one, and requiring all four would return nothing whenever a
60
+ * single word is misremembered.
61
+ *
62
+ * The last token gets a prefix `*` so search-as-you-type matches mid-word, but only
63
+ * from three characters: `"a"*` matches a large fraction of the index and would
64
+ * spend the whole query budget on a keystroke that means nothing yet.
65
+ */
66
+ export function matchQuery(raw) {
67
+ const tokens = raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu);
68
+ if (!tokens?.length)
69
+ return null;
70
+ const terms = tokens.map(t => `"${t}"`);
71
+ const last = tokens[tokens.length - 1];
72
+ if (!/\s$/.test(raw) && last.length >= 3)
73
+ terms[terms.length - 1] = `"${last}"*`;
74
+ return terms.join(' OR ');
75
+ }
76
+ /** The tokens `matchQuery` will search for — what a caller matches names against. */
77
+ export function queryTokens(raw) {
78
+ return raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
79
+ }
80
+ export class SearchIndex {
81
+ source;
82
+ file;
83
+ db = null;
84
+ openError = null;
85
+ cursor = 0;
86
+ caughtUp = false;
87
+ timer = null;
88
+ sourceMax = 0;
89
+ constructor(source, file) {
90
+ this.source = source;
91
+ this.file = file;
92
+ }
93
+ /** Open (or rebuild) the sidecar and start indexing in the background. */
94
+ start() {
95
+ try {
96
+ this.open();
97
+ }
98
+ catch (err) {
99
+ // A search index is a convenience; failing to open one must never stop the relay
100
+ // serving state, transcripts or sends. Report it on /api/search instead.
101
+ this.openError = err instanceof Error ? err.message : String(err);
102
+ console.warn(`⚠ search index unavailable (${this.openError}) — /api/search will report it`);
103
+ return;
104
+ }
105
+ this.schedule(0);
106
+ }
107
+ stop() {
108
+ if (this.timer)
109
+ clearTimeout(this.timer);
110
+ this.timer = null;
111
+ }
112
+ open() {
113
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
114
+ const db = new DatabaseSync(this.file);
115
+ db.exec('PRAGMA journal_mode = WAL');
116
+ db.exec('PRAGMA synchronous = NORMAL');
117
+ // A dev relay on another port shares this file with the LaunchAgent's. WAL lets them
118
+ // both read; the writer that loses waits rather than throwing away its batch, and a
119
+ // tick that still fails is retried by the scheduler with nothing lost (the cursor
120
+ // only advances on commit).
121
+ db.exec('PRAGMA busy_timeout = 5000');
122
+ db.exec('CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)');
123
+ const version = Number(this.readMeta(db, 'version') ?? 0);
124
+ if (version !== SCHEMA_VERSION) {
125
+ db.exec('DROP TABLE IF EXISTS chunks');
126
+ db.exec(`
127
+ CREATE VIRTUAL TABLE chunks USING fts5(
128
+ body,
129
+ session_id UNINDEXED,
130
+ src_rowid UNINDEXED,
131
+ role UNINDEXED,
132
+ at UNINDEXED,
133
+ tokenize='porter unicode61'
134
+ )
135
+ `);
136
+ db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('version', String(SCHEMA_VERSION));
137
+ db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('cursor', '0');
138
+ if (version)
139
+ console.log(`search index schema ${version} → ${SCHEMA_VERSION}, rebuilding`);
140
+ }
141
+ this.db = db;
142
+ this.cursor = Number(this.readMeta(db, 'cursor') ?? 0);
143
+ }
144
+ readMeta(db, key) {
145
+ const row = db.prepare('SELECT v FROM meta WHERE k = ?').get(key);
146
+ return row?.v ?? null;
147
+ }
148
+ schedule(ms) {
149
+ if (this.timer)
150
+ clearTimeout(this.timer);
151
+ this.timer = setTimeout(() => {
152
+ let more = false;
153
+ try {
154
+ more = this.tick();
155
+ }
156
+ catch (err) {
157
+ console.warn(`⚠ search index tick failed: ${err instanceof Error ? err.message : err}`);
158
+ }
159
+ this.schedule(more ? BACKFILL_PAUSE_MS : IDLE_POLL_MS);
160
+ }, ms);
161
+ this.timer.unref?.();
162
+ }
163
+ /**
164
+ * Index one window of source rows. Returns true while there is more to do.
165
+ *
166
+ * The window is picked by rowid *before* the prose filter runs, so the cursor
167
+ * advances past rows that hold nothing worth indexing. Filtering first and
168
+ * advancing to the last match instead would leave a caught-up index re-scanning
169
+ * every tool_result between the last prose row and the end of the table, every
170
+ * poll, forever.
171
+ */
172
+ tick() {
173
+ const db = this.db;
174
+ if (!db)
175
+ return false;
176
+ const window = this.source.query('SELECT rowid FROM session_messages WHERE rowid > ? ORDER BY rowid LIMIT ?', [this.cursor, WINDOW_ROWS]);
177
+ if (!window.length) {
178
+ this.caughtUp = true;
179
+ return false;
180
+ }
181
+ const end = window[window.length - 1].rowid;
182
+ // Only rows that can hold prose: a plain-text prompt, or a frame carrying a text
183
+ // block. Everything else is tool plumbing and 98.8% of the bytes.
184
+ const rows = this.source.query(`SELECT rowid, id, session_id, role, content, full_message, created_at, sent_at, queue_order
185
+ FROM session_messages
186
+ WHERE rowid > ? AND rowid <= ? AND session_id IS NOT NULL
187
+ AND (role = 'user' OR content LIKE '%"type":"text"%')
188
+ ORDER BY rowid`, [this.cursor, end]);
189
+ const insert = db.prepare('INSERT INTO chunks(body, session_id, src_rowid, role, at) VALUES (?, ?, ?, ?, ?)');
190
+ db.exec('BEGIN');
191
+ try {
192
+ for (const row of rows) {
193
+ // Reuse the transcript parser rather than a second JSON walk: it already knows
194
+ // that text inside a `type:"user"` frame is injected context and not the user's
195
+ // words, and indexing something the chat view would never show is how a search
196
+ // result becomes impossible to find once you open it.
197
+ for (const entry of parseMessage(row, null)) {
198
+ if (entry.role !== 'user' && entry.role !== 'assistant')
199
+ continue;
200
+ const body = entry.text.trim();
201
+ if (!body)
202
+ continue;
203
+ insert.run(body.slice(0, MAX_CHUNK_CHARS), row.session_id, row.rowid, entry.role, entry.ts);
204
+ }
205
+ }
206
+ db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('cursor', String(end));
207
+ db.exec('COMMIT');
208
+ }
209
+ catch (err) {
210
+ db.exec('ROLLBACK');
211
+ throw err;
212
+ }
213
+ this.cursor = end;
214
+ return true;
215
+ }
216
+ status() {
217
+ if (this.openError)
218
+ return { chunks: 0, ready: false, progress: 0, error: this.openError };
219
+ const db = this.db;
220
+ if (!db)
221
+ return { chunks: 0, ready: false, progress: 0 };
222
+ const chunks = Number(db.prepare('SELECT COUNT(*) c FROM chunks').get().c);
223
+ if (this.caughtUp)
224
+ return { chunks, ready: true, progress: 1 };
225
+ // Only re-read the source's high-water mark while backfilling; it costs a query
226
+ // and the answer only matters for the progress bar.
227
+ if (!this.sourceMax) {
228
+ const max = this.source.query('SELECT MAX(rowid) m FROM session_messages')[0]?.m;
229
+ this.sourceMax = max ?? 0;
230
+ }
231
+ const progress = this.sourceMax ? Math.min(1, this.cursor / this.sourceMax) : 0;
232
+ return { chunks, ready: false, progress };
233
+ }
234
+ /** Top matching chunks, best first. Empty when the query has no searchable tokens. */
235
+ search(raw, limit = CHUNK_LIMIT) {
236
+ const db = this.db;
237
+ if (!db)
238
+ return [];
239
+ const match = matchQuery(raw);
240
+ if (!match)
241
+ return [];
242
+ let rows;
243
+ try {
244
+ rows = db
245
+ .prepare(`SELECT session_id, src_rowid, role, at, -bm25(chunks) AS score,
246
+ snippet(chunks, 0, ?, ?, '…', 24) AS snippet
247
+ FROM chunks WHERE chunks MATCH ? ORDER BY bm25(chunks) LIMIT ?`)
248
+ .all(HIT_OPEN, HIT_CLOSE, match, limit);
249
+ }
250
+ catch (err) {
251
+ // A MATCH that still fails to parse is a bug in matchQuery, not user error —
252
+ // report it rather than showing an empty result that looks like "no matches".
253
+ throw new Error(`search failed for ${JSON.stringify(match)}: ${err instanceof Error ? err.message : err}`);
254
+ }
255
+ return rows.map(r => ({
256
+ sessionId: r.session_id,
257
+ srcRowid: Number(r.src_rowid),
258
+ role: r.role === 'user' ? 'user' : 'assistant',
259
+ at: r.at,
260
+ score: Number(r.score),
261
+ snippet: r.snippet
262
+ }));
263
+ }
264
+ }
265
+ const SNIPPETS_PER_RESULT = 3;
266
+ /**
267
+ * Fold chunk hits up into workspaces.
268
+ *
269
+ * Chunk-level results are the wrong unit here: one long conversation produces a
270
+ * dozen and buries every other workspace. What to do with those dozen is the whole
271
+ * ranking decision, and it was measured rather than guessed — searching this Mac's
272
+ * history for "removing adding lamp manual", where the right answer is a chat that
273
+ * says "Add by name is gone. Removed the form":
274
+ *
275
+ * summing every hit → right answer ranks 9th
276
+ * best single hit → 5th
277
+ * sum of the top 3 → 5th, and steadier across other queries
278
+ *
279
+ * Summing everything ranks by *volume*: a 32-message conversation about lamps beat
280
+ * the four messages that actually removed the feature. So only the best
281
+ * `SNIPPETS_PER_RESULT` hits score, which caps what repetition can buy and makes
282
+ * the number mean something the user can check — the score is exactly the strength
283
+ * of the snippets shown under the row. `hits` still counts them all.
284
+ *
285
+ * Hits whose session no longer resolves to a workspace are dropped; a result nobody
286
+ * can open is worse than one fewer result.
287
+ */
288
+ export function foldHits(hits, resolve) {
289
+ const byWorkspace = new Map();
290
+ for (const hit of hits) {
291
+ const workspace = resolve(hit.sessionId);
292
+ if (!workspace)
293
+ continue;
294
+ let entry = byWorkspace.get(workspace.id);
295
+ if (!entry) {
296
+ entry = {
297
+ workspace,
298
+ sessionId: null,
299
+ hits: 0,
300
+ score: 0,
301
+ at: null,
302
+ snippets: [],
303
+ byName: false,
304
+ bestBySession: new Map()
305
+ };
306
+ byWorkspace.set(workspace.id, entry);
307
+ }
308
+ entry.hits++;
309
+ if (!entry.at || hit.at > entry.at)
310
+ entry.at = hit.at;
311
+ entry.bestBySession.set(hit.sessionId, Math.max(entry.bestBySession.get(hit.sessionId) ?? 0, hit.score));
312
+ // `hits` arrives in BM25 order, so the first few of a workspace are its best few:
313
+ // scoring and snippeting the same slice needs no second sort.
314
+ if (entry.snippets.length < SNIPPETS_PER_RESULT) {
315
+ entry.score += hit.score;
316
+ entry.snippets.push({ sessionId: hit.sessionId, role: hit.role, at: hit.at, text: hit.snippet });
317
+ }
318
+ }
319
+ const results = [];
320
+ for (const entry of byWorkspace.values()) {
321
+ const { bestBySession, ...rest } = entry;
322
+ let sessionId = null;
323
+ let best = -Infinity;
324
+ for (const [id, score] of bestBySession) {
325
+ if (score <= best)
326
+ continue;
327
+ best = score;
328
+ sessionId = id;
329
+ }
330
+ results.push({ ...rest, sessionId });
331
+ }
332
+ return results.sort((a, b) => b.score - a.score);
333
+ }