conductor-remote 1.92.1 → 1.92.2

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.
@@ -1,9 +1,5 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { DatabaseSync } from 'node:sqlite';
1
+ import { Worker } from 'node:worker_threads';
4
2
  import { chatCursor } from "./chat-cursor.js";
5
- import { HIT_CLOSE, HIT_OPEN } from "./shared.js";
6
- import { parseMessage } from "./transcript.js";
7
3
  /**
8
4
  * Full-text search over the chat history, in a sidecar DB the relay owns.
9
5
  *
@@ -22,7 +18,7 @@ import { parseMessage } from "./transcript.js";
22
18
  * together. Skipping it was the original cut and it was wrong: the chat view
23
19
  * *renders* thinking, so a hit there opens to something you can read, and the
24
20
  * reasoning is where a decision gets explained before the reply summarises it.
25
- * Note the shape trap behind the source query below: **every thinking block sits
21
+ * Note the shape trap behind the worker's source query: **every thinking block sits
26
22
  * in a row with no text block beside it** (0 of 102,773 rows carry both), so a
27
23
  * prefilter written for `"type":"text"` excludes 100% of thinking rather than
28
24
  * some of it — the bug that made this cut invisible.
@@ -43,20 +39,6 @@ import { parseMessage } from "./transcript.js";
43
39
  * stays that way; this opens its own file under the relay's state dir, and it is
44
40
  * disposable — delete it and the next start rebuilds it.
45
41
  */
46
- /** Bump to force a rebuild: a tokenizer or extraction change makes every stored chunk wrong. */
47
- const SCHEMA_VERSION = 2;
48
- /**
49
- * Source rows advanced per tick. The cursor moves by *scanned* rowid rather than
50
- * matched rowid, so a caught-up index re-scans nothing — get that wrong and every
51
- * idle poll re-reads the 3 GB tail looking for rows it already rejected.
52
- */
53
- const WINDOW_ROWS = 4000;
54
- /** Yield to the event loop between batches: the backfill is ~19ms of blocking work per window. */
55
- const BACKFILL_PAUSE_MS = 5;
56
- /** Once caught up, look for new messages at about the rate a chat produces them. */
57
- const IDLE_POLL_MS = 15_000;
58
- /** A pathological single message can't be allowed to dominate the index. */
59
- const MAX_CHUNK_CHARS = 64_000;
60
42
  /** How many chunks a query ranks before they are folded into workspaces. */
61
43
  const CHUNK_LIMIT = 300;
62
44
  /**
@@ -67,8 +49,6 @@ const CHUNK_LIMIT = 300;
67
49
  * must not render them literally.
68
50
  */
69
51
  export { HIT_CLOSE, HIT_OPEN } from "./shared.js";
70
- /** The `TranscriptEntry` roles this index keeps, and the set `search()` maps a stored role back through. */
71
- const INDEXED_ROLES = new Set(['user', 'assistant', 'thinking']);
72
52
  /**
73
53
  * Turn a phone query into an FTS5 MATCH expression.
74
54
  *
@@ -135,213 +115,126 @@ export function matchQuery(raw) {
135
115
  }
136
116
  /** The tokens `matchQuery` will search for — what a caller matches names against. */
137
117
  export { queryTokens } from "./shared.js";
