coding-tool-x 3.8.7 → 3.8.9

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 (143) hide show
  1. package/README.md +2 -2
  2. package/dist/web/assets/{AgentsDrawer-C7ePByZx.js → AgentsDrawer-Cj5CJ4d9.js} +1 -1
  3. package/dist/web/assets/Analytics-1doS-8uC.js +1 -0
  4. package/dist/web/assets/AssetPathField-C9TOPl7o.js +1 -0
  5. package/dist/web/assets/{ChatHistoryDrawer-Ccl4rhdz.js → ChatHistoryDrawer-BTmQ1aQr.js} +1 -1
  6. package/dist/web/assets/{CollapseItem-MxG_UD4o.js → CollapseItem-CwqJk3bE.js} +1 -1
  7. package/dist/web/assets/{CommandsDrawer-DWbaxHRZ.js → CommandsDrawer-CaZWDI_G.js} +1 -1
  8. package/dist/web/assets/ConfigExportDrawer-C3vSZEsS.js +1 -0
  9. package/dist/web/assets/{ConfigTemplates-AiRPv2cd.js → ConfigTemplates-gyERg9iC.js} +1 -1
  10. package/dist/web/assets/{ConfigTemplatesDrawer-CKMFsfEF.js → ConfigTemplatesDrawer-B17RqFi4.js} +1 -1
  11. package/dist/web/assets/{ConfigTemplatesPanel-Cvbnkvpt.js → ConfigTemplatesPanel-DKIC_FqG.js} +1 -1
  12. package/dist/web/assets/{Divider-D28g1gwj.js → Divider-DsdbkHrG.js} +1 -1
  13. package/dist/web/assets/{FavoritesDrawer-DA-dR_vP.js → FavoritesDrawer-DaziQmBp.js} +1 -1
  14. package/dist/web/assets/Forward-DLCL7aMY.js +1 -0
  15. package/dist/web/assets/{GatewayConvertDrawer-402_raGb.js → GatewayConvertDrawer-OMgWUUSc.js} +1 -1
  16. package/dist/web/assets/Home-DOSUTfrE.js +1 -0
  17. package/dist/web/assets/{MarkdownEditor-BvkDECwH.js → MarkdownEditor-9TTwTS-U.js} +1 -1
  18. package/dist/web/assets/McpDrawer-I8rl8lGH.js +1 -0
  19. package/dist/web/assets/{McpFormDrawer-DPMnyt5L.js → McpFormDrawer-DEP3C7ut.js} +1 -1
  20. package/dist/web/assets/{PluginManager-FKVeugr4.js → PluginManager-BuRcWuBj.js} +1 -1
  21. package/dist/web/assets/{PluginsDrawer-MvM1R55d.js → PluginsDrawer-_zZENaQn.js} +1 -1
  22. package/dist/web/assets/PluginsPanel-Bs5qZ-TK.js +1 -0
  23. package/dist/web/assets/PluginsPanel-CzGMK-Ap.css +1 -0
  24. package/dist/web/assets/ProjectList-BSxuL2td.js +1 -0
  25. package/dist/web/assets/{ProjectList-677RLZ8O.css → ProjectList-VbIYSHbH.css} +1 -1
  26. package/dist/web/assets/PromptsDrawer-CoZ-ZtsY.js +1 -0
  27. package/dist/web/assets/RecentSessionsDrawer-B_17aJBG.js +1 -0
  28. package/dist/web/assets/RecentSessionsDrawer-yrAD4p1A.css +1 -0
  29. package/dist/web/assets/SessionCard-BL_x0d3t.js +1 -0
  30. package/dist/web/assets/SessionList-DhRYZ6vl.js +1 -0
  31. package/dist/web/assets/SessionList-qG_XpPBJ.css +1 -0
  32. package/dist/web/assets/{SettingsDrawer-lMjttbkD.js → SettingsDrawer-fYb0DU7X.js} +1 -1
  33. package/dist/web/assets/SkillManager-S698Hns8.js +1 -0
  34. package/dist/web/assets/{SkillsDrawer-DF6JsSyj.js → SkillsDrawer-Ms4lPCow.js} +1 -1
  35. package/dist/web/assets/{SkillsPanel-B3Nm5j32.css → SkillsPanel-C4ke56nF.css} +1 -1
  36. package/dist/web/assets/SkillsPanel-rqnqAOGL.js +1 -0
  37. package/dist/web/assets/{SpeedTestDrawer-BaZkZqPP.js → SpeedTestDrawer-Bbn38SfV.js} +1 -1
  38. package/dist/web/assets/Upload-CdyVHxCI.js +1 -0
  39. package/dist/web/assets/{WorkspaceDrawer-BFSwZ5yU.js → WorkspaceDrawer-L7HJRc6u.js} +1 -1
  40. package/dist/web/assets/{WorkspaceManager-BHL1uYct.js → WorkspaceManager-DsoNg2z8.js} +1 -1
  41. package/dist/web/assets/{WorkspacePanel-B5PooBtg.js → WorkspacePanel-Be8PVZah.js} +1 -1
  42. package/dist/web/assets/{headers-cf-QapUq.js → headers-B5JWGn3v.js} +1 -1
  43. package/dist/web/assets/{icons-B2Vg-IOC.js → icons-DmFe4b5j.js} +1 -1
  44. package/dist/web/assets/{index-BohDDAy_.css → index-3Lp6z8TQ.css} +1 -1
  45. package/dist/web/assets/index-BKlAWTH-.js +2 -0
  46. package/dist/web/assets/{light-Cp0t7v32.js → light-0eavxNvt.js} +1 -1
  47. package/dist/web/assets/platforms-BmzzwnSw.js +1 -0
  48. package/dist/web/assets/platforms-DWXgmy2w.js +1 -0
  49. package/dist/web/assets/session-visibility-BAmxh3cK.js +1 -0
  50. package/dist/web/assets/sessions-CC0j-KH_.js +1 -0
  51. package/dist/web/assets/sessions-D4yQtfKb.js +1 -0
  52. package/dist/web/assets/use-message-DqCwefG6.js +1 -0
  53. package/dist/web/assets/{useResponsiveDrawer-D-DDpbnI.js → useResponsiveDrawer-V_onl9NV.js} +1 -1
  54. package/dist/web/index.html +7 -7
  55. package/package.json +1 -1
  56. package/src/commands/daemon.js +27 -4
  57. package/src/commands/search.js +17 -2
  58. package/src/config/paths.js +2 -0
  59. package/src/platforms/channel-auth-registry.js +36 -6
  60. package/src/platforms/channel-auth-service.js +12 -2
  61. package/src/platforms/driver-registry.js +9 -1
  62. package/src/platforms/drivers/claude/session-history-adapter.js +72 -1
  63. package/src/platforms/drivers/claude/sessions-implementation.js +49 -7
  64. package/src/platforms/drivers/claude/sessions.js +30 -6
  65. package/src/platforms/drivers/codex/parser.js +57 -1
  66. package/src/platforms/drivers/codex/session-history-adapter.js +8 -2
  67. package/src/platforms/drivers/codex/sessions-implementation.js +25 -3
  68. package/src/platforms/drivers/codex/sessions.js +18 -1
  69. package/src/platforms/drivers/dsh/api-operations.js +396 -0
  70. package/src/platforms/drivers/dsh/channels-implementation.js +458 -0
  71. package/src/platforms/drivers/dsh/channels.js +191 -0
  72. package/src/platforms/drivers/dsh/common.js +571 -0
  73. package/src/platforms/drivers/dsh/native-config.js +93 -0
  74. package/src/platforms/drivers/dsh/native-logs.js +116 -0
  75. package/src/platforms/drivers/dsh/plugin-manager.js +117 -0
  76. package/src/platforms/drivers/dsh/projects.js +23 -0
  77. package/src/platforms/drivers/dsh/proxy-implementation.js +260 -0
  78. package/src/platforms/drivers/dsh/proxy.js +23 -0
  79. package/src/platforms/drivers/dsh/resources.js +346 -0
  80. package/src/platforms/drivers/dsh/sessions.js +587 -0
  81. package/src/platforms/drivers/dsh/statistics-implementation.js +238 -0
  82. package/src/platforms/drivers/dsh/statistics.js +24 -0
  83. package/src/platforms/drivers/gemini/session-history-adapter.js +49 -1
  84. package/src/platforms/drivers/gemini/sessions-implementation.js +46 -1
  85. package/src/platforms/drivers/gemini/sessions.js +2 -1
  86. package/src/platforms/drivers/omp/prompts.js +58 -2
  87. package/src/platforms/drivers/omp/proxy-implementation.js +25 -1
  88. package/src/platforms/drivers/omp/session-history-adapter.js +42 -1
  89. package/src/platforms/drivers/omp/sessions-implementation.js +51 -3
  90. package/src/platforms/drivers/omp/sessions.js +33 -2
  91. package/src/platforms/drivers/opencode/channels-implementation.js +25 -22
  92. package/src/platforms/drivers/opencode/channels.js +6 -1
  93. package/src/platforms/drivers/opencode/sessions-implementation.js +89 -9
  94. package/src/platforms/drivers/opencode/sessions.js +37 -1
  95. package/src/platforms/manifest-schema.js +43 -3
  96. package/src/platforms/manifests/dsh.json +542 -0
  97. package/src/platforms/manifests/omp.json +2 -1
  98. package/src/platforms/manifests/opencode.json +28 -0
  99. package/src/platforms/native-oauth-adapters.js +318 -18
  100. package/src/platforms/oauth-credentials-service.js +344 -13
  101. package/src/platforms/path-resolver.js +2 -0
  102. package/src/platforms/platform-path-context.js +12 -2
  103. package/src/platforms/registry.js +16 -1
  104. package/src/platforms/runtime.js +5 -0
  105. package/src/platforms/session-history-adapters.js +1 -0
  106. package/src/server/api/platform-api-config.js +23 -2
  107. package/src/server/api/platform-route-factory.js +9 -2
  108. package/src/server/api/platforms.js +19 -1
  109. package/src/server/api/plugins.js +108 -13
  110. package/src/server/index.js +35 -1
  111. package/src/server/services/config-registry-service.js +10 -1
  112. package/src/server/services/config-templates-service.js +1 -0
  113. package/src/server/services/plugins-service.js +474 -103
  114. package/src/server/services/session-history-index.js +900 -140
  115. package/src/server/services/session-history-worker.js +3 -1
  116. package/src/server/services/skill-service.js +214 -7
  117. package/src/server/services/usage-log-utils.js +1 -0
  118. package/src/server/services/workspace-service.js +39 -10
  119. package/src/shared/driver-factories/api.js +22 -6
  120. package/src/shared/driver-factories/projects.js +8 -0
  121. package/dist/web/assets/Analytics-BVG6ehFv.js +0 -1
  122. package/dist/web/assets/AssetPathField-BZI9NvEr.js +0 -1
  123. package/dist/web/assets/ConfigExportDrawer-DrawKwxE.js +0 -1
  124. package/dist/web/assets/Home-CTF8ZWKW.js +0 -1
  125. package/dist/web/assets/McpDrawer-DZGHkuDm.js +0 -1
  126. package/dist/web/assets/PluginsPanel-CKuBs38B.js +0 -1
  127. package/dist/web/assets/PluginsPanel-D6ZqITcR.css +0 -1
  128. package/dist/web/assets/ProjectList-tsjwjXV1.js +0 -1
  129. package/dist/web/assets/PromptsDrawer-B-X9ci8n.js +0 -1
  130. package/dist/web/assets/RecentSessionsDrawer-CKFsOYDM.css +0 -1
  131. package/dist/web/assets/RecentSessionsDrawer-wLktMF_c.js +0 -1
  132. package/dist/web/assets/SessionCard-DkJPy0TY.js +0 -1
  133. package/dist/web/assets/SessionList-BpYRz1cX.css +0 -1
  134. package/dist/web/assets/SessionList-Gzhh4FOZ.js +0 -1
  135. package/dist/web/assets/SkillManager-CzFxWxx-.js +0 -1
  136. package/dist/web/assets/SkillsPanel--lLEts9y.js +0 -1
  137. package/dist/web/assets/Upload-BEQ-3Kqq.js +0 -1
  138. package/dist/web/assets/index-BFInlZGI.js +0 -2
  139. package/dist/web/assets/platforms-CBkB45JP.js +0 -1
  140. package/dist/web/assets/platforms-FTpjxAT6.js +0 -1
  141. package/dist/web/assets/sessions-Cmy31slD.js +0 -1
  142. package/dist/web/assets/sessions-yC_oVVh8.js +0 -1
  143. package/dist/web/assets/use-message-DoLM8C1C.js +0 -1
