conductor-remote 1.92.1 → 1.93.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,337 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ const fileCache = new Map();
5
+ /** Remove a TOML comment without treating a `#` inside a quoted string as one. */
6
+ function withoutComment(line) {
7
+ let quote = null;
8
+ let escaped = false;
9
+ for (let i = 0; i < line.length; i++) {
10
+ const char = line[i];
11
+ if (quote === '"' && escaped) {
12
+ escaped = false;
13
+ continue;
14
+ }
15
+ if (quote === '"' && char === '\\') {
16
+ escaped = true;
17
+ continue;
18
+ }
19
+ if (quote) {
20
+ if (char === quote)
21
+ quote = null;
22
+ continue;
23
+ }
24
+ if (char === '"' || char === "'")
25
+ quote = char;
26
+ else if (char === '#')
27
+ return line.slice(0, i);
28
+ }
29
+ return line;
30
+ }
31
+ /** Strip comments from a value that can span several physical TOML lines. */
32
+ function withoutComments(value) {
33
+ return value
34
+ .split(/\r?\n/)
35
+ .map(line => withoutComment(line))
36
+ .join('\n');
37
+ }
38
+ function tomlString(raw) {
39
+ const value = withoutComment(raw).trim();
40
+ if (value.startsWith("'") && value.endsWith("'") && !value.startsWith("'''"))
41
+ return value.slice(1, -1);
42
+ if (!(value.startsWith('"') && value.endsWith('"')) || value.startsWith('"""'))
43
+ return null;
44
+ try {
45
+ return JSON.parse(value);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /** Split a TOML dotted key, retaining dots inside quoted components. */
52
+ function dottedKey(raw) {
53
+ const parts = [];
54
+ let start = 0;
55
+ let quote = null;
56
+ let escaped = false;
57
+ for (let i = 0; i < raw.length; i++) {
58
+ const char = raw[i];
59
+ if (quote === '"' && escaped) {
60
+ escaped = false;
61
+ continue;
62
+ }
63
+ if (quote === '"' && char === '\\') {
64
+ escaped = true;
65
+ continue;
66
+ }
67
+ if (quote) {
68
+ if (char === quote)
69
+ quote = null;
70
+ continue;
71
+ }
72
+ if (char === '"' || char === "'")
73
+ quote = char;
74
+ else if (char === '.') {
75
+ parts.push(raw.slice(start, i));
76
+ start = i + 1;
77
+ }
78
+ }
79
+ if (quote)
80
+ return null;
81
+ parts.push(raw.slice(start));
82
+ const decoded = parts.map(part => {
83
+ const value = part.trim();
84
+ if (!value)
85
+ return null;
86
+ if (value.startsWith('"') || value.startsWith("'"))
87
+ return tomlString(value);
88
+ return /^[A-Za-z0-9_-]+$/.test(value) ? value : null;
89
+ });
90
+ return decoded.every((part) => part !== null) ? decoded : null;
91
+ }
92
+ function tablePath(line) {
93
+ const clean = withoutComment(line).trim();
94
+ if (clean.startsWith('[['))
95
+ return null;
96
+ const match = clean.match(/^\[([^\]]+)]$/);
97
+ return match ? dottedKey(match[1]) : null;
98
+ }
99
+ function assignment(line) {
100
+ const clean = withoutComment(line);
101
+ let quote = null;
102
+ let escaped = false;
103
+ for (let i = 0; i < clean.length; i++) {
104
+ const char = clean[i];
105
+ if (quote === '"' && escaped) {
106
+ escaped = false;
107
+ continue;
108
+ }
109
+ if (quote === '"' && char === '\\') {
110
+ escaped = true;
111
+ continue;
112
+ }
113
+ if (quote) {
114
+ if (char === quote)
115
+ quote = null;
116
+ continue;
117
+ }
118
+ if (char === '"' || char === "'")
119
+ quote = char;
120
+ else if (char === '=') {
121
+ const key = dottedKey(clean.slice(0, i));
122
+ if (key?.length !== 1)
123
+ return null;
124
+ return { key: key[0], value: clean.slice(i + 1).trim() };
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+ function multilineDelimiter(value) {
130
+ const trimmed = value.trimStart();
131
+ for (const delimiter of ['"""', "'''"]) {
132
+ if (!trimmed.startsWith(delimiter))
133
+ continue;
134
+ return trimmed.indexOf(delimiter, delimiter.length) < 0 ? delimiter : null;
135
+ }
136
+ return null;
137
+ }
138
+ function stringValue(value) {
139
+ if (tomlString(value) !== null)
140
+ return true;
141
+ const trimmed = withoutComments(value).trim();
142
+ return ((trimmed.startsWith('"""') && trimmed.indexOf('"""', 3) >= 3) ||
143
+ (trimmed.startsWith("'''") && trimmed.indexOf("'''", 3) >= 3));
144
+ }
145
+ function arrayOpen(value) {
146
+ let depth = 0;
147
+ let quote = null;
148
+ let escaped = false;
149
+ for (const char of withoutComments(value)) {
150
+ if (quote === '"' && escaped) {
151
+ escaped = false;
152
+ continue;
153
+ }
154
+ if (quote === '"' && char === '\\') {
155
+ escaped = true;
156
+ continue;
157
+ }
158
+ if (quote) {
159
+ if (char === quote)
160
+ quote = null;
161
+ continue;
162
+ }
163
+ if (char === '"' || char === "'")
164
+ quote = char;
165
+ else if (char === '[')
166
+ depth++;
167
+ else if (char === ']')
168
+ depth--;
169
+ }
170
+ return depth > 0;
171
+ }
172
+ function stringList(raw) {
173
+ const scalar = tomlString(raw);
174
+ if (scalar !== null)
175
+ return [scalar];
176
+ const value = withoutComments(raw).trim();
177
+ if (!value.startsWith('[') || !value.endsWith(']'))
178
+ return null;
179
+ const values = [];
180
+ let start = 1;
181
+ let quote = null;
182
+ let escaped = false;
183
+ for (let i = 1; i < value.length - 1; i++) {
184
+ const char = value[i];
185
+ if (quote === '"' && escaped) {
186
+ escaped = false;
187
+ continue;
188
+ }
189
+ if (quote === '"' && char === '\\') {
190
+ escaped = true;
191
+ continue;
192
+ }
193
+ if (quote) {
194
+ if (char === quote)
195
+ quote = null;
196
+ continue;
197
+ }
198
+ if (char === '"' || char === "'")
199
+ quote = char;
200
+ else if (char === ',') {
201
+ const item = tomlString(value.slice(start, i));
202
+ if (item === null)
203
+ return null;
204
+ values.push(item);
205
+ start = i + 1;
206
+ }
207
+ }
208
+ const tail = value.slice(start, -1).trim();
209
+ if (tail) {
210
+ const item = tomlString(tail);
211
+ if (item === null)
212
+ return null;
213
+ values.push(item);
214
+ }
215
+ return values;
216
+ }
217
+ function displayName(id) {
218
+ return id
219
+ .replace(/[-\s]+/g, ' ')
220
+ .trim()
221
+ .replace(/(^| )([a-z])/g, (_whole, prefix, letter) => `${prefix}${letter.toUpperCase()}`);
222
+ }
223
+ function parseLayer(text) {
224
+ const configs = new Map();
225
+ let kind = null;
226
+ let section = [];
227
+ let multiline = null;
228
+ const lines = text.split(/\r?\n/);
229
+ for (let i = 0; i < lines.length; i++) {
230
+ const line = lines[i];
231
+ if (multiline) {
232
+ if (line.includes(multiline))
233
+ multiline = null;
234
+ continue;
235
+ }
236
+ const header = tablePath(line);
237
+ if (header) {
238
+ section = header;
239
+ if (section.length === 3 && section[0] === 'scripts' && section[1] === 'run') {
240
+ kind = 'named';
241
+ const id = section[2];
242
+ if (!configs.has(id))
243
+ configs.set(id, { id });
244
+ }
245
+ continue;
246
+ }
247
+ const found = assignment(line);
248
+ if (!found)
249
+ continue;
250
+ multiline = multilineDelimiter(found.value);
251
+ if (section.length === 1 && section[0] === 'scripts' && found.key === 'run') {
252
+ if (stringValue(found.value) || multiline)
253
+ kind = 'legacy';
254
+ continue;
255
+ }
256
+ if (section.length !== 3 || section[0] !== 'scripts' || section[1] !== 'run')
257
+ continue;
258
+ const config = configs.get(section[2]) ?? { id: section[2] };
259
+ configs.set(config.id, config);
260
+ if (found.key === 'command' && (stringValue(found.value) || multiline))
261
+ config.command = true;
262
+ else if (found.key === 'hide' && /^(?:true|false)$/.test(found.value))
263
+ config.hide = found.value === 'true';
264
+ else if (found.key === 'available_in') {
265
+ let value = found.value;
266
+ while (arrayOpen(value) && i + 1 < lines.length)
267
+ value += `\n${lines[++i]}`;
268
+ const available = stringList(value);
269
+ if (available)
270
+ config.availableIn = available;
271
+ }
272
+ }
273
+ return { kind, configs: [...configs.values()] };
274
+ }
275
+ function resolveLayers(layers) {
276
+ let kind = null;
277
+ const resolved = new Map();
278
+ for (const layer of layers) {
279
+ if (layer.kind === 'legacy') {
280
+ kind = 'legacy';
281
+ resolved.clear();
282
+ continue;
283
+ }
284
+ if (layer.kind !== 'named')
285
+ continue;
286
+ if (kind === 'legacy')
287
+ resolved.clear();
288
+ kind = 'named';
289
+ for (const patch of layer.configs) {
290
+ const previous = resolved.get(patch.id) ?? { id: patch.id };
291
+ resolved.set(patch.id, { ...previous, ...patch });
292
+ }
293
+ }
294
+ if (kind !== 'named')
295
+ return [];
296
+ return [...resolved.values()].flatMap(config => {
297
+ if (!config.command || config.hide || (config.availableIn && !config.availableIn.includes('local')))
298
+ return [];
299
+ return [{ id: config.id, name: displayName(config.id) }];
300
+ });
301
+ }
302
+ /** Resolve lower-to-higher-priority TOML layers using Conductor's per-ID merge. */
303
+ export function resolveRunConfigs(layers) {
304
+ return resolveLayers(layers.map(parseLayer));
305
+ }
306
+ function readLayer(file) {
307
+ try {
308
+ const stat = fs.statSync(file);
309
+ const cached = fileCache.get(file);
310
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size)
311
+ return cached.value;
312
+ const source = fs.readFileSync(file, 'utf8');
313
+ const value = parseLayer(source);
314
+ fileCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, value });
315
+ return value;
316
+ }
317
+ catch {
318
+ fileCache.delete(file);
319
+ return null;
320
+ }
321
+ }
322
+ /** Read the same user -> shared -> local -> managed settings layers Conductor resolves. */
323
+ export function runConfigsFor(workspace) {
324
+ const shared = workspace.worktree
325
+ ? path.join(workspace.worktree, '.conductor', 'settings.toml')
326
+ : workspace.repo_root
327
+ ? path.join(workspace.repo_root, '.conductor', 'settings.toml')
328
+ : null;
329
+ const files = [
330
+ path.join(os.homedir(), '.conductor', 'settings.toml'),
331
+ shared,
332
+ workspace.repo_root ? path.join(workspace.repo_root, '.conductor', 'settings.local.toml') : null,
333
+ workspace.worktree ? path.join(workspace.worktree, '.conductor', 'settings.local.toml') : null,
334
+ path.join(os.homedir(), '.conductor', 'settings.managed.toml')
335
+ ];
336
+ return resolveLayers(files.flatMap(file => (file ? [readLayer(file)].filter((layer) => layer !== null) : [])));
337
+ }
@@ -0,0 +1,269 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { parentPort, workerData } from 'node:worker_threads';
5
+ import { ConductorDb } from "./db.js";
6
+ import { HIT_CLOSE, HIT_OPEN, matchQuery } from "./search.js";
7
+ import { parseMessage } from "./transcript.js";
8
+ /** Bump when extraction or an invariant such as one-chunk-per-source changes stored rows. */
9
+ const SCHEMA_VERSION = 2;
10
+ /**
11
+ * Source rows advanced per tick. The cursor moves by *scanned* rowid rather than
12
+ * matched rowid, so a caught-up index re-scans nothing.
13
+ */
14
+ const WINDOW_ROWS = 4000;
15
+ const BACKFILL_PAUSE_MS = 5;
16
+ const IDLE_POLL_MS = 15_000;
17
+ const MAX_CHUNK_CHARS = 64_000;
18
+ const CHUNK_LIMIT = 300;
19
+ const SLOW_OPERATION_MS = 100;
20
+ const SLOW_LOG_INTERVAL_MS = 60_000;
21
+ const INDEXED_ROLES = new Set(['user', 'assistant', 'thinking']);
22
+ const port = parentPort;
23
+ if (!port)
24
+ throw new Error('search worker requires a parent port');
25
+ const post = (message) => port.postMessage(message);
26
+ const log = (level, message) => post({ type: 'log', level, message });
27
+ const errorText = (error) => (error instanceof Error ? error.message : String(error));
28
+ class SearchIndexWorker {
29
+ source;
30
+ file;
31
+ db = null;
32
+ cursor = 0;
33
+ chunks = 0;
34
+ caughtUp = false;
35
+ timer = null;
36
+ sourceMax = 0;
37
+ slowLogs = new Map();
38
+ constructor(sourceDbPath, file) {
39
+ this.source = new ConductorDb(sourceDbPath, { onSlowQuery: message => log('warn', message) });
40
+ this.file = file;
41
+ }
42
+ open() {
43
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
44
+ const db = new DatabaseSync(this.file);
45
+ // Set the wait before any pragma or schema operation that may need the writer.
46
+ // A second dev relay can own it for a batch; this wait is why the connection must
47
+ // live off the HTTP thread.
48
+ db.exec('PRAGMA busy_timeout = 5000');
49
+ db.exec('PRAGMA journal_mode = WAL');
50
+ db.exec('PRAGMA synchronous = NORMAL');
51
+ db.exec('CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)');
52
+ const version = Number(this.readMeta(db, 'version') ?? 0);
53
+ if (version !== SCHEMA_VERSION) {
54
+ db.exec('DROP TABLE IF EXISTS chunks');
55
+ db.exec(`
56
+ CREATE VIRTUAL TABLE chunks USING fts5(
57
+ body,
58
+ session_id UNINDEXED,
59
+ src_rowid UNINDEXED,
60
+ role UNINDEXED,
61
+ at UNINDEXED,
62
+ tokenize='porter unicode61'
63
+ )
64
+ `);
65
+ db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('version', String(SCHEMA_VERSION));
66
+ db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('cursor', '0');
67
+ if (version)
68
+ log('log', `search index schema ${version} → ${SCHEMA_VERSION}, rebuilding`);
69
+ }
70
+ this.db = db;
71
+ this.cursor = Number(this.readMeta(db, 'cursor') ?? 0);
72
+ this.chunks = Number(db.prepare('SELECT COUNT(*) c FROM chunks').get().c);
73
+ }
74
+ start() {
75
+ this.schedule(0);
76
+ }
77
+ stop() {
78
+ if (this.timer)
79
+ clearTimeout(this.timer);
80
+ this.timer = null;
81
+ }
82
+ status() {
83
+ if (!this.db)
84
+ return { chunks: 0, ready: false, progress: 0 };
85
+ if (this.caughtUp)
86
+ return { chunks: this.chunks, ready: true, progress: 1 };
87
+ if (!this.sourceMax) {
88
+ const max = this.source.query('SELECT MAX(rowid) m FROM session_messages')[0]?.m;
89
+ this.sourceMax = max ?? 0;
90
+ }
91
+ const progress = this.sourceMax ? Math.min(1, this.cursor / this.sourceMax) : 0;
92
+ return { chunks: this.chunks, ready: false, progress };
93
+ }
94
+ search(raw, { limit = CHUNK_LIMIT, sessionIds } = {}) {
95
+ const started = performance.now();
96
+ try {
97
+ const db = this.db;
98
+ if (!db)
99
+ return [];
100
+ const match = matchQuery(raw);
101
+ if (!match || (sessionIds && !sessionIds.length))
102
+ return [];
103
+ const scope = sessionIds ? 'AND session_id IN (SELECT value FROM json_each(?))' : '';
104
+ const params = sessionIds
105
+ ? [HIT_OPEN, HIT_CLOSE, match, JSON.stringify(sessionIds), limit]
106
+ : [HIT_OPEN, HIT_CLOSE, match, limit];
107
+ let rows;
108
+ try {
109
+ rows = db
110
+ .prepare(`SELECT session_id, src_rowid, role, at, -bm25(chunks) AS score,
111
+ snippet(chunks, 0, ?, ?, '…', 24) AS snippet
112
+ FROM chunks WHERE chunks MATCH ? ${scope} ORDER BY bm25(chunks) LIMIT ?`)
113
+ .all(...params);
114
+ }
115
+ catch (error) {
116
+ throw new Error(`search failed for ${JSON.stringify(match)}: ${errorText(error)}`);
117
+ }
118
+ return rows.map(row => ({
119
+ sessionId: row.session_id,
120
+ srcRowid: Number(row.src_rowid),
121
+ role: INDEXED_ROLES.has(row.role) ? row.role : 'assistant',
122
+ at: row.at,
123
+ score: Number(row.score),
124
+ snippet: row.snippet
125
+ }));
126
+ }
127
+ finally {
128
+ this.reportSlow('query', performance.now() - started);
129
+ }
130
+ }
131
+ readMeta(db, key) {
132
+ const row = db.prepare('SELECT v FROM meta WHERE k = ?').get(key);
133
+ return row?.v ?? null;
134
+ }
135
+ schedule(ms) {
136
+ if (this.timer)
137
+ clearTimeout(this.timer);
138
+ this.timer = setTimeout(() => {
139
+ const started = performance.now();
140
+ let more = false;
141
+ try {
142
+ more = this.tick();
143
+ }
144
+ catch (error) {
145
+ log('warn', `⚠ search index tick failed: ${errorText(error)}`);
146
+ }
147
+ finally {
148
+ this.reportSlow('backfill batch', performance.now() - started);
149
+ }
150
+ try {
151
+ post({ type: 'status', status: this.status() });
152
+ }
153
+ catch (error) {
154
+ log('warn', `⚠ search index status failed: ${errorText(error)}`);
155
+ }
156
+ this.schedule(more ? BACKFILL_PAUSE_MS : IDLE_POLL_MS);
157
+ }, ms);
158
+ this.timer.unref?.();
159
+ }
160
+ /**
161
+ * Index one source-row window. Filtering happens after the rowid window is chosen,
162
+ * so the cursor advances across tool-only rows instead of scanning them forever.
163
+ */
164
+ tick() {
165
+ const db = this.db;
166
+ if (!db)
167
+ return false;
168
+ const startingCursor = this.cursor;
169
+ const window = this.source.query('SELECT rowid FROM session_messages WHERE rowid > ? ORDER BY rowid LIMIT ?', [startingCursor, WINDOW_ROWS]);
170
+ if (!window.length) {
171
+ this.caughtUp = true;
172
+ return false;
173
+ }
174
+ this.caughtUp = false;
175
+ const end = window[window.length - 1].rowid;
176
+ const rows = this.source.query(`SELECT rowid, id, session_id, role, content, full_message, created_at, sent_at, queue_order
177
+ FROM session_messages
178
+ WHERE rowid > ? AND rowid <= ? AND session_id IS NOT NULL
179
+ AND (role = 'user' OR content LIKE '%"type":"text"%' OR content LIKE '%"type":"thinking"%')
180
+ ORDER BY rowid`, [startingCursor, end]);
181
+ // Parse outside the sidecar write transaction. A second relay may be waiting for
182
+ // that writer, and parsing transcript JSON does not need to hold it.
183
+ const chunks = [];
184
+ for (const row of rows) {
185
+ for (const entry of parseMessage(row, null)) {
186
+ if (!INDEXED_ROLES.has(entry.role))
187
+ continue;
188
+ const body = entry.text.trim();
189
+ if (!body)
190
+ continue;
191
+ chunks.push({
192
+ body: body.slice(0, MAX_CHUNK_CHARS),
193
+ sessionId: row.session_id,
194
+ srcRowid: row.rowid,
195
+ role: entry.role,
196
+ at: entry.ts
197
+ });
198
+ }
199
+ }
200
+ // BEGIN IMMEDIATE serialises the cursor check with the batch write. Without the
201
+ // check, a dev relay and the service can both read cursor N, wait for each other,
202
+ // and then insert the same N→M chunks twice.
203
+ db.exec('BEGIN IMMEDIATE');
204
+ try {
205
+ const durableCursor = Number(this.readMeta(db, 'cursor') ?? 0);
206
+ if (durableCursor !== startingCursor) {
207
+ db.exec('ROLLBACK');
208
+ this.cursor = durableCursor;
209
+ this.chunks = Number(db.prepare('SELECT COUNT(*) c FROM chunks').get().c);
210
+ return true;
211
+ }
212
+ const insert = db.prepare('INSERT INTO chunks(body, session_id, src_rowid, role, at) VALUES (?, ?, ?, ?, ?)');
213
+ for (const chunk of chunks) {
214
+ insert.run(chunk.body, chunk.sessionId, chunk.srcRowid, chunk.role, chunk.at);
215
+ }
216
+ db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('cursor', String(end));
217
+ db.exec('COMMIT');
218
+ }
219
+ catch (error) {
220
+ try {
221
+ db.exec('ROLLBACK');
222
+ }
223
+ catch {
224
+ // Preserve the original SQLite error.
225
+ }
226
+ throw error;
227
+ }
228
+ this.cursor = end;
229
+ this.chunks += chunks.length;
230
+ return true;
231
+ }
232
+ reportSlow(operation, elapsedMs) {
233
+ if (elapsedMs < SLOW_OPERATION_MS)
234
+ return;
235
+ const now = Date.now();
236
+ const previous = this.slowLogs.get(operation);
237
+ if (previous && now - previous.at < SLOW_LOG_INTERVAL_MS) {
238
+ previous.suppressed++;
239
+ return;
240
+ }
241
+ const suppressed = previous?.suppressed ? `; ${previous.suppressed} similar calls suppressed` : '';
242
+ log('warn', `⚠ slow search index ${operation} (${Math.round(elapsedMs)}ms${suppressed})`);
243
+ this.slowLogs.set(operation, { at: now, suppressed: 0 });
244
+ }
245
+ }
246
+ const config = workerData;
247
+ let index = null;
248
+ try {
249
+ index = new SearchIndexWorker(config.sourceDbPath, config.file);
250
+ index.open();
251
+ post({ type: 'status', status: index.status() });
252
+ index.start();
253
+ }
254
+ catch (error) {
255
+ const message = errorText(error);
256
+ log('warn', `⚠ search index unavailable (${message}) — /api/search will report it`);
257
+ post({ type: 'status', status: { chunks: 0, ready: false, progress: 0, error: message } });
258
+ }
259
+ port.on('message', (request) => {
260
+ if (request.type !== 'search')
261
+ return;
262
+ try {
263
+ post({ type: 'result', id: request.id, hits: index?.search(request.raw, request.options) ?? [] });
264
+ }
265
+ catch (error) {
266
+ post({ type: 'error', id: request.id, error: errorText(error) });
267
+ }
268
+ });
269
+ process.once('exit', () => index?.stop());