118
+ /**
119
+ * Main-thread facade for the disposable full-text index.
120
+ *
121
+ * `node:sqlite` is synchronous. Keeping its connection here meant a contended
122
+ * sidecar write, a backfill batch, or an expensive FTS rank stopped every HTTP
123
+ * route even though Conductor's AppleScript process itself is asynchronous. The
124
+ * worker owns both SQLite handles now; only small structured-clone messages cross
125
+ * back to the server thread.
126
+ */
138
127
  export class SearchIndex {
139
- source;
128
+ sourceDbPath;
140
129
  file;
141
- db = null;
142
- openError = null;
143
- cursor = 0;
144
- caughtUp = false;
145
- timer = null;
146
- sourceMax = 0;
147
- constructor(source, file) {
148
- this.source = source;
130
+ worker = null;
131
+ indexStatus = { chunks: 0, ready: false, progress: 0 };
132
+ nextId = 1;
133
+ pending = new Map();
134
+ stopping = false;
135
+ constructor(sourceDbPath, file) {
136
+ this.sourceDbPath = sourceDbPath;
149
137
  this.file = file;
150
138
  }
151
- /** Open (or rebuild) the sidecar and start indexing in the background. */
139
+ /** Spawn the index worker. Search remains a convenience: startup failure is non-fatal. */
152
140
  start() {
141
+ if (this.worker)
142
+ return;
143
+ this.stopping = false;
144
+ this.indexStatus = { chunks: 0, ready: false, progress: 0 };
145
+ const module = import.meta.url.endsWith('.ts') ? './search-worker.ts' : './search-worker.js';
153
146
  try {
154
- this.open();
147
+ const worker = new Worker(new URL(module, import.meta.url), {
148
+ workerData: { sourceDbPath: this.sourceDbPath, file: this.file },
149
+ // The CLI suppresses node:sqlite's still-experimental warning in the main
150
+ // isolate; warning state does not cross into a worker.
151
+ execArgv: [...process.execArgv, '--disable-warning=ExperimentalWarning']
152
+ });
153
+ this.worker = worker;
154
+ worker.on('message', message => this.onMessage(message));
155
+ worker.on('error', error => {
156
+ if (this.worker === worker)
157
+ this.workerFailed(error.message);
158
+ });
159
+ worker.on('exit', code => {
160
+ if (this.worker !== worker)
161
+ return;
162
+ this.worker = null;
163
+ if (!this.stopping && !this.indexStatus.error)
164
+ this.workerFailed(`worker exited with code ${code}`);
165
+ });
155
166
  }
156
167
  catch (err) {
157
- // A search index is a convenience; failing to open one must never stop the relay
158
- // serving state, transcripts or sends. Report it on /api/search instead.
159
- this.openError = err instanceof Error ? err.message : String(err);
160
- console.warn(`⚠ search index unavailable (${this.openError}) — /api/search will report it`);
161
- return;
168
+ this.workerFailed(err instanceof Error ? err.message : String(err));
162
169
  }
163
- this.schedule(0);
164
- }
165
- stop() {
166
- if (this.timer)
167
- clearTimeout(this.timer);
168
- this.timer = null;
169
170
  }
170
- open() {
171
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
172
- const db = new DatabaseSync(this.file);
173
- db.exec('PRAGMA journal_mode = WAL');
174
- db.exec('PRAGMA synchronous = NORMAL');
175
- // A dev relay on another port shares this file with the LaunchAgent's. WAL lets them
176
- // both read; the writer that loses waits rather than throwing away its batch, and a
177
- // tick that still fails is retried by the scheduler with nothing lost (the cursor
178
- // only advances on commit).
179
- db.exec('PRAGMA busy_timeout = 5000');
180
- db.exec('CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)');
181
- const version = Number(this.readMeta(db, 'version') ?? 0);
182
- if (version !== SCHEMA_VERSION) {
183
- db.exec('DROP TABLE IF EXISTS chunks');
184
- db.exec(`
185
- CREATE VIRTUAL TABLE chunks USING fts5(
186
- body,
187
- session_id UNINDEXED,
188
- src_rowid UNINDEXED,
189
- role UNINDEXED,
190
- at UNINDEXED,
191
- tokenize='porter unicode61'
192
- )
193
- `);
194
- db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('version', String(SCHEMA_VERSION));
195
- db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('cursor', '0');
196
- if (version)
197
- console.log(`search index schema ${version} → ${SCHEMA_VERSION}, rebuilding`);
198
- }
199
- this.db = db;
200
- this.cursor = Number(this.readMeta(db, 'cursor') ?? 0);
171
+ async stop() {
172
+ this.stopping = true;
173
+ const worker = this.worker;
174
+ this.worker = null;
175
+ this.rejectPending('search index stopped');
176
+ if (worker)
177
+ await worker.terminate();
201
178
  }
202
- readMeta(db, key) {
203
- const row = db.prepare('SELECT v FROM meta WHERE k = ?').get(key);
204
- return row?.v ?? null;
179
+ status() {
180
+ return { ...this.indexStatus };
205
181
  }
206
- schedule(ms) {
207
- if (this.timer)
208
- clearTimeout(this.timer);
209
- this.timer = setTimeout(() => {
210
- let more = false;
182
+ /**
183
+ * Top matching chunks, best first. The SQLite work happens entirely in the
184
+ * worker, so awaiting a slow rank does not stop unrelated API requests.
185
+ */
186
+ search(raw, options = {}) {
187
+ if (!matchQuery(raw) || (options.sessionIds && !options.sessionIds.length))
188
+ return Promise.resolve([]);
189
+ const worker = this.worker;
190
+ if (!worker || this.indexStatus.error)
191
+ return Promise.resolve([]);
192
+ const id = this.nextId++;
193
+ return new Promise((resolve, reject) => {
194
+ this.pending.set(id, { resolve, reject });
211
195
  try {
212
- more = this.tick();
196
+ worker.postMessage({
197
+ id,
198
+ type: 'search',
199
+ raw,
200
+ options: { limit: CHUNK_LIMIT, ...options }
201
+ });
213
202
  }
214
- catch (err) {
215
- console.warn(`⚠ search index tick failed: ${err instanceof Error ? err.message : err}`);
203
+ catch (error) {
204
+ this.pending.delete(id);
205
+ reject(error instanceof Error ? error : new Error(String(error)));
216
206
  }
217
- this.schedule(more ? BACKFILL_PAUSE_MS : IDLE_POLL_MS);
218
- }, ms);
219
- this.timer.unref?.();
207
+ });
220
208
  }
221
- /**
222
- * Index one window of source rows. Returns true while there is more to do.
223
- *
224
- * The window is picked by rowid *before* the prose filter runs, so the cursor
225
- * advances past rows that hold nothing worth indexing. Filtering first and
226
- * advancing to the last match instead would leave a caught-up index re-scanning
227
- * every tool_result between the last prose row and the end of the table, every
228
- * poll, forever.
229
- */
230
- tick() {
231
- const db = this.db;
232
- if (!db)
233
- return false;
234
- const window = this.source.query('SELECT rowid FROM session_messages WHERE rowid > ? ORDER BY rowid LIMIT ?', [this.cursor, WINDOW_ROWS]);
235
- if (!window.length) {
236
- this.caughtUp = true;
237
- return false;
238
- }
239
- const end = window[window.length - 1].rowid;
240
- // Only rows that can hold prose: a plain-text prompt, or a frame carrying a text or
241
- // thinking block. Everything else is tool plumbing and the bulk of the bytes.
242
- // The thinking clause is not redundant with the text one — the two block types never
243
- // share a row (see this file's header), so dropping it drops thinking entirely.
244
- const rows = this.source.query(`SELECT rowid, id, session_id, role, content, full_message, created_at, sent_at, queue_order
245
- FROM session_messages
246
- WHERE rowid > ? AND rowid <= ? AND session_id IS NOT NULL
247
- AND (role = 'user' OR content LIKE '%"type":"text"%' OR content LIKE '%"type":"thinking"%')
248
- ORDER BY rowid`, [this.cursor, end]);
249
- const insert = db.prepare('INSERT INTO chunks(body, session_id, src_rowid, role, at) VALUES (?, ?, ?, ?, ?)');
250
- db.exec('BEGIN');
251
- try {
252
- for (const row of rows) {
253
- // Reuse the transcript parser rather than a second JSON walk: it already knows
254
- // that text inside a `type:"user"` frame is injected context and not the user's
255
- // words, and indexing something the chat view would never show is how a search
256
- // result becomes impossible to find once you open it.
257
- for (const entry of parseMessage(row, null)) {
258
- if (!INDEXED_ROLES.has(entry.role))
259
- continue;
260
- const body = entry.text.trim();
261
- if (!body)
262
- continue;
263
- insert.run(body.slice(0, MAX_CHUNK_CHARS), row.session_id, row.rowid, entry.role, entry.ts);
264
- }
265
- }
266
- db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('cursor', String(end));
267
- db.exec('COMMIT');
209
+ onMessage(message) {
210
+ if (message.type === 'status') {
211
+ this.indexStatus = message.status;
212
+ return;
268
213
  }
269
- catch (err) {
270
- db.exec('ROLLBACK');
271
- throw err;
214
+ if (message.type === 'log') {
215
+ console[message.level](message.message);
216
+ return;
272
217
  }
273
- this.cursor = end;
274
- return true;
218
+ const pending = this.pending.get(message.id);
219
+ if (!pending)
220
+ return;
221
+ this.pending.delete(message.id);
222
+ if (message.type === 'result')
223
+ pending.resolve(message.hits);
224
+ else
225
+ pending.reject(new Error(message.error));
275
226
  }
276
- status() {
277
- if (this.openError)
278
- return { chunks: 0, ready: false, progress: 0, error: this.openError };
279
- const db = this.db;
280
- if (!db)
281
- return { chunks: 0, ready: false, progress: 0 };
282
- const chunks = Number(db.prepare('SELECT COUNT(*) c FROM chunks').get().c);
283
- if (this.caughtUp)
284
- return { chunks, ready: true, progress: 1 };
285
- // Only re-read the source's high-water mark while backfilling; it costs a query
286
- // and the answer only matters for the progress bar.
287
- if (!this.sourceMax) {
288
- const max = this.source.query('SELECT MAX(rowid) m FROM session_messages')[0]?.m;
289
- this.sourceMax = max ?? 0;
290
- }
291
- const progress = this.sourceMax ? Math.min(1, this.cursor / this.sourceMax) : 0;
292
- return { chunks, ready: false, progress };
227
+ workerFailed(error) {
228
+ if (this.indexStatus.error === error)
229
+ return;
230
+ this.indexStatus = { chunks: 0, ready: false, progress: 0, error };
231
+ this.rejectPending(error);
232
+ console.warn(`⚠ search index unavailable (${error}) /api/search will report it`);
293
233
  }
294
- /**
295
- * Top matching chunks, best first. Empty when the query has no searchable tokens.
296
- *
297
- * `sessionIds` scopes the ranking, not just the result: it is the repo filter,
298
- * resolved to chat ids by the caller because this index knows nothing about
299
- * workspaces. It has to sit inside the query rather than be applied to the
300
- * chunks it returns, because the `limit` is spent before any post-filter runs
301
- * — a common word fills all 300 slots from the busiest repo and a smaller one
302
- * folds up to nothing. Measured on this Mac's 205k chunks with the largest repo's
303
- * 1,005 chats as the list: +0.3ms on a rare word, +60ms on the worst common-word
304
- * query (165ms → 220ms), still under the phone's 250ms debounce. The list rides
305
- * in as one JSON parameter through `json_each`, so its length never meets
306
- * SQLite's bound-variable limit. An empty list matches nothing, which is the
307
- * right answer for a repo with no chats and never "everything".
308
- */
309
- search(raw, { limit = CHUNK_LIMIT, sessionIds } = {}) {
310
- const db = this.db;
311
- if (!db)
312
- return [];
313
- const match = matchQuery(raw);
314
- if (!match)
315
- return [];
316
- if (sessionIds && !sessionIds.length)
317
- return [];
318
- const scope = sessionIds ? 'AND session_id IN (SELECT value FROM json_each(?))' : '';
319
- const params = sessionIds
320
- ? [HIT_OPEN, HIT_CLOSE, match, JSON.stringify(sessionIds), limit]
321
- : [HIT_OPEN, HIT_CLOSE, match, limit];
322
- let rows;
323
- try {
324
- rows = db
325
- .prepare(`SELECT session_id, src_rowid, role, at, -bm25(chunks) AS score,
326
- snippet(chunks, 0, ?, ?, '…', 24) AS snippet
327
- FROM chunks WHERE chunks MATCH ? ${scope} ORDER BY bm25(chunks) LIMIT ?`)
328
- .all(...params);
329
- }
330
- catch (err) {
331
- // A MATCH that still fails to parse is a bug in matchQuery, not user error —
332
- // report it rather than showing an empty result that looks like "no matches".
333
- throw new Error(`search failed for ${JSON.stringify(match)}: ${err instanceof Error ? err.message : err}`);
334
- }
335
- return rows.map(r => ({
336
- sessionId: r.session_id,
337
- srcRowid: Number(r.src_rowid),
338
- // An index written before SCHEMA_VERSION 2 is dropped on open, so an unknown role
339
- // here is a bug rather than an old row — fall back to the neutral one.
340
- role: INDEXED_ROLES.has(r.role) ? r.role : 'assistant',
341
- at: r.at,
342
- score: Number(r.score),
343
- snippet: r.snippet
344
- }));
234
+ rejectPending(message) {
235
+ for (const pending of this.pending.values())
236
+ pending.reject(new Error(message));
237
+ this.pending.clear();
345
238
  }
346
239
  }
347
240
  const SNIPPETS_PER_RESULT = 3;
@@ -53,7 +53,7 @@ const planUsage = new PlanUsageService();
53
53
  // Full-text index over the chat prose, in the relay's own sidecar DB — never in
54
54
  // Conductor's (see src/search.ts). It backfills in the background and is disposable:
55
55
  // deleting the file rebuilds it on the next start.
56
- const search = new SearchIndex(db, path.join(stateDir(), 'search.db'));
56
+ const search = new SearchIndex(cfg.dbPath, path.join(stateDir(), 'search.db'));
57
57
  search.start();
58
58
  /**
59
59
  * The MCP tools, bound to this relay over loopback.
@@ -879,7 +879,7 @@ const server = http.createServer(async (req, res) => {
879
879
  const scope = scoped
880
880
  ? { sessionIds: reads.searchSessionIds(repos.length ? repos : undefined, includeArchived) }
881
881
  : {};
882
- const hits = search.search(q, scope);
882
+ const hits = await search.search(q, scope);
883
883
  const targets = reads.searchTargets([...new Set(hits.map(h => h.sessionId))]);
884
884
  const fromChats = foldHits(hits, sid => {
885
885
  const workspace = targets.get(sid)?.workspace ?? null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.92.1",
3
+ "version": "1.92.2",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",