coding-tool-x 3.7.13 → 3.8.1

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,930 @@
1
+ 'use strict';
2
+
3
+ const { openDatabase, closeDatabase } = require('./sqlite-connection');
4
+ const { PATHS } = require('../../config/paths');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Constants
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** @type {number} Maximum time a source inventory is considered fresh */
13
+ const INDEX_INVENTORY_TTL_MS = 5000;
14
+
15
+ /** @type {number} Max wait for stale-ok on a cold source */
16
+ const COLD_STALE_WAIT_MS = 2500;
17
+
18
+ /** @type {number} Hard timeout for worker processes */
19
+ const WORKER_TIMEOUT_MS = 180000;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Schema
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const SCHEMA_VERSION = 1;
26
+
27
+ const SCHEMA_SQL = `
28
+ CREATE TABLE IF NOT EXISTS source_state (
29
+ source TEXT PRIMARY KEY,
30
+ last_inventory_ms INTEGER,
31
+ last_error TEXT
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS session_file (
35
+ source TEXT NOT NULL,
36
+ file_path TEXT NOT NULL,
37
+ size INTEGER NOT NULL DEFAULT 0,
38
+ mtime_ms INTEGER NOT NULL DEFAULT 0,
39
+ session_id TEXT NOT NULL,
40
+ project_name TEXT NOT NULL,
41
+ project_display_name TEXT,
42
+ project_full_path TEXT,
43
+ first_message TEXT,
44
+ git_branch TEXT,
45
+ provider TEXT,
46
+ model TEXT,
47
+ started_at INTEGER,
48
+ updated_at INTEGER,
49
+ message_count INTEGER NOT NULL DEFAULT 0,
50
+ usage_json TEXT,
51
+ extra_json TEXT,
52
+ PRIMARY KEY (source, file_path),
53
+ UNIQUE (source, session_id)
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_session_file_project
57
+ ON session_file(source, project_name, updated_at DESC);
58
+
59
+ CREATE INDEX IF NOT EXISTS idx_session_file_sid
60
+ ON session_file(source, session_id);
61
+
62
+ CREATE TABLE IF NOT EXISTS session_message (
63
+ source TEXT NOT NULL,
64
+ session_id TEXT NOT NULL,
65
+ ordinal INTEGER NOT NULL,
66
+ message_id TEXT,
67
+ role TEXT,
68
+ type TEXT,
69
+ subtype TEXT,
70
+ content TEXT,
71
+ timestamp INTEGER,
72
+ model TEXT,
73
+ provider TEXT,
74
+ user_message_number INTEGER,
75
+ extra_json TEXT,
76
+ PRIMARY KEY (source, session_id, ordinal),
77
+ FOREIGN KEY (source, session_id) REFERENCES session_file(source, session_id)
78
+ ON DELETE CASCADE
79
+ );
80
+
81
+ CREATE INDEX IF NOT EXISTS idx_session_message_sid_ord
82
+ ON session_message(source, session_id, ordinal);
83
+ `;
84
+
85
+ const FTS_SETUP_SQL = `
86
+ CREATE VIRTUAL TABLE IF NOT EXISTS session_message_fts USING fts5(
87
+ content,
88
+ content='session_message',
89
+ content_rowid='rowid',
90
+ tokenize='trigram'
91
+ );
92
+
93
+ CREATE TRIGGER IF NOT EXISTS session_message_fts_insert AFTER INSERT ON session_message BEGIN
94
+ INSERT INTO session_message_fts(rowid, content) VALUES (new.rowid, new.content);
95
+ END;
96
+
97
+ CREATE TRIGGER IF NOT EXISTS session_message_fts_delete AFTER DELETE ON session_message BEGIN
98
+ INSERT INTO session_message_fts(session_message_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
99
+ END;
100
+
101
+ CREATE TRIGGER IF NOT EXISTS session_message_fts_update AFTER UPDATE ON session_message BEGIN
102
+ INSERT INTO session_message_fts(session_message_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
103
+ INSERT INTO session_message_fts(rowid, content) VALUES (new.rowid, new.content);
104
+ END;
105
+ `;
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Helpers
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * @returns {boolean}
113
+ */
114
+ function _detectFts5(db) {
115
+ try {
116
+ const rows = db.prepare('SELECT 1 FROM pragma_compile_options WHERE compile_options = ?').all('ENABLE_FTS5');
117
+ return rows.length > 0;
118
+ } catch (_err) {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Normalize page/limit/order defaults matching current route behavior.
125
+ * @param {{ page?: number, limit?: number, order?: string }} options
126
+ * @returns {{ page: number, limit: number, order: 'ASC'|'DESC' }}
127
+ */
128
+ function _normalizePageOpts(options = {}) {
129
+ let page = Number(options.page) || 1;
130
+ let limit = Number(options.limit) || 20;
131
+ const order = options.order === 'asc' ? 'ASC' : 'DESC';
132
+ if (page < 1) page = 1;
133
+ if (limit < 1) limit = 1;
134
+ if (limit > 200) limit = 200;
135
+ return { page, limit, order };
136
+ }
137
+
138
+ /**
139
+ * Double embedded quotes in a string for FTS5 MATCH.
140
+ * @param {string} s
141
+ * @returns {string}
142
+ */
143
+ function _ftsQuote(s) {
144
+ return '"' + String(s).replace(/"/g, '""') + '"';
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Core: createSessionHistoryIndex
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /**
152
+ * @param {object} [opts]
153
+ * @param {string} [opts.dbPath]
154
+ * @param {object} [opts.adapterRegistry] - source → { inventory, parse }
155
+ * @param {Function} [opts.workerRunner] - (source, indexDbPath) => Promise<void>
156
+ * @param {boolean|null} [opts.ftsEnabledOverride] - override FTS detection for tests
157
+ * @returns {object} index API
158
+ */
159
+ function createSessionHistoryIndex(opts = {}) {
160
+ const dbPath = opts.dbPath || PATHS?.sessionHistoryIndex || path.join(PATHS?.base || process.cwd(), 'session-history.sqlite');
161
+ const adapters = opts.adapterRegistry || require('./session-history-adapters');
162
+ const workerRunner = opts.workerRunner || _defaultWorkerRunner;
163
+ const ftsEnabled = opts.ftsEnabledOverride !== undefined
164
+ ? opts.ftsEnabledOverride
165
+ : null;
166
+ const shouldUseWorker = process.env.NODE_ENV !== 'test' && process.env.CC_TOOL_SESSION_HISTORY_CHILD !== '1';
167
+ let _db = null;
168
+ let _ftsAvailable = null;
169
+ /** @type {Map<string, Promise<void>>} */
170
+ const _inflight = new Map();
171
+
172
+ function _getDb() {
173
+ if (_db) return _db;
174
+ _db = openDatabase(dbPath);
175
+ _initSchema(_db);
176
+ if (ftsEnabled !== null) {
177
+ _ftsAvailable = ftsEnabled;
178
+ } else if (_ftsAvailable === null) {
179
+ _ftsAvailable = _detectFts5(_db);
180
+ }
181
+ if (_ftsAvailable && ftsEnabled !== false) {
182
+ _initFts(_db);
183
+ }
184
+ return _db;
185
+ }
186
+
187
+ function _initSchema(db) {
188
+ db.exec('PRAGMA journal_mode = WAL');
189
+ db.exec(SCHEMA_SQL);
190
+ }
191
+
192
+ function _initFts(db) {
193
+ try {
194
+ db.exec(FTS_SETUP_SQL);
195
+ } catch (_err) {
196
+ _ftsAvailable = false;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * @param {string} source
202
+ * @param {{ force?: boolean, consistency?: string }} [options]
203
+ * @returns {Promise<void>}
204
+ */
205
+ async function ensureSourceIndexed(source, options = {}) {
206
+ const consistency = options.consistency || 'stale-ok';
207
+ const force = options.force === true;
208
+
209
+ const key = `ensure:${source}`;
210
+ if (_inflight.has(key)) {
211
+ if (consistency === 'complete') {
212
+ return _inflight.get(key);
213
+ }
214
+ const inflight = _inflight.get(key);
215
+ const timeout = new Promise((resolve) => setTimeout(() => resolve('timeout'), COLD_STALE_WAIT_MS));
216
+ const result = await Promise.race([inflight, timeout]);
217
+ if (result === 'timeout') return;
218
+ return result;
219
+ }
220
+
221
+ const promise = shouldUseWorker
222
+ ? workerRunner(source, dbPath, { force })
223
+ : _runInventory(source, { force });
224
+ _inflight.set(key, promise);
225
+ promise.finally(() => {
226
+ if (_inflight.get(key) === promise) {
227
+ _inflight.delete(key);
228
+ }
229
+ }).catch(() => {});
230
+
231
+ if (consistency === 'stale-ok') {
232
+ const timeout = new Promise((resolve) => setTimeout(() => resolve('timeout'), COLD_STALE_WAIT_MS));
233
+ const result = await Promise.race([promise, timeout]);
234
+ if (result === 'timeout') {
235
+ return;
236
+ }
237
+ }
238
+
239
+ await promise;
240
+ }
241
+
242
+ async function _runInventory(source, { force = false } = {}) {
243
+ const db = _getDb();
244
+ const adapter = adapters[source];
245
+ if (!adapter || !adapter.inventory) {
246
+ return;
247
+ }
248
+
249
+ if (!force) {
250
+ const row = db.prepare('SELECT last_inventory_ms FROM source_state WHERE source = ?').get(source);
251
+ if (row && row.last_inventory_ms && (Date.now() - row.last_inventory_ms) < INDEX_INVENTORY_TTL_MS) {
252
+ return;
253
+ }
254
+ }
255
+
256
+ const descriptors = await adapter.inventory();
257
+ if (!Array.isArray(descriptors)) return;
258
+
259
+ const seen = new Map();
260
+ for (const d of descriptors) {
261
+ const prev = seen.get(d.sessionId);
262
+ if (!prev || d.mtimeMs > prev.mtimeMs || (d.mtimeMs === prev.mtimeMs && d.filePath < prev.filePath)) {
263
+ seen.set(d.sessionId, d);
264
+ }
265
+ }
266
+
267
+ const indexedFiles = new Map();
268
+ const indexedRows = db.prepare('SELECT file_path, size, mtime_ms FROM session_file WHERE source = ?').all(source);
269
+ for (const r of indexedRows) {
270
+ indexedFiles.set(r.file_path, r);
271
+ }
272
+
273
+ const toParse = [];
274
+ const activePaths = new Set();
275
+ for (const d of seen.values()) {
276
+ activePaths.add(d.filePath);
277
+ const idx = indexedFiles.get(d.filePath);
278
+ if (!idx || idx.size !== d.size || idx.mtime_ms !== d.mtimeMs) {
279
+ toParse.push(d);
280
+ }
281
+ }
282
+
283
+ for (const filePath of indexedFiles.keys()) {
284
+ if (!activePaths.has(filePath)) {
285
+ db.prepare('DELETE FROM session_file WHERE source = ? AND file_path = ?').run(source, filePath);
286
+ }
287
+ }
288
+
289
+ let errorMsg = null;
290
+ for (const d of toParse) {
291
+ try {
292
+ const preStat = { size: d.size, mtimeMs: d.mtimeMs };
293
+ const { session, messages } = await adapter.parse(d);
294
+
295
+ try {
296
+ const postStat = fs.statSync(d.filePath);
297
+ if (postStat.size !== preStat.size || postStat.mtimeMs !== preStat.mtimeMs) {
298
+ const retry = await adapter.parse(d);
299
+ const retryStat = fs.statSync(d.filePath);
300
+ if (retryStat.size !== postStat.size || retryStat.mtimeMs !== postStat.mtimeMs) {
301
+ const existing = db.prepare('SELECT 1 FROM session_file WHERE source = ? AND file_path = ?').get(source, d.filePath);
302
+ if (existing) continue;
303
+ continue;
304
+ }
305
+ _upsertSession(db, source, d, retry.session, retry.messages);
306
+ continue;
307
+ }
308
+ } catch (_statErr) {
309
+ continue;
310
+ }
311
+
312
+ _upsertSession(db, source, d, session, messages);
313
+ } catch (err) {
314
+ const existing = db.prepare('SELECT 1 FROM session_file WHERE source = ? AND file_path = ?').get(source, d.filePath);
315
+ if (!existing) {
316
+ errorMsg = (errorMsg ? errorMsg + '; ' : '') + `${d.filePath}: ${err.message}`;
317
+ }
318
+ }
319
+ }
320
+
321
+ db.prepare(
322
+ 'INSERT INTO source_state(source, last_inventory_ms, last_error) VALUES(?, ?, ?) ON CONFLICT(source) DO UPDATE SET last_inventory_ms = excluded.last_inventory_ms, last_error = excluded.last_error'
323
+ ).run(source, Date.now(), errorMsg);
324
+ }
325
+
326
+ function _upsertSession(db, source, descriptor, session, messages) {
327
+ try {
328
+ db.exec('BEGIN IMMEDIATE');
329
+ db.prepare('DELETE FROM session_file WHERE source = ? AND session_id = ?').run(source, session.sessionId);
330
+
331
+ db.prepare(`
332
+ INSERT INTO session_file(
333
+ source, file_path, size, mtime_ms, session_id,
334
+ project_name, project_display_name, project_full_path,
335
+ first_message, git_branch, provider, model,
336
+ started_at, updated_at, message_count, usage_json, extra_json
337
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
338
+ `).run(
339
+ source,
340
+ descriptor.filePath,
341
+ descriptor.size,
342
+ descriptor.mtimeMs,
343
+ session.sessionId,
344
+ session.projectName || '',
345
+ session.projectDisplayName || null,
346
+ session.projectFullPath || null,
347
+ session.firstMessage || null,
348
+ session.gitBranch || null,
349
+ session.provider || null,
350
+ session.model || null,
351
+ session.startedAt || null,
352
+ session.updatedAt || null,
353
+ messages.length,
354
+ session.usageJson || null,
355
+ session.extraJson || null
356
+ );
357
+
358
+ if (messages.length > 0) {
359
+ const stmt = db.prepare(`
360
+ INSERT INTO session_message(
361
+ source, session_id, ordinal, message_id, role, type, subtype,
362
+ content, timestamp, model, provider, user_message_number, extra_json
363
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
364
+ `);
365
+
366
+ let ordinal = 0;
367
+ for (const msg of messages) {
368
+ stmt.run(
369
+ source,
370
+ session.sessionId,
371
+ ordinal,
372
+ msg.messageId || null,
373
+ msg.role || null,
374
+ msg.type || null,
375
+ msg.subtype || null,
376
+ msg.content || null,
377
+ msg.timestamp || null,
378
+ msg.model || null,
379
+ msg.provider || null,
380
+ msg.userMessageNumber != null ? msg.userMessageNumber : null,
381
+ msg.extraJson || null
382
+ );
383
+ ordinal++;
384
+ }
385
+ }
386
+
387
+ db.exec('COMMIT');
388
+ } catch (err) {
389
+ try { db.exec('ROLLBACK'); } catch (_) {}
390
+ throw err;
391
+ }
392
+ }
393
+
394
+
395
+
396
+ // ---- Public query methods ----
397
+
398
+ /**
399
+ * @param {string} source
400
+ * @param {object} [options]
401
+ * @returns {Promise<Array<{name, displayName, fullPath, path, sessionCount, lastUsed, latestSession, source}>>}
402
+ */
403
+ async function listProjects(source, options = {}) {
404
+ await ensureSourceIndexed(source, { consistency: options.consistency || 'stale-ok' });
405
+ const db = _getDb();
406
+
407
+ const rows = db.prepare(`
408
+ SELECT project_name, project_display_name, project_full_path,
409
+ COUNT(*) AS session_count,
410
+ MAX(updated_at) AS last_used,
411
+ (SELECT session_id FROM session_file sf2
412
+ WHERE sf2.source = ? AND sf2.project_name = sf.project_name
413
+ ORDER BY updated_at DESC LIMIT 1) AS latest_session
414
+ FROM session_file sf
415
+ WHERE source = ?
416
+ GROUP BY project_name
417
+ ORDER BY last_used DESC
418
+ `).all(source, source);
419
+
420
+ return rows.map(r => ({
421
+ name: r.project_name,
422
+ displayName: r.project_display_name || r.project_name,
423
+ fullPath: r.project_full_path || '',
424
+ path: r.project_full_path || '',
425
+ sessionCount: r.session_count,
426
+ lastUsed: r.last_used,
427
+ latestSession: r.latest_session,
428
+ source
429
+ }));
430
+ }
431
+
432
+ /**
433
+ * @param {string} source
434
+ * @param {string} projectName
435
+ * @param {object} [options]
436
+ * @returns {Promise<Array>}
437
+ */
438
+ async function listSessions(source, projectName, options = {}) {
439
+ await ensureSourceIndexed(source, { consistency: 'stale-ok' });
440
+
441
+ const db = _getDb();
442
+ const rows = db.prepare(`
443
+ SELECT * FROM session_file
444
+ WHERE source = ? AND project_name = ?
445
+ ORDER BY updated_at DESC
446
+ `).all(source, projectName);
447
+
448
+ return rows.map(r => ({
449
+ sessionId: r.session_id,
450
+ filePath: r.file_path,
451
+ size: r.size,
452
+ mtime: r.mtime_ms,
453
+ firstMessage: r.first_message,
454
+ gitBranch: r.git_branch,
455
+ provider: r.provider,
456
+ model: r.model,
457
+ messageCount: r.message_count,
458
+ tokens: r.usage_json ? JSON.parse(r.usage_json) : null,
459
+ extra: r.extra_json ? JSON.parse(r.extra_json) : {},
460
+ source: r.source,
461
+ projectName: r.project_name,
462
+ projectDisplayName: r.project_display_name,
463
+ projectFullPath: r.project_full_path,
464
+ startedAt: r.started_at,
465
+ updatedAt: r.updated_at
466
+ }));
467
+ }
468
+
469
+ /**
470
+ * @param {string} source
471
+ * @param {string} sessionId
472
+ * @param {object} [options]
473
+ * @returns {Promise<{sessionId, lastModified, size, filePath}|null>}
474
+ */
475
+ async function getSessionStatus(source, sessionId, options = {}) {
476
+ await ensureSourceIndexed(source, { consistency: 'stale-ok' });
477
+ const db = _getDb();
478
+ const row = db.prepare(
479
+ 'SELECT session_id, mtime_ms, size, file_path FROM session_file WHERE source = ? AND session_id = ?'
480
+ ).get(source, sessionId);
481
+
482
+ if (!row) return null;
483
+
484
+ // Verify file still exists
485
+ try {
486
+ await fs.promises.stat(row.file_path);
487
+ } catch (_) {
488
+ // File gone - invalidate and return null
489
+ db.prepare('DELETE FROM session_file WHERE source = ? AND session_id = ?').run(source, sessionId);
490
+ return null;
491
+ }
492
+
493
+ return {
494
+ sessionId: row.session_id,
495
+ lastModified: row.mtime_ms,
496
+ size: row.size,
497
+ filePath: row.file_path
498
+ };
499
+ }
500
+
501
+ /**
502
+ * @param {string} source
503
+ * @param {string} sessionId
504
+ * @param {object} [options]
505
+ * @returns {Promise<{sessionId, items: Array}|null>}
506
+ */
507
+ async function getSessionOutline(source, sessionId, options = {}) {
508
+ await _ensureSessionCurrent(source, sessionId);
509
+ const db = _getDb();
510
+
511
+ const rows = db.prepare(`
512
+ SELECT user_message_number, content, timestamp
513
+ FROM session_message
514
+ WHERE source = ? AND session_id = ? AND user_message_number IS NOT NULL
515
+ ORDER BY ordinal ASC
516
+ `).all(source, sessionId);
517
+
518
+ if (rows.length === 0) {
519
+ // Check session exists at all
520
+ const sf = db.prepare('SELECT 1 FROM session_file WHERE source = ? AND session_id = ?').get(source, sessionId);
521
+ if (!sf) return null;
522
+ return { sessionId, items: [] };
523
+ }
524
+
525
+ return {
526
+ sessionId,
527
+ items: rows.map(r => ({
528
+ userMessageNumber: r.user_message_number,
529
+ preview: _buildPreview(r.content),
530
+ timestamp: r.timestamp
531
+ }))
532
+ };
533
+ }
534
+
535
+ /**
536
+ * @param {string} source
537
+ * @param {string} sessionId
538
+ * @param {object} [options]
539
+ * @returns {Promise<{messages: Array, metadata: object, pagination: object}|null>}
540
+ */
541
+ async function getMessagePage(source, sessionId, options = {}) {
542
+ await _ensureSessionCurrent(source, sessionId);
543
+ const db = _getDb();
544
+ const { page, limit, order } = _normalizePageOpts(options);
545
+
546
+ // Check session exists
547
+ const sf = db.prepare('SELECT * FROM session_file WHERE source = ? AND session_id = ?').get(source, sessionId);
548
+ if (!sf) return null;
549
+
550
+ const total = Number(sf.message_count);
551
+ if (total === 0) {
552
+ return {
553
+ messages: [],
554
+ metadata: _buildMessageMetadata(sf),
555
+ pagination: { page, limit, total: 0, hasMore: false }
556
+ };
557
+ }
558
+
559
+ const offset = (page - 1) * limit;
560
+ const rows = db.prepare(`
561
+ SELECT * FROM session_message
562
+ WHERE source = ? AND session_id = ?
563
+ ORDER BY ordinal ${order}
564
+ LIMIT ? OFFSET ?
565
+ `).all(source, sessionId, limit, offset);
566
+
567
+ return {
568
+ messages: rows.map(r => ({
569
+ messageId: r.message_id,
570
+ role: r.role,
571
+ type: r.type,
572
+ subtype: r.subtype,
573
+ content: r.content,
574
+ timestamp: r.timestamp,
575
+ model: r.model,
576
+ provider: r.provider,
577
+ userMessageNumber: r.user_message_number,
578
+ extra: r.extra_json ? JSON.parse(r.extra_json) : {}
579
+ })),
580
+ metadata: _buildMessageMetadata(sf),
581
+ pagination: {
582
+ page,
583
+ limit,
584
+ total,
585
+ hasMore: offset + limit < total
586
+ }
587
+ };
588
+ }
589
+
590
+ /**
591
+ * @param {string} source
592
+ * @param {number} [limit=5]
593
+ * @param {object} [options]
594
+ * @returns {Promise<Array>}
595
+ */
596
+ async function getRecentSessions(source, limit = 5, options = {}) {
597
+ await ensureSourceIndexed(source, { consistency: 'stale-ok' });
598
+ const db = _getDb();
599
+
600
+ const rows = db.prepare(`
601
+ SELECT * FROM session_file
602
+ WHERE source = ?
603
+ ORDER BY updated_at DESC
604
+ LIMIT ?
605
+ `).all(source, Math.max(1, Math.min(limit, 100)));
606
+
607
+ return rows.map(r => ({
608
+ sessionId: r.session_id,
609
+ filePath: r.file_path,
610
+ size: r.size,
611
+ mtime: r.mtime_ms,
612
+ firstMessage: r.first_message,
613
+ gitBranch: r.git_branch,
614
+ provider: r.provider,
615
+ model: r.model,
616
+ messageCount: r.message_count,
617
+ projectName: r.project_name,
618
+ projectDisplayName: r.project_display_name,
619
+ projectFullPath: r.project_full_path,
620
+ source: r.source,
621
+ updatedAt: r.updated_at
622
+ }));
623
+ }
624
+
625
+ /**
626
+ * @param {string} source
627
+ * @param {string} keyword
628
+ * @param {object} [options]
629
+ * @returns {Promise<Array>}
630
+ */
631
+ async function searchSessions(source, keyword, options = {}) {
632
+ await ensureSourceIndexed(source, { consistency: options.consistency || 'complete' });
633
+ const db = _getDb();
634
+ const contextLength = options.contextLength || 35;
635
+ const projectName = options.projectName || null;
636
+
637
+ // Build candidate query
638
+ let candidates;
639
+ if (_ftsAvailable) {
640
+ // FTS5 trigram search for needles >= 3 chars
641
+ const needle = String(keyword);
642
+ if ([...needle].length >= 3) {
643
+ const ftsRows = db.prepare(`
644
+ SELECT sm.source, sm.session_id, sm.ordinal, sm.content, sm.role, sm.type, sm.timestamp
645
+ FROM session_message_fts fts
646
+ JOIN session_message sm ON sm.rowid = fts.rowid
647
+ JOIN session_file sf ON sf.source = sm.source AND sf.session_id = sm.session_id
648
+ WHERE sm.source = ? AND session_message_fts MATCH ?
649
+ ${projectName ? 'AND sf.project_name = ?' : ''}
650
+ ORDER BY sf.updated_at DESC
651
+ LIMIT 500
652
+ `);
653
+ const params = [source, _ftsQuote(needle)];
654
+ if (projectName) params.push(projectName);
655
+ candidates = ftsRows.all(...params);
656
+ } else {
657
+ // Short needle: scan all messages for this source
658
+ candidates = _scanMessagesRelational(db, source, keyword, projectName);
659
+ }
660
+ } else {
661
+ candidates = _scanMessagesRelational(db, source, keyword, projectName);
662
+ }
663
+
664
+ if (candidates.length === 0) return [];
665
+
666
+ // Final matching with JS toLowerCase/indexOf (preserves CJK, Unicode case)
667
+ const lowerKeyword = String(keyword).toLocaleLowerCase();
668
+ const matched = [];
669
+ const matchMap = new Map(); // sessionId → { session, matches[] }
670
+
671
+ for (const c of candidates) {
672
+ const content = c.content || '';
673
+ const lowerContent = content.toLocaleLowerCase();
674
+ let idx = 0;
675
+ let count = 0;
676
+ const positions = [];
677
+ while ((idx = lowerContent.indexOf(lowerKeyword, idx)) !== -1) {
678
+ positions.push(idx);
679
+ count++;
680
+ idx += lowerKeyword.length;
681
+ }
682
+ if (count === 0) continue;
683
+
684
+ if (!matchMap.has(c.session_id)) {
685
+ const sf = db.prepare('SELECT * FROM session_file WHERE source = ? AND session_id = ?').get(source, c.session_id);
686
+ if (!sf) continue;
687
+ matchMap.set(c.session_id, {
688
+ session: sf,
689
+ matchCount: 0,
690
+ messages: []
691
+ });
692
+ }
693
+
694
+ const entry = matchMap.get(c.session_id);
695
+ // Claude: count every occurrence; Codex/Gemini/OMP: at most 1 per message
696
+ entry.matchCount += (source === 'claude') ? count : 1;
697
+ entry.messages.push({
698
+ ordinal: c.ordinal,
699
+ content,
700
+ positions,
701
+ role: c.role || 'unknown',
702
+ type: c.type || c.role || 'unknown',
703
+ timestamp: c.timestamp,
704
+ context: _extractContext(content, positions[0], contextLength, lowerKeyword.length)
705
+ });
706
+ }
707
+
708
+ // Flatten to results
709
+ const results = [];
710
+ for (const [_sid, entry] of matchMap) {
711
+ results.push({
712
+ sessionId: entry.session.session_id,
713
+ projectName: entry.session.project_name,
714
+ projectDisplayName: entry.session.project_display_name,
715
+ projectFullPath: entry.session.project_full_path,
716
+ filePath: entry.session.file_path,
717
+ firstMessage: entry.session.first_message,
718
+ updatedAt: entry.session.updated_at,
719
+ matchCount: entry.matchCount,
720
+ source: entry.session.source,
721
+ matches: entry.messages.slice(0, 5)
722
+ });
723
+ }
724
+
725
+ // Sort: matchCount DESC, then updated_at DESC
726
+ results.sort((a, b) => {
727
+ const cm = b.matchCount - a.matchCount;
728
+ if (cm !== 0) return cm;
729
+ const aRow = matchMap.get(a.sessionId);
730
+ const bRow = matchMap.get(b.sessionId);
731
+ const au = (aRow && aRow.session.updated_at) || 0;
732
+ const bu = (bRow && bRow.session.updated_at) || 0;
733
+ return bu - au;
734
+ });
735
+
736
+ return results;
737
+ }
738
+
739
+ function _scanMessagesRelational(db, source, keyword, projectName = null) {
740
+ const lowerKeyword = String(keyword).toLocaleLowerCase();
741
+ // Use instr for case-insensitive substring matching
742
+ let sql = `
743
+ SELECT sm.source, sm.session_id, sm.ordinal, sm.content, sm.role, sm.type, sm.timestamp
744
+ FROM session_message sm
745
+ JOIN session_file sf ON sf.source = sm.source AND sf.session_id = sm.session_id
746
+ WHERE sm.source = ? AND instr(lower(sm.content), lower(?)) > 0
747
+ ${projectName ? 'AND sf.project_name = ?' : ''}
748
+ ORDER BY sf.updated_at DESC
749
+ LIMIT 500
750
+ `;
751
+ const params = [source, keyword];
752
+ if (projectName) params.push(projectName);
753
+ return db.prepare(sql).all(...params);
754
+ }
755
+
756
+ function _extractContext(content, position, contextLength, keywordLength) {
757
+ const start = Math.max(0, position - contextLength);
758
+ const end = Math.min(content.length, position + keywordLength + contextLength);
759
+ let ctx = content.slice(start, end);
760
+ if (start > 0) ctx = '...' + ctx;
761
+ if (end < content.length) ctx = ctx + '...';
762
+ return ctx;
763
+ }
764
+
765
+ function _buildPreview(content) {
766
+ if (!content) return '(空消息)';
767
+ const firstLine = String(content)
768
+ .split('\n')
769
+ .map(l => l.trim())
770
+ .find(Boolean) || '(空消息)';
771
+ return firstLine.length > 42 ? firstLine.slice(0, 42) + '...' : firstLine;
772
+ }
773
+
774
+ function _buildMessageMetadata(sf) {
775
+ return {
776
+ sessionId: sf.session_id,
777
+ gitBranch: sf.git_branch,
778
+ provider: sf.provider,
779
+ model: sf.model,
780
+ messageCount: sf.message_count,
781
+ usage: sf.usage_json ? JSON.parse(sf.usage_json) : null,
782
+ extra: sf.extra_json ? JSON.parse(sf.extra_json) : {}
783
+ };
784
+ }
785
+
786
+ /**
787
+ * Ensure the indexed data for a session is current by checking file mtime.
788
+ */
789
+ async function _ensureSessionCurrent(source, sessionId) {
790
+ await ensureSourceIndexed(source, { consistency: 'stale-ok' });
791
+ const db = _getDb();
792
+ const row = db.prepare(
793
+ 'SELECT file_path, size, mtime_ms FROM session_file WHERE source = ? AND session_id = ?'
794
+ ).get(source, sessionId);
795
+ if (!row) return;
796
+
797
+ let currentStat;
798
+ try {
799
+ currentStat = await fs.promises.stat(row.file_path);
800
+ } catch (_) {
801
+ // File gone, session stays indexed as-is
802
+ return;
803
+ }
804
+
805
+ if (currentStat.size !== row.size || currentStat.mtimeMs !== row.mtime_ms) {
806
+ // File changed; force re-index
807
+ await ensureSourceIndexed(source, { force: true, consistency: 'complete' });
808
+ }
809
+ }
810
+
811
+ /**
812
+ * @param {string} source
813
+ * @param {object} [options]
814
+ */
815
+ function invalidateSource(source, options = {}) {
816
+ const db = _getDb ? _getDb() : null;
817
+ if (!db) return;
818
+
819
+ if (options.deleted && options.sessionId) {
820
+ db.prepare('DELETE FROM session_file WHERE source = ? AND session_id = ?').run(source, options.sessionId);
821
+ }
822
+
823
+ // Mark state as stale
824
+ db.prepare(
825
+ 'UPDATE source_state SET last_inventory_ms = NULL WHERE source = ?'
826
+ ).run(source);
827
+ }
828
+
829
+ function closeSessionHistoryIndex() {
830
+ closeDatabase(dbPath);
831
+ _db = null;
832
+ }
833
+
834
+ // Build the API object
835
+ const api = {
836
+ ensureSourceIndexed,
837
+ listProjects,
838
+ listSessions,
839
+ getSessionStatus,
840
+ getSessionOutline,
841
+ getMessagePage,
842
+ getRecentSessions,
843
+ searchSessions,
844
+ invalidateSource,
845
+ closeSessionHistoryIndex,
846
+ };
847
+
848
+ // Lazy-init helpers used internally
849
+ api._getDb = _getDb;
850
+ api._initSchema = _initSchema;
851
+
852
+ return api;
853
+ }
854
+
855
+ // ---------------------------------------------------------------------------
856
+ // Default singleton
857
+ // ---------------------------------------------------------------------------
858
+
859
+ let _defaultIndex = null;
860
+
861
+ function _getDefaultIndex() {
862
+ if (!_defaultIndex) {
863
+ _defaultIndex = createSessionHistoryIndex();
864
+ }
865
+ return _defaultIndex;
866
+ }
867
+
868
+ async function ensureSourceIndexed(source, options) {
869
+ return _getDefaultIndex().ensureSourceIndexed(source, options);
870
+ }
871
+
872
+ async function listProjects(source, options) {
873
+ return _getDefaultIndex().listProjects(source, options);
874
+ }
875
+
876
+ async function listSessions(source, projectName, options) {
877
+ return _getDefaultIndex().listSessions(source, projectName, options);
878
+ }
879
+
880
+ async function getSessionStatus(source, sessionId, options) {
881
+ return _getDefaultIndex().getSessionStatus(source, sessionId, options);
882
+ }
883
+
884
+ async function getSessionOutline(source, sessionId, options) {
885
+ return _getDefaultIndex().getSessionOutline(source, sessionId, options);
886
+ }
887
+
888
+ async function getMessagePage(source, sessionId, options) {
889
+ return _getDefaultIndex().getMessagePage(source, sessionId, options);
890
+ }
891
+
892
+ async function getRecentSessions(source, limit, options) {
893
+ return _getDefaultIndex().getRecentSessions(source, limit, options);
894
+ }
895
+
896
+ async function searchSessions(source, keyword, options) {
897
+ return _getDefaultIndex().searchSessions(source, keyword, options);
898
+ }
899
+
900
+ function invalidateSource(source, options) {
901
+ _getDefaultIndex().invalidateSource(source, options);
902
+ }
903
+
904
+ function closeSessionHistoryIndex() {
905
+ if (_defaultIndex) {
906
+ _defaultIndex.closeSessionHistoryIndex();
907
+ _defaultIndex = null;
908
+ }
909
+ }
910
+
911
+ // ---------------------------------------------------------------------------
912
+ // Default worker runner uses a child process in production.
913
+ async function _defaultWorkerRunner(source, indexDbPath, options = {}) {
914
+ const { runInventoryWorker } = require('./session-history-worker');
915
+ return runInventoryWorker(source, indexDbPath, options);
916
+ }
917
+
918
+ module.exports = {
919
+ createSessionHistoryIndex,
920
+ ensureSourceIndexed,
921
+ listProjects,
922
+ listSessions,
923
+ getSessionStatus,
924
+ getSessionOutline,
925
+ getMessagePage,
926
+ getRecentSessions,
927
+ searchSessions,
928
+ invalidateSource,
929
+ closeSessionHistoryIndex
930
+ };