@@ -25,6 +25,8 @@ const INVENTORY_LOCK_WAIT_MS = 30000;
25
25
  const INVENTORY_PARSE_BATCH_SIZE = 8;
26
26
  /** @type {number} Maximum number of files parsed concurrently */
27
27
  const INVENTORY_PARSE_CONCURRENCY = 2;
28
+ const SUMMARY_REFRESH_CONCURRENCY = 8;
29
+ const SUMMARY_VERSION = 1;
28
30
  const BUILTIN_SESSION_SOURCES = new Set(['claude', 'codex', 'gemini', 'omp']);
29
31
  // Keep synchronized with the parserVersion exposed by the built-in session drivers.
30
32
  const BUILTIN_SESSION_PARSER_VERSIONS = Object.freeze({ claude: 2, codex: 2, gemini: 2, omp: 2 });
@@ -103,8 +105,8 @@ function _tryAcquireInventoryLock(dbPath, source) {
103
105
  return null;
104
106
  }
105
107
 
106
- async function _acquireInventoryLock(dbPath, source) {
107
- const deadline = Date.now() + INVENTORY_LOCK_WAIT_MS;
108
+ async function _acquireInventoryLock(dbPath, source, waitMs = INVENTORY_LOCK_WAIT_MS) {
109
+ const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
108
110
  while (true) {
109
111
  const lock = _tryAcquireInventoryLock(dbPath, source);
110
112
  if (lock?.busy) {
@@ -117,6 +119,21 @@ async function _acquireInventoryLock(dbPath, source) {
117
119
  }
118
120
  }
119
121
 
122
+ function _hasActiveInventoryLock(dbPath) {
123
+ const lockPath = _inventoryLockPath(dbPath);
124
+ try {
125
+ const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
126
+ const ageMs = Math.max(0, Date.now() - fs.statSync(lockPath).mtimeMs);
127
+ const ownerPid = Number(owner?.pid);
128
+ return _isProcessAlive(ownerPid) || (
129
+ (!Number.isInteger(ownerPid) || ownerPid <= 0)
130
+ && ageMs < INVENTORY_LOCK_STALE_MS
131
+ );
132
+ } catch (_) {
133
+ return false;
134
+ }
135
+ }
136
+
120
137
  function _releaseInventoryLock(lock) {
121
138
  if (!lock) return;
122
139
  try { fs.closeSync(lock.fd); } catch (_err) {}
@@ -199,9 +216,38 @@ const SCHEMA_SQL = `
199
216
  CREATE TABLE IF NOT EXISTS source_state (
200
217
  source TEXT PRIMARY KEY,
201
218
  last_inventory_ms INTEGER,
202
- last_error TEXT
219
+ last_error TEXT,
220
+ summary_inventory_ms INTEGER,
221
+ summary_error TEXT
203
222
  );
204
223
 
224
+ CREATE TABLE IF NOT EXISTS session_summary (
225
+ source TEXT NOT NULL,
226
+ file_path TEXT NOT NULL,
227
+ session_id TEXT NOT NULL,
228
+ project_name TEXT NOT NULL,
229
+ project_display_name TEXT,
230
+ project_full_path TEXT,
231
+ first_message TEXT,
232
+ git_branch TEXT,
233
+ provider TEXT,
234
+ model TEXT,
235
+ size INTEGER NOT NULL DEFAULT 0,
236
+ mtime_ms INTEGER NOT NULL DEFAULT 0,
237
+ started_at INTEGER,
238
+ updated_at INTEGER,
239
+ summary_version INTEGER NOT NULL DEFAULT 1,
240
+ extra_json TEXT,
241
+ PRIMARY KEY (source, file_path),
242
+ UNIQUE (source, session_id)
243
+ );
244
+
245
+ CREATE INDEX IF NOT EXISTS idx_session_summary_project
246
+ ON session_summary(source, project_name, updated_at DESC, session_id ASC);
247
+
248
+ CREATE INDEX IF NOT EXISTS idx_session_summary_updated
249
+ ON session_summary(source, updated_at DESC, session_id ASC);
250
+
205
251
  CREATE TABLE IF NOT EXISTS session_file (
206
252
  source TEXT NOT NULL,
207
253
  file_path TEXT NOT NULL,
@@ -221,6 +267,7 @@ CREATE TABLE IF NOT EXISTS session_file (
221
267
  usage_json TEXT,
222
268
  extra_json TEXT,
223
269
  parser_version INTEGER NOT NULL DEFAULT 0,
270
+ content_indexed INTEGER NOT NULL DEFAULT 1,
224
271
  PRIMARY KEY (source, file_path),
225
272
  UNIQUE (source, session_id)
226
273
  );
@@ -310,6 +357,17 @@ function _normalizePageOpts(options = {}) {
310
357
  return { page, limit, order };
311
358
  }
312
359
 
360
+ /**
361
+ * Normalize the bounded list pagination contract. Project/session lists are
362
+ * intentionally capped at 100 even when a caller supplies a larger limit.
363
+ */
364
+ function _normalizeListOpts(options = {}) {
365
+ const page = Math.max(1, Number.parseInt(options.page, 10) || 1);
366
+ const limit = Math.max(1, Math.min(100, Number.parseInt(options.limit, 10) || 20));
367
+ const query = String(options.q ?? options.search ?? '').trim();
368
+ return { page, limit, query };
369
+ }
370
+
313
371
  /**
314
372
  * Double embedded quotes in a string for FTS5 MATCH.
315
373
  * @param {string} s
@@ -319,6 +377,13 @@ function _ftsQuote(s) {
319
377
  return '"' + String(s).replace(/"/g, '""') + '"';
320
378
  }
321
379
 
380
+ function _timestampValue(value) {
381
+ const numeric = Number(value);
382
+ if (Number.isFinite(numeric) && numeric > 0) return numeric;
383
+ const parsed = Date.parse(String(value || ''));
384
+ return Number.isFinite(parsed) ? parsed : 0;
385
+ }
386
+
322
387
  function _normalizeTypedSessionsResult(result, defaultOperation = 'resolve-driver') {
323
388
  if (!result || typeof result !== 'object') {
324
389
  return null;
@@ -478,7 +543,39 @@ function _adaptRuntimeSessionsDriver(driver) {
478
543
  await unwrap(await driver.parse(descriptor, ...args)),
479
544
  descriptor,
480
545
  { preservePayloadUpdatedAt: driver.preservePayloadUpdatedAt === true }
481
- )
546
+ ),
547
+ summarize: typeof driver.summarize === 'function'
548
+ ? async (descriptor, ...args) => _normalizeRuntimeSummaryResult(
549
+ await unwrap(await driver.summarize(descriptor, ...args)),
550
+ descriptor
551
+ )
552
+ : null
553
+ };
554
+ }
555
+
556
+ function _normalizeRuntimeSummaryResult(result, descriptor = {}) {
557
+ if (!result || typeof result !== 'object') return null;
558
+ const source = result.session && typeof result.session === 'object' ? result.session : result;
559
+ if (!source.sessionId && !descriptor.sessionId) return null;
560
+ const projectHint = descriptor.projectHint || source.projectHint || source.projectName || '';
561
+ const projectName = source.projectName || projectHint || 'unknown';
562
+ const extra = _safeParseObject(source.extraJson);
563
+ return {
564
+ sessionId: String(source.sessionId || descriptor.sessionId),
565
+ projectName,
566
+ projectDisplayName: source.projectDisplayName || projectName,
567
+ projectFullPath: source.projectFullPath || source.projectPath || null,
568
+ firstMessage: source.firstMessage || null,
569
+ gitBranch: source.gitBranch || null,
570
+ provider: source.provider || null,
571
+ model: source.model || null,
572
+ startedAt: source.startedAt || null,
573
+ updatedAt: source.updatedAt ?? descriptor.mtimeMs ?? null,
574
+ extraJson: JSON.stringify({
575
+ ...extra,
576
+ projectHint: projectHint || null,
577
+ mtimeMs: descriptor.mtimeMs ?? null
578
+ })
482
579
  };
483
580
  }
484
581
  // ---------------------------------------------------------------------------
@@ -513,6 +610,10 @@ function createSessionHistoryIndex(opts = {}) {
513
610
  let _ftsAvailable = null;
514
611
  /** @type {Map<string, Promise<void>>} */
515
612
  const _inflight = new Map();
613
+ /** @type {Map<string, Promise<void>>} */
614
+ const _summaryInflight = new Map();
615
+ /** @type {Map<string, Promise<void>>} */
616
+ const _contentInflight = new Map();
516
617
 
517
618
  function _getDb() {
518
619
  if (_db) return _db;
@@ -535,6 +636,29 @@ function createSessionHistoryIndex(opts = {}) {
535
636
  return _parserVersionForDriver(runtimeDriver);
536
637
  }
537
638
 
639
+ function _resolveSessionsAdapter(source, config = indexConfig) {
640
+ let adapter = null;
641
+ if (explicitAdapters) {
642
+ adapter = adapters[source];
643
+ } else {
644
+ const runtimeDriver = _getRuntimeSessionsDriver(runtime, source, config);
645
+ if (_isTypedSessionsResult(runtimeDriver)) {
646
+ throw _typedFailureToError(runtimeDriver);
647
+ }
648
+ if (runtimeDriver) {
649
+ adapter = _adaptRuntimeSessionsDriver(runtimeDriver);
650
+ } else if (BUILTIN_SESSION_SOURCES.has(source) && adapters[source]) {
651
+ adapter = adapters[source];
652
+ } else {
653
+ throw _typedFailureToError(_typedSessionsUnsupported(
654
+ source,
655
+ new Error(`unsupported sessions source: ${source}`)
656
+ ));
657
+ }
658
+ }
659
+ return adapter;
660
+ }
661
+
538
662
  /**
539
663
  * Check only the persisted inventory timestamp. Inventory owns file checks.
540
664
  *
@@ -542,15 +666,37 @@ function createSessionHistoryIndex(opts = {}) {
542
666
  * @returns {boolean}
543
667
  */
544
668
  function _isSourceFresh(source) {
669
+ return _isSourceFreshForScope(source, null);
670
+ }
671
+
672
+ function _isSourceFreshForScope(source, projectName = null) {
545
673
  const row = _getDb().prepare(
546
674
  'SELECT last_inventory_ms FROM source_state WHERE source = ?'
547
675
  ).get(source);
548
- return Boolean(
676
+ const migrationPending = _hasKnownParserMigrationPending(source)
677
+ || (explicitAdapters && _hasParserMigrationPending(source));
678
+ const baseFresh = Boolean(
549
679
  row?.last_inventory_ms
550
680
  && Date.now() - Number(row.last_inventory_ms) < INDEX_INVENTORY_TTL_MS
551
- && !_hasKnownParserMigrationPending(source)
552
- && (!explicitAdapters || !_hasParserMigrationPending(source))
681
+ && !migrationPending
553
682
  );
683
+
684
+ const scope = projectName
685
+ ? sourceFreshness.get(`${source}:${projectName}`)
686
+ : sourceFreshness.get(source);
687
+ if (projectName) {
688
+ // A complete source inventory also covers project-scoped searches. A
689
+ // project-only refresh has no persisted source timestamp, so rely on
690
+ // its in-process scope marker until the normal inventory TTL expires.
691
+ if (baseFresh && scope?.kind === 'source') return true;
692
+ return scope?.kind === 'project'
693
+ && scope.projectName === projectName
694
+ && Date.now() - scope.at < INDEX_INVENTORY_TTL_MS;
695
+ }
696
+ if (!baseFresh) return false;
697
+ // A project-scoped refresh deliberately does not make the complete source
698
+ // fresh. Otherwise a later workspace search could reuse a partial index.
699
+ return !scope || scope.kind === 'source';
554
700
  }
555
701
 
556
702
  function _hasKnownParserMigrationPending(source) {
@@ -586,10 +732,31 @@ function createSessionHistoryIndex(opts = {}) {
586
732
  function _initSchema(db) {
587
733
  db.exec('PRAGMA journal_mode = WAL');
588
734
  db.exec(SCHEMA_SQL);
589
- const columns = db.prepare('PRAGMA table_info(session_file)').all();
590
- if (!columns.some((column) => column.name === 'parser_version')) {
591
- db.exec('ALTER TABLE session_file ADD COLUMN parser_version INTEGER NOT NULL DEFAULT 0');
592
- }
735
+ const ensureColumn = (table, name, definition) => {
736
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all();
737
+ if (!columns.some((column) => column.name === name)) {
738
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
739
+ }
740
+ };
741
+ ensureColumn('source_state', 'summary_inventory_ms', 'INTEGER');
742
+ ensureColumn('source_state', 'summary_error', 'TEXT');
743
+ ensureColumn('session_file', 'parser_version', 'INTEGER NOT NULL DEFAULT 0');
744
+ ensureColumn('session_file', 'content_indexed', 'INTEGER NOT NULL DEFAULT 1');
745
+
746
+ // The first migration is deliberately a metadata-only copy. It reuses
747
+ // rows already present in session_file and never touches JSONL files or
748
+ // the message/FTS tables.
749
+ db.exec(`
750
+ INSERT OR IGNORE INTO session_summary(
751
+ source, file_path, session_id, project_name, project_display_name,
752
+ project_full_path, first_message, git_branch, provider, model,
753
+ size, mtime_ms, started_at, updated_at, summary_version, extra_json
754
+ )
755
+ SELECT source, file_path, session_id, project_name, project_display_name,
756
+ project_full_path, first_message, git_branch, provider, model,
757
+ size, mtime_ms, started_at, updated_at, ${SUMMARY_VERSION}, extra_json
758
+ FROM session_file
759
+ `);
593
760
  }
594
761
 
595
762
  function _initFts(db) {
@@ -600,6 +767,234 @@ function createSessionHistoryIndex(opts = {}) {
600
767
  }
601
768
  }
602
769
 
770
+ function _isSummaryFresh(source) {
771
+ const row = _getDb().prepare(
772
+ 'SELECT summary_inventory_ms FROM source_state WHERE source = ?'
773
+ ).get(source);
774
+ return Boolean(
775
+ row?.summary_inventory_ms
776
+ && Date.now() - Number(row.summary_inventory_ms) < INDEX_INVENTORY_TTL_MS
777
+ );
778
+ }
779
+
780
+ function _hasSummaryData(source) {
781
+ return Boolean(_getDb().prepare(
782
+ 'SELECT 1 AS indexed FROM session_summary WHERE source = ? LIMIT 1'
783
+ ).get(source));
784
+ }
785
+
786
+ function _summaryFromDescriptor(descriptor = {}) {
787
+ const projectName = descriptor.projectHint || descriptor.projectName || 'unknown';
788
+ return {
789
+ sessionId: String(descriptor.sessionId || ''),
790
+ projectName,
791
+ projectDisplayName: descriptor.projectDisplayName || projectName,
792
+ projectFullPath: descriptor.projectFullPath || descriptor.projectPath || null,
793
+ firstMessage: descriptor.firstMessage || null,
794
+ gitBranch: descriptor.gitBranch || null,
795
+ provider: descriptor.provider || null,
796
+ model: descriptor.model || null,
797
+ startedAt: descriptor.startedAt || null,
798
+ updatedAt: descriptor.updatedAt ?? descriptor.mtimeMs ?? null,
799
+ extraJson: JSON.stringify({ projectHint: descriptor.projectHint || null, mtimeMs: descriptor.mtimeMs ?? null })
800
+ };
801
+ }
802
+
803
+ async function _summarizeDescriptor(adapter, descriptor) {
804
+ if (typeof adapter?.summarize !== 'function') {
805
+ return _summaryFromDescriptor(descriptor);
806
+ }
807
+ const result = await adapter.summarize({ ...descriptor, projectsDir: explicitProjectsDir });
808
+ if (_isTypedFailureResult(result)) throw _typedFailureToError(result);
809
+ const summary = _normalizeRuntimeSummaryResult(result, descriptor) || _summaryFromDescriptor(descriptor);
810
+ if (!summary.sessionId) summary.sessionId = String(descriptor.sessionId || '');
811
+ return summary;
812
+ }
813
+
814
+ function _recordSummaryState(db, source, inventoryMs, error) {
815
+ db.prepare(`
816
+ INSERT INTO source_state(source, summary_inventory_ms, summary_error)
817
+ VALUES(?, ?, ?)
818
+ ON CONFLICT(source) DO UPDATE SET
819
+ summary_inventory_ms = excluded.summary_inventory_ms,
820
+ summary_error = excluded.summary_error
821
+ `).run(source, inventoryMs, error || null);
822
+ }
823
+
824
+ function _upsertSummary(db, source, descriptor, summary) {
825
+ const sessionId = String(summary.sessionId || descriptor.sessionId || '');
826
+ if (!sessionId) return;
827
+ db.prepare(
828
+ 'DELETE FROM session_summary WHERE source = ? AND session_id = ? AND file_path <> ?'
829
+ ).run(source, sessionId, descriptor.filePath);
830
+ db.prepare(`
831
+ INSERT INTO session_summary(
832
+ source, file_path, session_id, project_name, project_display_name,
833
+ project_full_path, first_message, git_branch, provider, model,
834
+ size, mtime_ms, started_at, updated_at, summary_version, extra_json
835
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
836
+ ON CONFLICT(source, file_path) DO UPDATE SET
837
+ session_id = excluded.session_id,
838
+ project_name = excluded.project_name,
839
+ project_display_name = excluded.project_display_name,
840
+ project_full_path = excluded.project_full_path,
841
+ first_message = excluded.first_message,
842
+ git_branch = excluded.git_branch,
843
+ provider = excluded.provider,
844
+ model = excluded.model,
845
+ size = excluded.size,
846
+ mtime_ms = excluded.mtime_ms,
847
+ started_at = excluded.started_at,
848
+ updated_at = excluded.updated_at,
849
+ summary_version = excluded.summary_version,
850
+ extra_json = excluded.extra_json
851
+ `).run(
852
+ source,
853
+ descriptor.filePath,
854
+ sessionId,
855
+ summary.projectName || 'unknown',
856
+ summary.projectDisplayName || summary.projectName || 'unknown',
857
+ summary.projectFullPath || null,
858
+ summary.firstMessage || null,
859
+ summary.gitBranch || null,
860
+ summary.provider || null,
861
+ summary.model || null,
862
+ Number(descriptor.size) || 0,
863
+ Number(descriptor.mtimeMs) || 0,
864
+ summary.startedAt || null,
865
+ summary.updatedAt ?? descriptor.mtimeMs ?? null,
866
+ SUMMARY_VERSION,
867
+ summary.extraJson || null
868
+ );
869
+ }
870
+
871
+ async function _runSummaryInventory(source, { force = false, config = indexConfig } = {}) {
872
+ // A list request must never wait behind the expensive full-content
873
+ // inventory lock. It can serve the previous summaries and expose the
874
+ // refreshing/stale state instead.
875
+ const inventoryLock = await _acquireInventoryLock(dbPath, source, 100);
876
+ if (!inventoryLock) return;
877
+ let db = null;
878
+ let errorMsg = null;
879
+ let stateToRecord = null;
880
+ try {
881
+ db = _getDb();
882
+ if (!force && _isSummaryFresh(source)) return;
883
+ const adapter = _resolveSessionsAdapter(source, config);
884
+ const inventoryResult = await adapter.inventory({
885
+ projectsDir: source === 'claude' ? config.projectsDir : explicitProjectsDir
886
+ });
887
+ if (_isTypedFailureResult(inventoryResult)) throw _typedFailureToError(inventoryResult);
888
+
889
+ const winners = new Map();
890
+ for (const descriptor of Array.isArray(inventoryResult) ? inventoryResult : []) {
891
+ if (!descriptor?.filePath) continue;
892
+ const current = winners.get(descriptor.sessionId || descriptor.filePath);
893
+ if (!current
894
+ || Number(descriptor.mtimeMs) > Number(current.mtimeMs)
895
+ || (Number(descriptor.mtimeMs) === Number(current.mtimeMs) && descriptor.filePath < current.filePath)) {
896
+ winners.set(descriptor.sessionId || descriptor.filePath, descriptor);
897
+ }
898
+ }
899
+
900
+ const indexed = new Map(db.prepare(`
901
+ SELECT file_path, size, mtime_ms, summary_version, session_id
902
+ FROM session_summary WHERE source = ?
903
+ `).all(source).map(row => [row.file_path, row]));
904
+ const activePaths = new Set();
905
+ const changed = [];
906
+ for (const descriptor of winners.values()) {
907
+ activePaths.add(descriptor.filePath);
908
+ const row = indexed.get(descriptor.filePath);
909
+ if (!row
910
+ || Number(row.size) !== Number(descriptor.size)
911
+ || Number(row.mtime_ms) !== Number(descriptor.mtimeMs)
912
+ || Number(row.summary_version) !== SUMMARY_VERSION) {
913
+ changed.push(descriptor);
914
+ }
915
+ }
916
+
917
+ const summaries = new Array(changed.length);
918
+ let next = 0;
919
+ const summarizeWorker = async () => {
920
+ while (true) {
921
+ const index = next++;
922
+ if (index >= changed.length) return;
923
+ const descriptor = changed[index];
924
+ try {
925
+ summaries[index] = { descriptor, value: await _summarizeDescriptor(adapter, descriptor) };
926
+ } catch (error) {
927
+ summaries[index] = { descriptor, error };
928
+ }
929
+ }
930
+ };
931
+ await Promise.all(Array.from({
932
+ length: Math.min(SUMMARY_REFRESH_CONCURRENCY, changed.length)
933
+ }, summarizeWorker));
934
+
935
+ db.exec('BEGIN IMMEDIATE');
936
+ try {
937
+ for (const row of indexed.values()) {
938
+ if (!activePaths.has(row.file_path)) {
939
+ db.prepare('DELETE FROM session_summary WHERE source = ? AND file_path = ?')
940
+ .run(source, row.file_path);
941
+ }
942
+ }
943
+ for (const item of summaries) {
944
+ if (item?.error) {
945
+ const detail = `${item.descriptor.filePath}: ${item.error.message}`;
946
+ errorMsg = errorMsg ? `${errorMsg}; ${detail}` : detail;
947
+ continue;
948
+ }
949
+ if (item?.value) _upsertSummary(db, source, item.descriptor, item.value);
950
+ }
951
+ db.exec('COMMIT');
952
+ } catch (error) {
953
+ try { db.exec('ROLLBACK'); } catch (_) {}
954
+ throw error;
955
+ }
956
+ stateToRecord = { inventoryMs: Date.now(), error: errorMsg };
957
+ } catch (error) {
958
+ errorMsg = error?.message || String(error);
959
+ stateToRecord = { inventoryMs: null, error: errorMsg };
960
+ // Summary refresh is best-effort. Keep the previous lightweight rows
961
+ // available when a directory changes underneath inventory (or SQLite
962
+ // is temporarily busy); callers can inspect source meta for stale/error
963
+ // state and retry on the next refresh.
964
+ return null;
965
+ } finally {
966
+ if (stateToRecord && db) {
967
+ try { _recordSummaryState(db, source, stateToRecord.inventoryMs, stateToRecord.error); } catch (_) {}
968
+ }
969
+ _releaseInventoryLock(inventoryLock);
970
+ }
971
+ }
972
+
973
+ async function ensureSummaryIndexed(source, options = {}) {
974
+ const consistency = options.consistency || 'stale-ok';
975
+ const force = options.force === true;
976
+ const key = `summary:${source}`;
977
+ if (!force && _isSummaryFresh(source)) return;
978
+ if (_summaryInflight.has(key)) {
979
+ if (consistency === 'complete' || !_hasSummaryData(source)) return _summaryInflight.get(key);
980
+ return;
981
+ }
982
+ const promise = _runSummaryInventory(source, {
983
+ force,
984
+ config: options.config || indexConfig
985
+ });
986
+ _summaryInflight.set(key, promise);
987
+ promise.finally(() => {
988
+ if (_summaryInflight.get(key) === promise) _summaryInflight.delete(key);
989
+ }).catch(() => {});
990
+ if (consistency === 'complete') return promise;
991
+ if (_hasSummaryData(source)) return;
992
+ await Promise.race([
993
+ promise,
994
+ new Promise(resolve => setTimeout(resolve, INDEX_COLD_WAIT_MS))
995
+ ]);
996
+ }
997
+
603
998
  /**
604
999
  * @param {string} source
605
1000
  * @param {{ force?: boolean, consistency?: string, allowStaleData?: boolean }} [options]
@@ -609,9 +1004,12 @@ function createSessionHistoryIndex(opts = {}) {
609
1004
  const consistency = options.consistency || 'stale-ok';
610
1005
  const force = options.force === true;
611
1006
  const allowStaleData = options.allowStaleData === true;
612
- const key = `ensure:${source}`;
1007
+ const projectName = typeof options.projectName === 'string' && options.projectName.trim()
1008
+ ? options.projectName.trim()
1009
+ : null;
1010
+ const key = `ensure:${source}:${projectName || '*'}`;
613
1011
 
614
- if (!force && _isSourceFresh(source)) {
1012
+ if (!force && _isSourceFreshForScope(source, projectName)) {
615
1013
  return;
616
1014
  }
617
1015
 
@@ -630,13 +1028,23 @@ function createSessionHistoryIndex(opts = {}) {
630
1028
 
631
1029
  const useWorker = shouldUseWorker && !runtimeProvided && !explicitAdapters;
632
1030
  const workerOptions = { force };
1031
+ if (projectName) workerOptions.projectName = projectName;
633
1032
  const workerProjectsDir = source === 'claude'
634
1033
  ? (options.config?.projectsDir || explicitProjectsDir)
635
1034
  : explicitProjectsDir;
636
1035
  if (workerProjectsDir) workerOptions.projectsDir = workerProjectsDir;
637
1036
  const promise = useWorker
638
- ? workerRunner(source, dbPath, workerOptions)
639
- : _runInventory(source, { force, config: options.config || indexConfig });
1037
+ ? workerRunner(source, dbPath, workerOptions).then(() => {
1038
+ sourceFreshness.set(projectName ? `${source}:${projectName}` : source, projectName
1039
+ ? { kind: 'project', projectName, at: Date.now() }
1040
+ : { kind: 'source', at: Date.now() });
1041
+ return undefined;
1042
+ })
1043
+ : _runInventory(source, {
1044
+ force,
1045
+ projectName,
1046
+ config: options.config || indexConfig
1047
+ });
640
1048
  _inflight.set(key, promise);
641
1049
  promise.finally(() => {
642
1050
  if (_inflight.get(key) === promise) {
@@ -683,7 +1091,7 @@ function createSessionHistoryIndex(opts = {}) {
683
1091
  ).run(source, lastInventoryMs, lastError);
684
1092
  }
685
1093
 
686
- async function _runInventory(source, { force = false, config = indexConfig } = {}) {
1094
+ async function _runInventory(source, { force = false, projectName = null, config = indexConfig } = {}) {
687
1095
  // SQLite's writer lock is database-wide, so source-specific lock files do
688
1096
  // not prevent two different sources from entering BEGIN IMMEDIATE at the
689
1097
  // same time. Acquire one lock for the whole index before opening/schema
@@ -701,7 +1109,7 @@ function createSessionHistoryIndex(opts = {}) {
701
1109
 
702
1110
  try {
703
1111
  db = _getDb();
704
- if (!force && _isSourceFresh(source)) {
1112
+ if (!force && _isSourceFreshForScope(source, projectName)) {
705
1113
  return;
706
1114
  }
707
1115
  let adapter = null;
@@ -731,17 +1139,21 @@ function createSessionHistoryIndex(opts = {}) {
731
1139
  }
732
1140
 
733
1141
  const indexedFiles = new Map();
734
- for (const row of db.prepare('SELECT file_path, size, mtime_ms, session_id, parser_version FROM session_file WHERE source = ?').all(source)) {
1142
+ for (const row of db.prepare('SELECT file_path, size, mtime_ms, session_id, project_name, parser_version FROM session_file WHERE source = ?').all(source)) {
735
1143
  indexedFiles.set(row.file_path, {
736
1144
  size: row.size,
737
1145
  mtime_ms: row.mtime_ms,
738
1146
  sessionId: row.session_id,
1147
+ projectName: row.project_name,
739
1148
  parserVersion: row.parser_version
740
1149
  });
741
1150
  }
742
1151
 
743
1152
  const winnersBySessionId = new Map();
744
- for (const descriptor of inventoryResult) {
1153
+ const scopedInventory = projectName
1154
+ ? inventoryResult.filter(descriptor => descriptor?.projectHint === projectName)
1155
+ : inventoryResult;
1156
+ for (const descriptor of scopedInventory) {
745
1157
  const current = winnersBySessionId.get(descriptor.sessionId);
746
1158
  if (!current
747
1159
  || descriptor.mtimeMs > current.mtimeMs
@@ -752,7 +1164,14 @@ function createSessionHistoryIndex(opts = {}) {
752
1164
 
753
1165
  const toParse = [];
754
1166
  const activePaths = new Set();
755
- for (const d of winnersBySessionId.values()) {
1167
+ // Inventory adapters may return directory order. Parse changed files in
1168
+ // recency order so a bounded search warms the newest conversations
1169
+ // first, matching the result ordering used by the query layer.
1170
+ const orderedDescriptors = [...winnersBySessionId.values()].sort((left, right) =>
1171
+ Number(right.mtimeMs || 0) - Number(left.mtimeMs || 0)
1172
+ || String(left.filePath).localeCompare(String(right.filePath))
1173
+ );
1174
+ for (const d of orderedDescriptors) {
756
1175
  activePaths.add(d.filePath);
757
1176
  const idx = indexedFiles.get(d.filePath);
758
1177
  if (!idx
@@ -819,11 +1238,15 @@ function createSessionHistoryIndex(opts = {}) {
819
1238
  content, timestamp, model, provider, user_message_number, extra_json
820
1239
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
821
1240
  `);
1241
+ const statements = { deletePath, deleteSession, insertFile, insertMessage, db };
822
1242
 
823
1243
  db.exec('BEGIN IMMEDIATE');
824
1244
  try {
825
- for (const filePath of indexedFiles.keys()) {
826
- if (!activePaths.has(filePath)) deletePath.run(source, filePath);
1245
+ for (const [filePath, row] of indexedFiles.entries()) {
1246
+ if ((!projectName || row.projectName === projectName) && !activePaths.has(filePath)) {
1247
+ deletePath.run(source, filePath);
1248
+ db.prepare('DELETE FROM session_summary WHERE source = ? AND file_path = ?').run(source, filePath);
1249
+ }
827
1250
  }
828
1251
  db.exec('COMMIT');
829
1252
  } catch (error) {
@@ -863,7 +1286,7 @@ function createSessionHistoryIndex(opts = {}) {
863
1286
  for (const item of parsed) {
864
1287
  if (item?.value) {
865
1288
  const { descriptor, parseResult } = item.value;
866
- _insertSession({ deletePath, deleteSession, insertFile, insertMessage }, source, descriptor, parseResult.session, parseResult.messages, parserVersion);
1289
+ _insertSession(statements, source, descriptor, parseResult.session, parseResult.messages, parserVersion);
867
1290
  }
868
1291
  }
869
1292
  db.exec('COMMIT');
@@ -877,9 +1300,12 @@ function createSessionHistoryIndex(opts = {}) {
877
1300
  }
878
1301
 
879
1302
  stateToRecord = {
880
- lastInventoryMs: Date.now(),
1303
+ lastInventoryMs: projectName ? null : Date.now(),
881
1304
  lastError: errorMsg
882
1305
  };
1306
+ sourceFreshness.set(projectName ? `${source}:${projectName}` : source, projectName
1307
+ ? { kind: 'project', projectName, at: Date.now() }
1308
+ : { kind: 'source', at: Date.now() });
883
1309
  } catch (err) {
884
1310
  errorMsg = err && err.message ? err.message : String(err);
885
1311
  stateToRecord = {
@@ -890,7 +1316,7 @@ function createSessionHistoryIndex(opts = {}) {
890
1316
  } finally {
891
1317
  if (stateToRecord) {
892
1318
  try {
893
- if (db) {
1319
+ if (db && !projectName) {
894
1320
  _recordSourceState(db, source, stateToRecord.lastInventoryMs, stateToRecord.lastError);
895
1321
  }
896
1322
  } catch (_err) {}
@@ -924,6 +1350,7 @@ function createSessionHistoryIndex(opts = {}) {
924
1350
  session.extraJson || null,
925
1351
  parserVersion
926
1352
  );
1353
+ _upsertSummary(statements.db || _getDb(), source, descriptor, session);
927
1354
 
928
1355
  let ordinal = 0;
929
1356
  for (const msg of messages) {
@@ -950,89 +1377,311 @@ function createSessionHistoryIndex(opts = {}) {
950
1377
 
951
1378
  // ---- Public query methods ----
952
1379
 
953
- /**
954
- * @param {string} source
955
- * @param {object} [options]
956
- * @returns {Promise<Array<{name, displayName, fullPath, path, sessionCount, lastUsed, latestSession, source}>>}
957
- */
958
- async function listProjects(source, options = {}) {
959
- await ensureSourceIndexed(source, {
1380
+ function _projectRowsToPayload(rows, source, page, limit, total) {
1381
+ return {
1382
+ projects: rows.map(r => ({
1383
+ name: r.project_name,
1384
+ displayName: r.project_display_name || r.project_name,
1385
+ fullPath: r.project_full_path || '',
1386
+ path: r.project_full_path || '',
1387
+ sessionCount: Number(r.session_count) || 0,
1388
+ lastUsed: r.last_used,
1389
+ latestSession: r.latest_session,
1390
+ source
1391
+ })),
1392
+ currentProject: null,
1393
+ pagination: {
1394
+ page,
1395
+ limit,
1396
+ total,
1397
+ hasMore: page * limit < total
1398
+ }
1399
+ };
1400
+ }
1401
+
1402
+ function _sessionRowToSummary(r) {
1403
+ const extra = _safeParseObject(r.extra_json);
1404
+ return {
1405
+ sessionId: r.session_id,
1406
+ filePath: r.file_path,
1407
+ size: Number(r.size) || 0,
1408
+ mtime: r.mtime_ms,
1409
+ firstMessage: r.first_message,
1410
+ gitBranch: r.git_branch,
1411
+ provider: r.provider,
1412
+ model: r.model,
1413
+ messageCount: r.message_count == null ? null : Number(r.message_count),
1414
+ tokens: _safeParseJson(r.usage_json, null),
1415
+ extra,
1416
+ source: r.source,
1417
+ projectName: r.project_name,
1418
+ projectHint: extra.projectHint || r.project_name,
1419
+ projectDisplayName: r.project_display_name,
1420
+ projectFullPath: r.project_full_path,
1421
+ startedAt: r.started_at,
1422
+ updatedAt: r.updated_at
1423
+ };
1424
+ }
1425
+
1426
+ async function listProjectsPage(source, options = {}) {
1427
+ await ensureSummaryIndexed(source, {
960
1428
  force: options.force === true,
961
1429
  consistency: options.consistency || 'stale-ok',
962
- allowStaleData: true,
963
1430
  config: options.config
964
1431
  });
965
1432
  const db = _getDb();
966
-
1433
+ const { page, limit, query } = _normalizeListOpts(options);
1434
+ const pattern = `%${query.replace(/[%_]/g, ch => `\\${ch}`)}%`;
1435
+ const queryClause = query
1436
+ ? `AND (project_name LIKE ? ESCAPE '\\' OR project_display_name LIKE ? ESCAPE '\\' OR project_full_path LIKE ? ESCAPE '\\')`
1437
+ : '';
1438
+ const queryParams = query ? [pattern, pattern, pattern] : [];
1439
+ const total = Number(db.prepare(`
1440
+ SELECT COUNT(*) AS total FROM (
1441
+ SELECT project_name FROM session_summary
1442
+ WHERE source = ? ${queryClause}
1443
+ GROUP BY project_name
1444
+ )
1445
+ `).get(source, ...queryParams)?.total) || 0;
967
1446
  const rows = db.prepare(`
968
- SELECT project_name, project_display_name, project_full_path,
1447
+ SELECT project_name, MAX(project_display_name) AS project_display_name,
1448
+ MAX(project_full_path) AS project_full_path,
969
1449
  COUNT(*) AS session_count,
970
1450
  MAX(updated_at) AS last_used,
971
- (SELECT session_id FROM session_file sf2
1451
+ (SELECT session_id FROM session_summary sf2
972
1452
  WHERE sf2.source = ? AND sf2.project_name = sf.project_name
973
- ORDER BY updated_at DESC LIMIT 1) AS latest_session
974
- FROM session_file sf
975
- WHERE source = ?
1453
+ ORDER BY updated_at DESC, session_id ASC LIMIT 1) AS latest_session
1454
+ FROM session_summary sf
1455
+ WHERE source = ? ${queryClause}
976
1456
  GROUP BY project_name
977
- ORDER BY last_used DESC
978
- `).all(source, source);
979
-
980
- return rows.map(r => ({
981
- name: r.project_name,
982
- displayName: r.project_display_name || r.project_name,
983
- fullPath: r.project_full_path || '',
984
- path: r.project_full_path || '',
985
- sessionCount: r.session_count,
986
- lastUsed: r.last_used,
987
- latestSession: r.latest_session,
988
- source
1457
+ ORDER BY last_used DESC, project_name ASC
1458
+ LIMIT ? OFFSET ?
1459
+ `).all(source, source, ...queryParams, limit, (page - 1) * limit);
1460
+ return _projectRowsToPayload(rows, source, page, limit, total);
1461
+ }
1462
+
1463
+ async function getAllProjects(source, options = {}) {
1464
+ const projects = [];
1465
+ let page = 1;
1466
+ const limit = 100;
1467
+ while (true) {
1468
+ const payload = await listProjectsPage(source, {
1469
+ ...options,
1470
+ page,
1471
+ limit,
1472
+ // A forced refresh is only needed once. Repeating it for every page
1473
+ // would serialize needless metadata scans.
1474
+ force: page === 1 ? options.force === true : false
1475
+ });
1476
+ projects.push(...(Array.isArray(payload.projects) ? payload.projects : []));
1477
+ if (!payload.pagination?.hasMore) break;
1478
+ page += 1;
1479
+ }
1480
+ return projects;
1481
+ }
1482
+
1483
+ async function listProjects(source, options = {}) {
1484
+ return getAllProjects(source, options);
1485
+ }
1486
+
1487
+ async function listSessionsPage(source, projectName, options = {}) {
1488
+ await ensureSummaryIndexed(source, {
1489
+ force: options.force === true,
1490
+ consistency: options.consistency || 'stale-ok',
1491
+ config: options.config
1492
+ });
1493
+ const db = _getDb();
1494
+ const { page, limit, query } = _normalizeListOpts(options);
1495
+ const pattern = `%${query.replace(/[%_]/g, ch => `\\${ch}`)}%`;
1496
+ const queryClause = query
1497
+ ? `AND (session_id LIKE ? ESCAPE '\\' OR first_message LIKE ? ESCAPE '\\' OR git_branch LIKE ? ESCAPE '\\')`
1498
+ : '';
1499
+ const queryParams = query ? [pattern, pattern, pattern] : [];
1500
+ const countRow = db.prepare(`
1501
+ SELECT COUNT(*) AS total, COALESCE(SUM(size), 0) AS total_size
1502
+ FROM session_summary
1503
+ WHERE source = ? AND project_name = ? ${queryClause}
1504
+ `).get(source, projectName, ...queryParams);
1505
+ const total = Number(countRow?.total) || 0;
1506
+ const totalSize = Number(countRow?.total_size) || 0;
1507
+ const rows = db.prepare(`
1508
+ SELECT s.*,
1509
+ f.message_count AS content_message_count,
1510
+ f.usage_json AS content_usage_json,
1511
+ f.extra_json AS content_extra_json
1512
+ FROM session_summary s
1513
+ LEFT JOIN session_file f ON f.source = s.source AND f.session_id = s.session_id
1514
+ WHERE s.source = ? AND s.project_name = ? ${queryClause}
1515
+ ORDER BY s.updated_at DESC, s.session_id ASC
1516
+ LIMIT ? OFFSET ?
1517
+ `).all(source, projectName, ...queryParams, limit, (page - 1) * limit);
1518
+ const sessions = rows.map(r => _sessionRowToSummary({
1519
+ ...r,
1520
+ session_id: r.session_id,
1521
+ file_path: r.file_path,
1522
+ size: r.size,
1523
+ mtime_ms: r.mtime_ms,
1524
+ first_message: r.first_message,
1525
+ git_branch: r.git_branch,
1526
+ provider: r.provider,
1527
+ model: r.model,
1528
+ project_name: r.project_name,
1529
+ project_display_name: r.project_display_name,
1530
+ project_full_path: r.project_full_path,
1531
+ started_at: r.started_at,
1532
+ updated_at: r.updated_at,
1533
+ source: r.source,
1534
+ extra_json: r.content_extra_json || r.extra_json,
1535
+ message_count: r.content_message_count,
1536
+ usage_json: r.content_usage_json
989
1537
  }));
1538
+ return {
1539
+ sessions,
1540
+ totalSize,
1541
+ projectInfo: { sessionCount: total, totalSize },
1542
+ pagination: { page, limit, total, hasMore: page * limit < total }
1543
+ };
990
1544
  }
991
1545
 
992
- /**
993
- * @param {string} source
994
- * @param {string} projectName
995
- * @param {object} [options]
996
- * @returns {Promise<Array>}
997
- */
998
1546
  async function listSessions(source, projectName, options = {}) {
1547
+ // Keep the historical internal API stable for commands and callers that
1548
+ // explicitly request the complete index. HTTP list routes use
1549
+ // listSessionsPage above and therefore never enter this path.
999
1550
  await ensureSourceIndexed(source, {
1000
1551
  force: options.force === true,
1001
1552
  consistency: options.consistency || 'stale-ok',
1002
1553
  config: options.config
1003
1554
  });
1004
-
1005
1555
  const db = _getDb();
1006
1556
  const rows = db.prepare(`
1007
1557
  SELECT * FROM session_file
1008
1558
  WHERE source = ? AND project_name = ?
1009
- ORDER BY updated_at DESC
1559
+ ORDER BY updated_at DESC, session_id ASC
1010
1560
  `).all(source, projectName);
1561
+ return rows.map(_sessionRowToSummary);
1562
+ }
1011
1563
 
1012
- return rows.map(r => {
1013
- const extra = _safeParseObject(r.extra_json);
1564
+ async function _parseSingleSessionContent(source, summaryRow, config = indexConfig) {
1565
+ const adapter = _resolveSessionsAdapter(source, config);
1566
+ const descriptor = {
1567
+ filePath: summaryRow.file_path,
1568
+ size: Number(summaryRow.size) || 0,
1569
+ mtimeMs: Number(summaryRow.mtime_ms) || 0,
1570
+ sessionId: summaryRow.session_id,
1571
+ projectHint: summaryRow.project_name,
1572
+ projectsDir: source === 'claude' ? config.projectsDir : explicitProjectsDir
1573
+ };
1574
+ const parseResult = await adapter.parse(descriptor);
1575
+ if (_isTypedFailureResult(parseResult)) throw _typedFailureToError(parseResult);
1576
+ if (!parseResult?.session || typeof parseResult.session !== 'object'
1577
+ || typeof parseResult.session.sessionId !== 'string'
1578
+ || !parseResult.session.sessionId.trim()
1579
+ || !Array.isArray(parseResult.messages)) {
1580
+ throw new Error('invalid parsed session result');
1581
+ }
1582
+ return { descriptor, parseResult };
1583
+ }
1014
1584
 
1015
- return {
1016
- sessionId: r.session_id,
1017
- filePath: r.file_path,
1018
- size: r.size,
1019
- mtime: r.mtime_ms,
1020
- firstMessage: r.first_message,
1021
- gitBranch: r.git_branch,
1022
- provider: r.provider,
1023
- model: r.model,
1024
- messageCount: r.message_count,
1025
- tokens: _safeParseJson(r.usage_json, null),
1026
- extra,
1027
- source: r.source,
1028
- projectName: r.project_name,
1029
- projectHint: extra.projectHint || r.project_name,
1030
- projectDisplayName: r.project_display_name,
1031
- projectFullPath: r.project_full_path,
1032
- startedAt: r.started_at,
1033
- updatedAt: r.updated_at
1034
- };
1035
- });
1585
+ /**
1586
+ * Parse and index exactly one file for detail requests. This map is shared
1587
+ * by outline, messages, and status callers so opening a session while the
1588
+ * drawer fires concurrent requests cannot duplicate the expensive parse.
1589
+ */
1590
+ async function ensureSessionContentIndexed(source, sessionId, options = {}) {
1591
+ const key = `content:${source}:${sessionId}`;
1592
+ if (_contentInflight.has(key)) return _contentInflight.get(key);
1593
+ const promise = (async () => {
1594
+ await ensureSummaryIndexed(source, {
1595
+ force: options.force === true,
1596
+ consistency: 'complete',
1597
+ config: options.config || indexConfig
1598
+ });
1599
+ const db = _getDb();
1600
+ const summaryRow = db.prepare(
1601
+ 'SELECT * FROM session_summary WHERE source = ? AND session_id = ?'
1602
+ ).get(source, sessionId);
1603
+ if (!summaryRow) return null;
1604
+
1605
+ let stat;
1606
+ try {
1607
+ stat = await fs.promises.stat(summaryRow.file_path);
1608
+ } catch (_) {
1609
+ db.prepare('DELETE FROM session_summary WHERE source = ? AND file_path = ?')
1610
+ .run(source, summaryRow.file_path);
1611
+ db.prepare('DELETE FROM session_file WHERE source = ? AND file_path = ?')
1612
+ .run(source, summaryRow.file_path);
1613
+ return null;
1614
+ }
1615
+
1616
+ const current = db.prepare(`
1617
+ SELECT * FROM session_file
1618
+ WHERE source = ? AND session_id = ? AND file_path = ?
1619
+ `).get(source, sessionId, summaryRow.file_path);
1620
+ if (current
1621
+ && Number(current.size) === Number(stat.size)
1622
+ && Number(current.mtime_ms) === Number(stat.mtimeMs)
1623
+ && Number(current.content_indexed ?? 1) === 1) {
1624
+ return current;
1625
+ }
1626
+
1627
+ const parsed = await _parseSingleSessionContent(source, {
1628
+ ...summaryRow,
1629
+ size: stat.size,
1630
+ mtime_ms: stat.mtimeMs
1631
+ }, options.config || indexConfig);
1632
+ let afterStat;
1633
+ try {
1634
+ afterStat = await fs.promises.stat(summaryRow.file_path);
1635
+ } catch (_) {
1636
+ return null;
1637
+ }
1638
+ if (afterStat.size !== stat.size || afterStat.mtimeMs !== stat.mtimeMs) {
1639
+ // A growing session is left as a stale summary. The next detail
1640
+ // request retries it after the writer has produced a stable file.
1641
+ return null;
1642
+ }
1643
+
1644
+ const deletePath = db.prepare('DELETE FROM session_file WHERE source = ? AND file_path = ?');
1645
+ const deleteSession = db.prepare('DELETE FROM session_file WHERE source = ? AND session_id = ?');
1646
+ const insertFile = db.prepare(`
1647
+ INSERT INTO session_file(
1648
+ source, file_path, size, mtime_ms, session_id,
1649
+ project_name, project_display_name, project_full_path,
1650
+ first_message, git_branch, provider, model,
1651
+ started_at, updated_at, message_count, usage_json, extra_json,
1652
+ parser_version
1653
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1654
+ `);
1655
+ const insertMessage = db.prepare(`
1656
+ INSERT INTO session_message(
1657
+ source, session_id, ordinal, message_id, role, type, subtype,
1658
+ content, timestamp, model, provider, user_message_number, extra_json
1659
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1660
+ `);
1661
+ db.exec('BEGIN IMMEDIATE');
1662
+ try {
1663
+ _insertSession(
1664
+ { deletePath, deleteSession, insertFile, insertMessage, db },
1665
+ source,
1666
+ { ...parsed.descriptor, size: afterStat.size, mtimeMs: afterStat.mtimeMs },
1667
+ parsed.parseResult.session,
1668
+ parsed.parseResult.messages,
1669
+ _parserVersionForSource(source, _resolveSessionsAdapter(source, options.config || indexConfig))
1670
+ );
1671
+ db.exec('COMMIT');
1672
+ } catch (error) {
1673
+ try { db.exec('ROLLBACK'); } catch (_) {}
1674
+ throw error;
1675
+ }
1676
+ return db.prepare(
1677
+ 'SELECT * FROM session_file WHERE source = ? AND session_id = ?'
1678
+ ).get(source, parsed.parseResult.session.sessionId);
1679
+ })();
1680
+ _contentInflight.set(key, promise);
1681
+ promise.finally(() => {
1682
+ if (_contentInflight.get(key) === promise) _contentInflight.delete(key);
1683
+ }).catch(() => {});
1684
+ return promise;
1036
1685
  }
1037
1686
 
1038
1687
  /**
@@ -1042,10 +1691,10 @@ function createSessionHistoryIndex(opts = {}) {
1042
1691
  * @returns {Promise<{sessionId, lastModified, size, filePath}|null>}
1043
1692
  */
1044
1693
  async function getSessionStatus(source, sessionId, options = {}) {
1045
- await _ensureSessionCurrent(source, sessionId);
1694
+ await _ensureSessionCurrent(source, sessionId, options);
1046
1695
  const db = _getDb();
1047
1696
  const row = db.prepare(
1048
- 'SELECT session_id, mtime_ms, size, file_path FROM session_file WHERE source = ? AND session_id = ?'
1697
+ 'SELECT session_id, mtime_ms, size, file_path FROM session_summary WHERE source = ? AND session_id = ?'
1049
1698
  ).get(source, sessionId);
1050
1699
  if (!row) return null;
1051
1700
 
@@ -1064,7 +1713,7 @@ function createSessionHistoryIndex(opts = {}) {
1064
1713
  * @returns {Promise<{sessionId, items: Array}|null>}
1065
1714
  */
1066
1715
  async function getSessionOutline(source, sessionId, options = {}) {
1067
- await _ensureSessionCurrent(source, sessionId);
1716
+ await ensureSessionContentIndexed(source, sessionId, options);
1068
1717
  const db = _getDb();
1069
1718
 
1070
1719
  const rows = db.prepare(`
@@ -1098,7 +1747,7 @@ function createSessionHistoryIndex(opts = {}) {
1098
1747
  * @returns {Promise<{messages: Array, metadata: object, pagination: object}|null>}
1099
1748
  */
1100
1749
  async function getMessagePage(source, sessionId, options = {}) {
1101
- await _ensureSessionCurrent(source, sessionId);
1750
+ await ensureSessionContentIndexed(source, sessionId, options);
1102
1751
  const db = _getDb();
1103
1752
  const { page, limit, order } = _normalizePageOpts(options);
1104
1753
 
@@ -1153,7 +1802,7 @@ function createSessionHistoryIndex(opts = {}) {
1153
1802
  * @returns {Promise<Array>}
1154
1803
  */
1155
1804
  async function getRecentSessions(source, limit = 5, options = {}) {
1156
- await ensureSourceIndexed(source, {
1805
+ await ensureSummaryIndexed(source, {
1157
1806
  force: options.force === true,
1158
1807
  consistency: options.consistency || 'stale-ok',
1159
1808
  config: options.config
@@ -1161,27 +1810,22 @@ function createSessionHistoryIndex(opts = {}) {
1161
1810
  const db = _getDb();
1162
1811
 
1163
1812
  const rows = db.prepare(`
1164
- SELECT * FROM session_file
1165
- WHERE source = ?
1166
- ORDER BY updated_at DESC
1813
+ SELECT s.*,
1814
+ f.message_count AS content_message_count,
1815
+ f.usage_json AS content_usage_json,
1816
+ f.extra_json AS content_extra_json
1817
+ FROM session_summary s
1818
+ LEFT JOIN session_file f ON f.source = s.source AND f.session_id = s.session_id
1819
+ WHERE s.source = ?
1820
+ ORDER BY s.updated_at DESC, s.session_id ASC
1167
1821
  LIMIT ?
1168
1822
  `).all(source, Math.max(1, Math.min(limit, 100)));
1169
1823
 
1170
- return rows.map(r => ({
1171
- sessionId: r.session_id,
1172
- filePath: r.file_path,
1173
- size: r.size,
1174
- mtime: r.mtime_ms,
1175
- firstMessage: r.first_message,
1176
- gitBranch: r.git_branch,
1177
- provider: r.provider,
1178
- model: r.model,
1179
- messageCount: r.message_count,
1180
- projectName: r.project_name,
1181
- projectDisplayName: r.project_display_name,
1182
- projectFullPath: r.project_full_path,
1183
- source: r.source,
1184
- updatedAt: r.updated_at
1824
+ return rows.map(r => _sessionRowToSummary({
1825
+ ...r,
1826
+ message_count: r.content_message_count,
1827
+ usage_json: r.content_usage_json,
1828
+ extra_json: r.content_extra_json || r.extra_json
1185
1829
  }));
1186
1830
  }
1187
1831
 
@@ -1193,14 +1837,18 @@ function createSessionHistoryIndex(opts = {}) {
1193
1837
  */
1194
1838
  async function searchSessions(source, keyword, options = {}) {
1195
1839
  if (!String(keyword || '').trim()) return [];
1840
+ const projectName = typeof options.projectName === 'string' && options.projectName.trim()
1841
+ ? options.projectName.trim()
1842
+ : null;
1843
+ const resultLimit = Math.max(1, Math.min(200, Number.parseInt(options.limit, 10) || 100));
1196
1844
  await ensureSourceIndexed(source, {
1197
1845
  force: options.force === true,
1198
1846
  consistency: options.consistency || 'complete',
1847
+ projectName,
1199
1848
  config: options.config
1200
1849
  });
1201
1850
  const db = _getDb();
1202
- const contextLength = options.contextLength || 35;
1203
- const projectName = options.projectName || null;
1851
+ const contextLength = Math.max(1, Math.min(200, Number(options.contextLength) || 35));
1204
1852
 
1205
1853
  // Build candidate query
1206
1854
  let candidates;
@@ -1209,26 +1857,38 @@ function createSessionHistoryIndex(opts = {}) {
1209
1857
  const needle = String(keyword);
1210
1858
  if ([...needle].length >= 3) {
1211
1859
  const ftsRows = db.prepare(`
1860
+ WITH matched_sessions AS (
1861
+ SELECT sm.source, sm.session_id, MAX(sf.updated_at) AS updated_at
1862
+ FROM session_message_fts fts
1863
+ JOIN session_message sm ON sm.rowid = fts.rowid
1864
+ JOIN session_file sf ON sf.source = sm.source AND sf.session_id = sm.session_id
1865
+ WHERE sm.source = ? AND session_message_fts MATCH ?
1866
+ ${projectName ? 'AND sf.project_name = ?' : ''}
1867
+ GROUP BY sm.source, sm.session_id
1868
+ ORDER BY updated_at DESC, sm.session_id ASC
1869
+ LIMIT ?
1870
+ )
1212
1871
  SELECT sm.source, sm.session_id, sm.ordinal, sm.content, sm.role, sm.type, sm.timestamp,
1213
1872
  sf.project_name, sf.project_display_name, sf.project_full_path,
1214
1873
  sf.file_path, sf.first_message, sf.updated_at
1215
1874
  FROM session_message_fts fts
1216
1875
  JOIN session_message sm ON sm.rowid = fts.rowid
1876
+ JOIN matched_sessions ms ON ms.source = sm.source AND ms.session_id = sm.session_id
1217
1877
  JOIN session_file sf ON sf.source = sm.source AND sf.session_id = sm.session_id
1218
- WHERE sm.source = ? AND session_message_fts MATCH ?
1219
- ${projectName ? 'AND sf.project_name = ?' : ''}
1220
- ORDER BY sf.updated_at DESC, sm.ordinal ASC, sm.rowid ASC
1221
- LIMIT 500
1878
+ WHERE session_message_fts MATCH ?
1879
+ ORDER BY ms.updated_at DESC, sm.ordinal ASC, sm.rowid ASC
1222
1880
  `);
1223
1881
  const params = [source, _ftsQuote(needle)];
1224
1882
  if (projectName) params.push(projectName);
1883
+ params.push(resultLimit);
1884
+ params.push(_ftsQuote(needle));
1225
1885
  candidates = ftsRows.all(...params);
1226
1886
  } else {
1227
1887
  // Short needle: scan all messages for this source
1228
- candidates = _scanMessagesRelational(db, source, keyword, projectName);
1888
+ candidates = _scanMessagesRelational(db, source, keyword, projectName, resultLimit);
1229
1889
  }
1230
1890
  } else {
1231
- candidates = _scanMessagesRelational(db, source, keyword, projectName);
1891
+ candidates = _scanMessagesRelational(db, source, keyword, projectName, resultLimit);
1232
1892
  }
1233
1893
 
1234
1894
  if (candidates.length === 0) return [];
@@ -1298,22 +1958,20 @@ function createSessionHistoryIndex(opts = {}) {
1298
1958
  });
1299
1959
  }
1300
1960
 
1301
- // Sort: matchCount DESC, then updated_at DESC
1961
+ // Search is a recency browser: matchCount remains useful metadata, but
1962
+ // should not push older conversations above newer ones.
1302
1963
  results.sort((a, b) => {
1303
-
1304
- const cm = b.matchCount - a.matchCount;
1305
- if (cm !== 0) return cm;
1306
1964
  const aRow = matchMap.get(a.sessionId);
1307
1965
  const bRow = matchMap.get(b.sessionId);
1308
- const au = (aRow && aRow.session.updated_at) || 0;
1309
- const bu = (bRow && bRow.session.updated_at) || 0;
1310
- return bu - au;
1966
+ const au = _timestampValue(aRow && aRow.session.updated_at);
1967
+ const bu = _timestampValue(bRow && bRow.session.updated_at);
1968
+ return bu - au || a.sessionId.localeCompare(b.sessionId);
1311
1969
  });
1312
1970
 
1313
- return results;
1971
+ return results.slice(0, resultLimit);
1314
1972
  }
1315
1973
 
1316
- function _scanMessagesRelational(db, source, keyword, projectName = null) {
1974
+ function _scanMessagesRelational(db, source, keyword, projectName = null, resultLimit = 100) {
1317
1975
  const useSqlMatch = /^[\x00-\x7F]*$/.test(String(keyword));
1318
1976
  const select = `
1319
1977
  SELECT sm.source, sm.session_id, sm.ordinal, sm.content, sm.role, sm.type, sm.timestamp,
@@ -1323,13 +1981,37 @@ function createSessionHistoryIndex(opts = {}) {
1323
1981
  JOIN session_file sf ON sf.source = sm.source AND sf.session_id = sm.session_id
1324
1982
  WHERE sm.source = ?
1325
1983
  ${projectName ? 'AND sf.project_name = ?' : ''}
1326
- ORDER BY sf.updated_at DESC, sm.ordinal ASC, sm.rowid ASC
1327
1984
  `;
1328
1985
 
1329
1986
  if (useSqlMatch) {
1330
- const sql = `${select.replace('WHERE sm.source = ?', 'WHERE sm.source = ? AND instr(lower(sm.content), lower(?)) > 0')}\nLIMIT 500`;
1331
- const params = [source, keyword];
1987
+ const sql = `
1988
+ WITH matched_messages AS (
1989
+ SELECT sm.source, sm.session_id, sm.ordinal, sm.rowid AS message_rowid,
1990
+ sm.content, sm.role, sm.type, sm.timestamp,
1991
+ sf.project_name, sf.project_display_name, sf.project_full_path,
1992
+ sf.file_path, sf.first_message, sf.updated_at
1993
+ FROM session_message sm
1994
+ JOIN session_file sf ON sf.source = sm.source AND sf.session_id = sm.session_id
1995
+ WHERE sm.source = ?
1996
+ ${projectName ? 'AND sf.project_name = ?' : ''}
1997
+ AND instr(lower(sm.content), lower(?)) > 0
1998
+ ),
1999
+ matched_sessions AS (
2000
+ SELECT source, session_id, MAX(updated_at) AS updated_at
2001
+ FROM matched_messages
2002
+ GROUP BY source, session_id
2003
+ ORDER BY updated_at DESC, session_id ASC
2004
+ LIMIT ?
2005
+ )
2006
+ SELECT mm.source, mm.session_id, mm.ordinal, mm.content, mm.role, mm.type, mm.timestamp,
2007
+ mm.project_name, mm.project_display_name, mm.project_full_path,
2008
+ mm.file_path, mm.first_message, mm.updated_at
2009
+ FROM matched_messages mm
2010
+ JOIN matched_sessions ms ON ms.source = mm.source AND ms.session_id = mm.session_id
2011
+ ORDER BY ms.updated_at DESC, mm.ordinal ASC, mm.message_rowid ASC`;
2012
+ const params = [source];
1332
2013
  if (projectName) params.push(projectName);
2014
+ params.push(keyword, Math.max(1, resultLimit));
1333
2015
  return db.prepare(sql).all(...params);
1334
2016
  }
1335
2017
 
@@ -1337,17 +2019,26 @@ function createSessionHistoryIndex(opts = {}) {
1337
2019
  // apply the same locale-aware JavaScript matcher used by searchSessions,
1338
2020
  // collecting at most the 500 matching candidates (not 500 raw rows).
1339
2021
  const candidates = [];
2022
+ const matchedSessionIds = new Set();
1340
2023
  const pageSize = 500;
1341
2024
  let offset = 0;
1342
- while (candidates.length < pageSize) {
2025
+ let stop = false;
2026
+ while (!stop) {
1343
2027
  const params = [source];
1344
2028
  if (projectName) params.push(projectName);
1345
2029
  params.push(pageSize, offset);
1346
- const batch = db.prepare(`${select}\nLIMIT ? OFFSET ?`).all(...params);
2030
+ const batch = db.prepare(`${select}\nORDER BY sf.updated_at DESC, sm.ordinal ASC, sm.rowid ASC\nLIMIT ? OFFSET ?`).all(...params);
1347
2031
  if (batch.length === 0) break;
1348
2032
  for (const row of batch) {
1349
- if (_containsLocaleMatch(row.content, keyword)) candidates.push(row);
1350
- if (candidates.length >= pageSize) break;
2033
+ if (!_containsLocaleMatch(row.content, keyword)) continue;
2034
+ if (!matchedSessionIds.has(row.session_id)) {
2035
+ if (matchedSessionIds.size >= resultLimit) {
2036
+ stop = true;
2037
+ break;
2038
+ }
2039
+ matchedSessionIds.add(row.session_id);
2040
+ }
2041
+ candidates.push(row);
1351
2042
  }
1352
2043
  if (batch.length < pageSize) break;
1353
2044
  offset += batch.length;
@@ -1393,12 +2084,30 @@ function createSessionHistoryIndex(opts = {}) {
1393
2084
  /**
1394
2085
  * Ensure the indexed data for a session is current by checking file mtime.
1395
2086
  */
1396
- async function _ensureSessionCurrent(source, sessionId) {
1397
- await ensureSourceIndexed(source, { consistency: 'stale-ok' });
2087
+ async function _ensureSessionCurrent(source, sessionId, options = {}) {
1398
2088
  const db = _getDb();
1399
- const row = db.prepare(
1400
- 'SELECT file_path, size, mtime_ms FROM session_file WHERE source = ? AND session_id = ?'
2089
+ let row = db.prepare(
2090
+ 'SELECT file_path, size, mtime_ms FROM session_summary WHERE source = ? AND session_id = ?'
1401
2091
  ).get(source, sessionId);
2092
+ if (!row) {
2093
+ const fullState = db.prepare(
2094
+ 'SELECT last_inventory_ms FROM source_state WHERE source = ?'
2095
+ ).get(source);
2096
+ const fullIndexIsFresh = fullState?.last_inventory_ms
2097
+ && Date.now() - Number(fullState.last_inventory_ms) < INDEX_INVENTORY_TTL_MS;
2098
+ // A completed legacy inventory with no matching session row is a
2099
+ // definitive miss (including malformed files). Do not start a second
2100
+ // summary scan merely because this status probe is lightweight.
2101
+ if (fullIndexIsFresh && options.force !== true) return;
2102
+ await ensureSummaryIndexed(source, {
2103
+ force: options.force === true,
2104
+ consistency: 'complete',
2105
+ config: options.config || indexConfig
2106
+ });
2107
+ row = db.prepare(
2108
+ 'SELECT file_path, size, mtime_ms FROM session_summary WHERE source = ? AND session_id = ?'
2109
+ ).get(source, sessionId);
2110
+ }
1402
2111
  if (!row) return;
1403
2112
 
1404
2113
  const fileKey = `${source}:${row.file_path}`;
@@ -1418,6 +2127,7 @@ function createSessionHistoryIndex(opts = {}) {
1418
2127
  currentStat = await fs.promises.stat(row.file_path);
1419
2128
  } catch (_) {
1420
2129
  fileVersions.set(fileKey, { size: -1, mtimeMs: -1, checkedAt: Date.now(), missing: true });
2130
+ db.prepare('DELETE FROM session_summary WHERE source = ? AND file_path = ?').run(source, row.file_path);
1421
2131
  db.prepare('DELETE FROM session_file WHERE source = ? AND file_path = ?').run(source, row.file_path);
1422
2132
  return;
1423
2133
  }
@@ -1428,7 +2138,11 @@ function createSessionHistoryIndex(opts = {}) {
1428
2138
  checkedAt: Date.now()
1429
2139
  });
1430
2140
  if (currentStat.size !== row.size || currentStat.mtimeMs !== row.mtime_ms) {
1431
- await ensureSourceIndexed(source, { force: true, consistency: 'complete' });
2141
+ await ensureSummaryIndexed(source, {
2142
+ force: true,
2143
+ consistency: 'complete',
2144
+ config: options.config || indexConfig
2145
+ });
1432
2146
  }
1433
2147
  })();
1434
2148
  fileChecks.set(fileKey, check);
@@ -1449,32 +2163,45 @@ function createSessionHistoryIndex(opts = {}) {
1449
2163
 
1450
2164
  if (options.deleted && options.sessionId) {
1451
2165
  db.prepare('DELETE FROM session_file WHERE source = ? AND session_id = ?').run(source, options.sessionId);
2166
+ db.prepare('DELETE FROM session_summary WHERE source = ? AND session_id = ?').run(source, options.sessionId);
1452
2167
  }
1453
2168
  if (options.projectName) {
1454
2169
  db.prepare('DELETE FROM session_file WHERE source = ? AND project_name = ?').run(source, options.projectName);
2170
+ db.prepare('DELETE FROM session_summary WHERE source = ? AND project_name = ?').run(source, options.projectName);
1455
2171
  }
1456
2172
 
1457
2173
  // Mark state as stale
1458
2174
  db.prepare(
1459
2175
  'UPDATE source_state SET last_inventory_ms = NULL WHERE source = ?'
1460
2176
  ).run(source);
2177
+ db.prepare(
2178
+ 'UPDATE source_state SET summary_inventory_ms = NULL, summary_error = NULL WHERE source = ?'
2179
+ ).run(source);
1461
2180
  for (const key of fileVersions.keys()) {
1462
2181
  if (key.startsWith(`${source}:`)) fileVersions.delete(key);
1463
2182
  }
2183
+ for (const key of sourceFreshness.keys()) {
2184
+ if (key === source || key.startsWith(`${source}:`)) sourceFreshness.delete(key);
2185
+ }
1464
2186
  }
1465
2187
  function getSourceIndexMeta(source) {
1466
2188
  const row = _getDb().prepare(
1467
- 'SELECT last_inventory_ms, last_error FROM source_state WHERE source = ?'
2189
+ 'SELECT last_inventory_ms, last_error, summary_inventory_ms, summary_error FROM source_state WHERE source = ?'
1468
2190
  ).get(source);
1469
- const generatedAt = row?.last_inventory_ms ? Number(row.last_inventory_ms) : null;
1470
- const stale = !generatedAt || Date.now() - generatedAt >= INDEX_INVENTORY_TTL_MS;
1471
- const refreshing = _inflight.has(`ensure:${source}`);
2191
+ const generatedAt = row?.summary_inventory_ms
2192
+ ? Number(row.summary_inventory_ms)
2193
+ : (row?.last_inventory_ms ? Number(row.last_inventory_ms) : null);
2194
+ const error = row?.summary_error || row?.last_error || null;
2195
+ const stale = Boolean(error) || !generatedAt || Date.now() - generatedAt >= INDEX_INVENTORY_TTL_MS;
2196
+ const refreshing = _summaryInflight.has(`summary:${source}`)
2197
+ || [..._inflight.keys()].some(key => key === `ensure:${source}:*` || key.startsWith(`ensure:${source}:`))
2198
+ || _hasActiveInventoryLock(dbPath);
1472
2199
  return {
1473
2200
  generatedAt,
1474
2201
  stale,
1475
2202
  refreshing,
1476
- fallback: Boolean(row?.last_error && !_hasUsableIndexedData(source)),
1477
- error: row?.last_error || null
2203
+ fallback: Boolean(error && !_hasSummaryData(source)),
2204
+ error
1478
2205
  };
1479
2206
  }
1480
2207
  function closeSessionHistoryIndex() {
@@ -1482,13 +2209,21 @@ function createSessionHistoryIndex(opts = {}) {
1482
2209
  _db = null;
1483
2210
  fileVersions.clear();
1484
2211
  fileChecks.clear();
2212
+ _summaryInflight.clear();
2213
+ _contentInflight.clear();
2214
+ sourceFreshness.clear();
1485
2215
  }
1486
2216
 
1487
2217
  // Build the API object
1488
2218
  const api = {
1489
2219
  ensureSourceIndexed,
2220
+ ensureSummaryIndexed,
2221
+ ensureSessionContentIndexed,
2222
+ getAllProjects,
1490
2223
  listProjects,
2224
+ listProjectsPage,
1491
2225
  listSessions,
2226
+ listSessionsPage,
1492
2227
  getSessionStatus,
1493
2228
  getSessionOutline,
1494
2229
  getMessagePage,
@@ -1529,10 +2264,30 @@ async function listProjects(source, options) {
1529
2264
  return _getDefaultIndex(options).listProjects(source, options);
1530
2265
  }
1531
2266
 
2267
+ async function ensureSummaryIndexed(source, options) {
2268
+ return _getDefaultIndex(options).ensureSummaryIndexed(source, options);
2269
+ }
2270
+
2271
+ async function ensureSessionContentIndexed(source, sessionId, options) {
2272
+ return _getDefaultIndex(options).ensureSessionContentIndexed(source, sessionId, options);
2273
+ }
2274
+
2275
+ async function listProjectsPage(source, options) {
2276
+ return _getDefaultIndex(options).listProjectsPage(source, options);
2277
+ }
2278
+
2279
+ async function getAllProjects(source, options) {
2280
+ return _getDefaultIndex(options).getAllProjects(source, options);
2281
+ }
2282
+
1532
2283
  async function listSessions(source, projectName, options) {
1533
2284
  return _getDefaultIndex().listSessions(source, projectName, options);
1534
2285
  }
1535
2286
 
2287
+ async function listSessionsPage(source, projectName, options) {
2288
+ return _getDefaultIndex().listSessionsPage(source, projectName, options);
2289
+ }
2290
+
1536
2291
  async function getSessionStatus(source, sessionId, options) {
1537
2292
  return _getDefaultIndex().getSessionStatus(source, sessionId, options);
1538
2293
  }
@@ -1578,8 +2333,13 @@ async function _defaultWorkerRunner(source, indexDbPath, options = {}) {
1578
2333
  module.exports = {
1579
2334
  createSessionHistoryIndex,
1580
2335
  ensureSourceIndexed,
2336
+ ensureSummaryIndexed,
2337
+ ensureSessionContentIndexed,
2338
+ getAllProjects,
1581
2339
  listProjects,
2340
+ listProjectsPage,
1582
2341
  listSessions,
2342
+ listSessionsPage,
1583
2343
  getSessionStatus,
1584
2344
  getSessionOutline,
1585
2345
  getMessagePage,