xtctx 0.11.1 → 0.11.4

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 (105) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/src/cli/hook.d.ts.map +1 -1
  3. package/dist/src/cli/hook.js +25 -14
  4. package/dist/src/cli/hook.js.map +1 -1
  5. package/dist/src/cli/index.d.ts.map +1 -1
  6. package/dist/src/cli/index.js +16 -1
  7. package/dist/src/cli/index.js.map +1 -1
  8. package/dist/src/cli/setup.d.ts.map +1 -1
  9. package/dist/src/cli/setup.js +1 -2
  10. package/dist/src/cli/setup.js.map +1 -1
  11. package/dist/src/cli/status.d.ts.map +1 -1
  12. package/dist/src/cli/status.js +54 -5
  13. package/dist/src/cli/status.js.map +1 -1
  14. package/dist/src/config/disconnect.d.ts +2 -0
  15. package/dist/src/config/disconnect.d.ts.map +1 -1
  16. package/dist/src/config/disconnect.js +109 -27
  17. package/dist/src/config/disconnect.js.map +1 -1
  18. package/dist/src/config/managed-block.d.ts +29 -0
  19. package/dist/src/config/managed-block.d.ts.map +1 -0
  20. package/dist/src/config/managed-block.js +67 -0
  21. package/dist/src/config/managed-block.js.map +1 -0
  22. package/dist/src/config/mcp-config.d.ts +2 -0
  23. package/dist/src/config/mcp-config.d.ts.map +1 -1
  24. package/dist/src/config/mcp-config.js +101 -7
  25. package/dist/src/config/mcp-config.js.map +1 -1
  26. package/dist/src/config/setup.d.ts +2 -0
  27. package/dist/src/config/setup.d.ts.map +1 -1
  28. package/dist/src/config/setup.js +78 -37
  29. package/dist/src/config/setup.js.map +1 -1
  30. package/dist/src/config/skills.d.ts.map +1 -1
  31. package/dist/src/config/skills.js +4 -4
  32. package/dist/src/config/skills.js.map +1 -1
  33. package/dist/src/handoff/embeddings.d.ts +11 -0
  34. package/dist/src/handoff/embeddings.d.ts.map +1 -1
  35. package/dist/src/handoff/embeddings.js +74 -5
  36. package/dist/src/handoff/embeddings.js.map +1 -1
  37. package/dist/src/handoff/sqlite-index.d.ts +9 -0
  38. package/dist/src/handoff/sqlite-index.d.ts.map +1 -1
  39. package/dist/src/handoff/sqlite-index.js +285 -98
  40. package/dist/src/handoff/sqlite-index.js.map +1 -1
  41. package/dist/src/handoff/types.d.ts +6 -0
  42. package/dist/src/handoff/types.d.ts.map +1 -1
  43. package/dist/src/mcp/server.d.ts +1 -1
  44. package/dist/src/mcp/server.d.ts.map +1 -1
  45. package/dist/src/mcp/server.js +17 -8
  46. package/dist/src/mcp/server.js.map +1 -1
  47. package/dist/src/mcp/tools/continuity.d.ts +19 -1
  48. package/dist/src/mcp/tools/continuity.d.ts.map +1 -1
  49. package/dist/src/mcp/tools/continuity.js +27 -2
  50. package/dist/src/mcp/tools/continuity.js.map +1 -1
  51. package/dist/src/mcp/tools/manifest.d.ts.map +1 -1
  52. package/dist/src/mcp/tools/manifest.js +15 -6
  53. package/dist/src/mcp/tools/manifest.js.map +1 -1
  54. package/dist/src/mcp/tools/sessions.d.ts +9 -1
  55. package/dist/src/mcp/tools/sessions.d.ts.map +1 -1
  56. package/dist/src/mcp/tools/sessions.js +68 -9
  57. package/dist/src/mcp/tools/sessions.js.map +1 -1
  58. package/dist/src/runtime/services.d.ts.map +1 -1
  59. package/dist/src/runtime/services.js +22 -2
  60. package/dist/src/runtime/services.js.map +1 -1
  61. package/dist/src/scrapers/antigravity.d.ts +11 -0
  62. package/dist/src/scrapers/antigravity.d.ts.map +1 -1
  63. package/dist/src/scrapers/antigravity.js +47 -36
  64. package/dist/src/scrapers/antigravity.js.map +1 -1
  65. package/dist/src/scrapers/base.d.ts.map +1 -1
  66. package/dist/src/scrapers/base.js +4 -1
  67. package/dist/src/scrapers/base.js.map +1 -1
  68. package/dist/src/scrapers/claude-code.d.ts +17 -0
  69. package/dist/src/scrapers/claude-code.d.ts.map +1 -1
  70. package/dist/src/scrapers/claude-code.js +115 -59
  71. package/dist/src/scrapers/claude-code.js.map +1 -1
  72. package/dist/src/scrapers/codex.d.ts.map +1 -1
  73. package/dist/src/scrapers/codex.js +166 -153
  74. package/dist/src/scrapers/codex.js.map +1 -1
  75. package/dist/src/scrapers/copilot-cli.d.ts +2 -1
  76. package/dist/src/scrapers/copilot-cli.d.ts.map +1 -1
  77. package/dist/src/scrapers/copilot-cli.js +88 -3
  78. package/dist/src/scrapers/copilot-cli.js.map +1 -1
  79. package/dist/src/scrapers/cursor.d.ts.map +1 -1
  80. package/dist/src/scrapers/cursor.js +20 -9
  81. package/dist/src/scrapers/cursor.js.map +1 -1
  82. package/dist/src/scrapers/index.d.ts +7 -2
  83. package/dist/src/scrapers/index.d.ts.map +1 -1
  84. package/dist/src/scrapers/index.js +7 -2
  85. package/dist/src/scrapers/index.js.map +1 -1
  86. package/dist/src/scrapers/opencode.d.ts +2 -1
  87. package/dist/src/scrapers/opencode.d.ts.map +1 -1
  88. package/dist/src/scrapers/opencode.js +38 -9
  89. package/dist/src/scrapers/opencode.js.map +1 -1
  90. package/dist/src/tools/sources.d.ts.map +1 -1
  91. package/dist/src/tools/sources.js +2 -2
  92. package/dist/src/tools/sources.js.map +1 -1
  93. package/dist/src/utils/atomic-file.d.ts +8 -0
  94. package/dist/src/utils/atomic-file.d.ts.map +1 -0
  95. package/dist/src/utils/atomic-file.js +15 -0
  96. package/dist/src/utils/atomic-file.js.map +1 -0
  97. package/dist/src/utils/errors.d.ts +5 -0
  98. package/dist/src/utils/errors.d.ts.map +1 -1
  99. package/dist/src/utils/errors.js +9 -0
  100. package/dist/src/utils/errors.js.map +1 -1
  101. package/dist/src/utils/project-scope.d.ts +16 -0
  102. package/dist/src/utils/project-scope.d.ts.map +1 -1
  103. package/dist/src/utils/project-scope.js +18 -2
  104. package/dist/src/utils/project-scope.js.map +1 -1
  105. package/package.json +3 -3
@@ -1,24 +1,37 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir } from "node:fs/promises";
3
- import { dirname } from "node:path";
2
+ import { mkdir, readdir, rm } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
4
  import Database from "better-sqlite3";
5
- import { DEFAULT_EMBEDDING_MODEL, TransformersEmbeddingProvider, } from "./embeddings.js";
5
+ import { DEFAULT_EMBEDDING_MODEL, TransformersEmbeddingProvider, poolVectors, splitTextForEmbedding, } from "./embeddings.js";
6
6
  import { cosineSimilarity, deserializeVector, serializeVector } from "./vector.js";
7
+ /**
8
+ * Bumped whenever the schema shape changes. The index is derived data, so a
9
+ * version mismatch (older or newer) triggers a full rebuild rather than a
10
+ * migration — the transcript stores remain authoritative.
11
+ */
12
+ const SCHEMA_VERSION = 1;
7
13
  const DEFAULT_LIMIT = 5;
8
14
  const MAX_LIMIT = 100;
9
15
  const DEFAULT_WINDOW_SIZE = 8;
10
16
  const DEFAULT_WINDOW_STRIDE = 4;
11
17
  const MAX_MATCHES_PER_SESSION = 3;
18
+ /**
19
+ * Minimum raw cosine similarity for a retrieval window to count as a semantic
20
+ * match. Unrelated sentence-transformer pairs sit near 0; related ones are
21
+ * comfortably above this.
22
+ */
23
+ const MIN_SEMANTIC_COSINE = 0.15;
12
24
  const SOURCE_CURSOR_OVERLAP_MS = 1_000;
13
25
  export class SqliteHandoffIndex {
14
26
  dbPath;
15
27
  projectRoot;
16
28
  tools;
17
29
  db = null;
30
+ stmts = null;
18
31
  initialized;
19
32
  refreshPromise = null;
20
33
  lastRefreshMs = 0;
21
- refreshTtlMs = 1_000;
34
+ refreshTtlMs = 5_000;
22
35
  embeddingProvider;
23
36
  windowSize;
24
37
  windowStride;
@@ -31,6 +44,10 @@ export class SqliteHandoffIndex {
31
44
  this.windowSize = Math.max(2, Math.floor(options.windowSize ?? DEFAULT_WINDOW_SIZE));
32
45
  this.windowStride = Math.max(1, Math.floor(options.windowStride ?? DEFAULT_WINDOW_STRIDE));
33
46
  this.initialized = this.initialize();
47
+ // Attach a no-op handler so a failed open cannot become an unhandled
48
+ // rejection (which would kill the process) before the first caller
49
+ // awaits; each awaiter of `initialized` still observes the rejection.
50
+ this.initialized.catch(() => { });
34
51
  }
35
52
  async listRecentSessions(limit, toolFilter) {
36
53
  await this.refresh({ toolFilter });
@@ -42,11 +59,21 @@ export class SqliteHandoffIndex {
42
59
  .prepare(`SELECT session_ref, tool, started_at, last_activity_at, message_count, preview, source_path
43
60
  FROM sessions
44
61
  ${where}
45
- ORDER BY datetime(last_activity_at) DESC
62
+ ORDER BY last_activity_at DESC
46
63
  LIMIT ?`)
47
64
  .all(...filters, normalizedLimit);
48
65
  return rows.map(formatSessionRow);
49
66
  }
67
+ async getSessionByRef(sessionRef) {
68
+ await this.refresh({ sessionRef });
69
+ const db = this.getDb();
70
+ const row = db
71
+ .prepare(`SELECT session_ref, tool, started_at, last_activity_at, message_count, preview, source_path
72
+ FROM sessions
73
+ WHERE session_ref = ?`)
74
+ .get(sessionRef);
75
+ return row ? formatSessionRow(row) : null;
76
+ }
50
77
  async getSessionDetail(sessionRef, offset, limit) {
51
78
  await this.refresh({ sessionRef });
52
79
  const db = this.getDb();
@@ -56,7 +83,7 @@ export class SqliteHandoffIndex {
56
83
  .prepare(`SELECT id, timestamp, role, content, message_index, source_pointer
57
84
  FROM messages
58
85
  WHERE session_ref = ?
59
- ORDER BY datetime(timestamp) ASC, message_index ASC, id ASC
86
+ ORDER BY timestamp ASC, message_index ASC, id ASC
60
87
  LIMIT ? OFFSET ?`)
61
88
  .all(sessionRef, normalizedLimit, normalizedOffset);
62
89
  return rows.map((row) => ({
@@ -77,10 +104,18 @@ export class SqliteHandoffIndex {
77
104
  return this.keywordSearch(trimmed, limit, toolFilter);
78
105
  }
79
106
  try {
80
- return await this.semanticSearch(trimmed, limit, toolFilter, normalizedMode);
107
+ const results = await this.semanticSearch(trimmed, limit, toolFilter, normalizedMode);
108
+ clearSetting(this.getDb(), "last_error:embeddings");
109
+ return results;
81
110
  }
82
111
  catch (error) {
83
112
  if (normalizedMode === "hybrid") {
113
+ // Degrading to keyword keeps search useful, but a broken embedding
114
+ // path must not be invisible: it silently returned keyword-only
115
+ // results for every user while status still read healthy.
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ setSetting(this.getDb(), "last_error:embeddings", message);
118
+ process.stderr.write(`xtctx: semantic search unavailable, using keyword only (${message})\n`);
84
119
  return this.keywordSearch(trimmed, limit, toolFilter);
85
120
  }
86
121
  throw error;
@@ -113,6 +148,7 @@ export class SqliteHandoffIndex {
113
148
  indexed_sessions: indexed?.sessions ?? 0,
114
149
  indexed_messages: indexed?.messages ?? 0,
115
150
  last_indexed_at: indexed?.last_indexed_at ?? null,
151
+ last_error: getSetting(db, `last_error:${tool}`),
116
152
  };
117
153
  }));
118
154
  return {
@@ -124,11 +160,12 @@ export class SqliteHandoffIndex {
124
160
  retrieval_units: retrievalUnitCount,
125
161
  vectorized_units: vectorizedUnitCount,
126
162
  vector_model: this.embeddingProvider.model,
163
+ embedding_error: getSetting(db, "last_error:embeddings"),
127
164
  tools,
128
165
  };
129
166
  }
130
167
  async close() {
131
- await this.initialized;
168
+ await this.initialized.catch(() => { });
132
169
  this.db?.close();
133
170
  this.db = null;
134
171
  }
@@ -142,6 +179,9 @@ export class SqliteHandoffIndex {
142
179
  }
143
180
  if (!this.refreshPromise) {
144
181
  this.refreshPromise = this.refreshNow().finally(() => {
182
+ // Stamp the TTL on failure as well as success so a persistently
183
+ // broken refresh backs off instead of re-running on every call.
184
+ this.lastRefreshMs = Date.now();
145
185
  this.refreshPromise = null;
146
186
  });
147
187
  }
@@ -171,32 +211,29 @@ export class SqliteHandoffIndex {
171
211
  lastTimestamp: overlapTimestamp(latestTimestamp),
172
212
  });
173
213
  }
214
+ clearSetting(db, `last_error:${scraper.tool}`);
174
215
  }
175
216
  catch (error) {
176
217
  setSetting(db, `last_error:${scraper.tool}`, error instanceof Error ? error.message : String(error));
177
- if (latestTimestamp) {
178
- try {
179
- await scraper.saveScrapedPosition({
180
- lastTimestamp: overlapTimestamp(latestTimestamp),
181
- });
182
- }
183
- catch {
184
- // Ignore position save failures inside catch
185
- }
186
- }
218
+ // Deliberately do NOT advance the cursor here: chunks yielded before
219
+ // the failure may sort after content in files never reached, and
220
+ // advancing would skip that content permanently. Re-scraping the
221
+ // same window is safe (message ids are deterministic hashes).
187
222
  }
188
223
  }
189
224
  for (const sessionRef of touchedSessions) {
225
+ // Roll up message_count/preview once per touched session rather than
226
+ // once per inserted message (which made indexing O(N²) per session).
227
+ this.prepared().sessionRollup.run(sessionRef);
190
228
  this.rebuildRetrievalUnitsForSession(sessionRef);
191
229
  }
192
230
  setSetting(db, "last_scan_at", startedAt);
193
- this.lastRefreshMs = Date.now();
194
231
  }
195
232
  upsertChunk(chunk) {
196
233
  if (!chunk.content.trim()) {
197
234
  return null;
198
235
  }
199
- const db = this.getDb();
236
+ const stmts = this.prepared();
200
237
  const timestamp = chunk.timestamp.toISOString();
201
238
  const sessionRef = `${chunk.tool}:${chunk.sessionId}`;
202
239
  const messageIndex = chunk.metadata.messageIndex ?? 0;
@@ -212,68 +249,52 @@ export class SqliteHandoffIndex {
212
249
  const now = new Date().toISOString();
213
250
  const metadataJson = JSON.stringify(chunk.metadata ?? {});
214
251
  const sourcePointer = sourcePathFromMetadata(chunk.metadata);
215
- db.prepare(`INSERT INTO sessions
216
- (session_ref, tool, source_session_id, project_root, started_at, last_activity_at,
217
- message_count, preview, source_path, updated_at)
218
- VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?, ?)
219
- ON CONFLICT(session_ref) DO UPDATE SET
220
- started_at = CASE
221
- WHEN excluded.started_at < started_at THEN excluded.started_at
222
- ELSE started_at
223
- END,
224
- last_activity_at = CASE
225
- WHEN excluded.last_activity_at > last_activity_at THEN excluded.last_activity_at
226
- ELSE last_activity_at
227
- END,
228
- source_path = COALESCE(source_path, excluded.source_path),
229
- updated_at = excluded.updated_at`).run(sessionRef, chunk.tool, chunk.sessionId, this.projectRoot, timestamp, timestamp, sourcePointer, now);
230
- db.prepare(`INSERT OR IGNORE INTO messages
231
- (id, session_ref, tool, source_session_id, timestamp, role, content,
232
- message_index, content_hash, metadata_json, source_pointer, indexed_at)
233
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, sessionRef, chunk.tool, chunk.sessionId, timestamp, chunk.role, chunk.content, messageIndex, contentHash, metadataJson, sourcePointer, now);
234
- db.prepare(`INSERT OR IGNORE INTO messages_fts(rowid, session_ref, tool, role, timestamp, content)
235
- SELECT rowid, session_ref, tool, role, timestamp, content
236
- FROM messages
237
- WHERE id = ?`).run(id);
238
- db.prepare(`UPDATE sessions
239
- SET message_count = (
240
- SELECT COUNT(*) FROM messages WHERE messages.session_ref = sessions.session_ref
241
- ),
242
- preview = COALESCE(
243
- (
244
- SELECT substr(content, 1, 240)
245
- FROM messages
246
- WHERE messages.session_ref = sessions.session_ref
247
- ORDER BY datetime(timestamp) ASC, message_index ASC, id ASC
248
- LIMIT 1
249
- ),
250
- preview
251
- )
252
- WHERE session_ref = ?`).run(sessionRef);
252
+ stmts.upsertChunkTxn([
253
+ sessionRef,
254
+ chunk.tool,
255
+ chunk.sessionId,
256
+ this.projectRoot,
257
+ timestamp,
258
+ timestamp,
259
+ sourcePointer,
260
+ now,
261
+ ], [
262
+ id,
263
+ sessionRef,
264
+ chunk.tool,
265
+ chunk.sessionId,
266
+ timestamp,
267
+ chunk.role,
268
+ chunk.content,
269
+ messageIndex,
270
+ contentHash,
271
+ metadataJson,
272
+ sourcePointer,
273
+ now,
274
+ ]);
253
275
  return sessionRef;
254
276
  }
255
277
  rebuildRetrievalUnitsForSession(sessionRef) {
256
278
  const db = this.getDb();
257
- const messages = db
258
- .prepare(`SELECT id, timestamp, role, content, message_index, source_pointer
259
- FROM messages
260
- WHERE session_ref = ?
261
- ORDER BY datetime(timestamp) ASC, message_index ASC, id ASC`)
262
- .all(sessionRef);
263
- db.prepare("DELETE FROM retrieval_units_fts WHERE session_ref = ?").run(sessionRef);
264
- db.prepare("DELETE FROM retrieval_units WHERE session_ref = ?").run(sessionRef);
279
+ const stmts = this.prepared();
280
+ const messages = stmts.selectSessionMessages.all(sessionRef);
265
281
  if (messages.length === 0) {
282
+ db.prepare("DELETE FROM retrieval_units_fts WHERE session_ref = ?").run(sessionRef);
283
+ db.prepare("DELETE FROM retrieval_units WHERE session_ref = ?").run(sessionRef);
266
284
  return;
267
285
  }
268
- const session = db
269
- .prepare("SELECT tool FROM sessions WHERE session_ref = ?")
270
- .get(sessionRef);
286
+ const session = stmts.selectSessionTool.get(sessionRef);
271
287
  if (!session) {
272
288
  return;
273
289
  }
290
+ // Diff the desired windows against what is stored instead of deleting
291
+ // everything: unit ids are deterministic content hashes, so unchanged
292
+ // windows (and, via the FK, their vectors) survive a re-index untouched.
274
293
  const now = new Date().toISOString();
294
+ const desired = new Map();
275
295
  for (const window of buildMessageWindows(messages, this.windowSize, this.windowStride)) {
276
296
  const content = formatRetrievalUnitContent(sessionRef, window.messages);
297
+ const searchableText = window.messages.map((message) => message.content).join("\n");
277
298
  const contentHash = hashParts([content]);
278
299
  const unitId = hashParts([
279
300
  "retrieval-unit",
@@ -282,17 +303,104 @@ export class SqliteHandoffIndex {
282
303
  String(window.end.message_index),
283
304
  contentHash,
284
305
  ]);
285
- db.prepare(`INSERT INTO retrieval_units
306
+ desired.set(unitId, {
307
+ start: window.start,
308
+ end: window.end,
309
+ content,
310
+ searchableText,
311
+ contentHash,
312
+ });
313
+ }
314
+ const existing = new Set(stmts.selectUnitIds.all(sessionRef).map((row) => row.id));
315
+ const applyDiff = db.transaction(() => {
316
+ for (const unitId of existing) {
317
+ if (!desired.has(unitId)) {
318
+ stmts.deleteUnitFts.run(unitId);
319
+ stmts.deleteUnit.run(unitId);
320
+ }
321
+ }
322
+ for (const [unitId, unit] of desired) {
323
+ if (existing.has(unitId)) {
324
+ continue;
325
+ }
326
+ stmts.insertUnit.run(unitId, sessionRef, session.tool, unit.start.message_index, unit.end.message_index, unit.start.timestamp, unit.end.timestamp, unit.content, unit.contentHash, now);
327
+ // Only the transcript text is keyword-indexed. `unit.content` also
328
+ // carries the window scaffolding ("Session: …", "Turn 1/8 |
329
+ // message_index=0 | user @ …") that gives the embedding model
330
+ // ordering context; indexing it made `message_index` or a tool name
331
+ // match every session.
332
+ stmts.insertUnitFts.run(unitId, sessionRef, session.tool, unit.searchableText);
333
+ }
334
+ });
335
+ applyDiff();
336
+ }
337
+ prepared() {
338
+ if (this.stmts) {
339
+ return this.stmts;
340
+ }
341
+ const db = this.getDb();
342
+ const upsertSession = db.prepare(`INSERT INTO sessions
343
+ (session_ref, tool, source_session_id, project_root, started_at, last_activity_at,
344
+ message_count, preview, source_path, updated_at)
345
+ VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?, ?)
346
+ ON CONFLICT(session_ref) DO UPDATE SET
347
+ started_at = CASE
348
+ WHEN excluded.started_at < started_at THEN excluded.started_at
349
+ ELSE started_at
350
+ END,
351
+ last_activity_at = CASE
352
+ WHEN excluded.last_activity_at > last_activity_at THEN excluded.last_activity_at
353
+ ELSE last_activity_at
354
+ END,
355
+ source_path = COALESCE(source_path, excluded.source_path),
356
+ updated_at = excluded.updated_at`);
357
+ const insertMessage = db.prepare(`INSERT OR IGNORE INTO messages
358
+ (id, session_ref, tool, source_session_id, timestamp, role, content,
359
+ message_index, content_hash, metadata_json, source_pointer, indexed_at)
360
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
361
+ this.stmts = {
362
+ upsertSession,
363
+ insertMessage,
364
+ upsertChunkTxn: db.transaction((sessionArgs, messageArgs) => {
365
+ upsertSession.run(...sessionArgs);
366
+ insertMessage.run(...messageArgs);
367
+ }),
368
+ sessionRollup: db.prepare(`UPDATE sessions
369
+ SET message_count = (
370
+ SELECT COUNT(*) FROM messages WHERE messages.session_ref = sessions.session_ref
371
+ ),
372
+ preview = COALESCE(
373
+ (
374
+ SELECT substr(content, 1, 240)
375
+ FROM messages
376
+ WHERE messages.session_ref = sessions.session_ref
377
+ ORDER BY timestamp ASC, message_index ASC, id ASC
378
+ LIMIT 1
379
+ ),
380
+ preview
381
+ )
382
+ WHERE session_ref = ?`),
383
+ selectSessionMessages: db.prepare(`SELECT id, timestamp, role, content, message_index, source_pointer
384
+ FROM messages
385
+ WHERE session_ref = ?
386
+ ORDER BY timestamp ASC, message_index ASC, id ASC`),
387
+ selectSessionTool: db.prepare("SELECT tool FROM sessions WHERE session_ref = ?"),
388
+ selectUnitIds: db.prepare("SELECT id FROM retrieval_units WHERE session_ref = ?"),
389
+ insertUnit: db.prepare(`INSERT INTO retrieval_units
286
390
  (id, session_ref, tool, message_start_index, message_end_index,
287
391
  started_at, ended_at, content, content_hash, updated_at)
288
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(unitId, sessionRef, session.tool, window.start.message_index, window.end.message_index, window.start.timestamp, window.end.timestamp, content, contentHash, now);
289
- db.prepare(`INSERT INTO retrieval_units_fts(unit_id, session_ref, tool, content)
290
- VALUES (?, ?, ?, ?)`).run(unitId, sessionRef, session.tool, content);
291
- }
392
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
393
+ insertUnitFts: db.prepare(`INSERT INTO retrieval_units_fts(unit_id, session_ref, tool, content)
394
+ VALUES (?, ?, ?, ?)`),
395
+ deleteUnit: db.prepare("DELETE FROM retrieval_units WHERE id = ?"),
396
+ deleteUnitFts: db.prepare("DELETE FROM retrieval_units_fts WHERE unit_id = ?"),
397
+ };
398
+ return this.stmts;
292
399
  }
293
400
  async keywordSearch(query, limit, toolFilter) {
294
401
  const rows = this.queryKeywordUnits(query, limit, toolFilter);
295
- return groupUnits(rows, new Map(), "keyword", normalizeLimit(limit, DEFAULT_LIMIT));
402
+ // Rank by BM25 position so relevance, not recency, dominates ordering.
403
+ return groupUnits(rows, rankKeywordRows(rows), "keyword", normalizeLimit(limit, DEFAULT_LIMIT));
296
404
  }
297
405
  async semanticSearch(query, limit, toolFilter, mode) {
298
406
  const normalizedLimit = normalizeLimit(limit, DEFAULT_LIMIT);
@@ -318,12 +426,14 @@ export class SqliteHandoffIndex {
318
426
  const timeRange = getTimeRange(rows.map((row) => row.ended_at));
319
427
  const scored = rows
320
428
  .map((row) => {
321
- const semanticScore = normalizeCosine(cosineSimilarity(queryVector, deserializeVector(row.vector, row.dimensions)));
429
+ const rawCosine = cosineSimilarity(queryVector, deserializeVector(row.vector, row.dimensions));
430
+ const semanticScore = normalizeCosine(rawCosine);
322
431
  const keywordScore = keywordScores.get(row.unit_id) ?? 0;
323
432
  const recencyScore = scoreRecency(row.ended_at, timeRange);
324
433
  const continuityScore = scoreContinuity(row.message_end_index, row.session_message_count);
325
434
  return {
326
435
  row,
436
+ rawCosine,
327
437
  score: blendScores(mode, semanticScore, keywordScore, recencyScore, continuityScore),
328
438
  semanticScore,
329
439
  keywordScore,
@@ -331,6 +441,12 @@ export class SqliteHandoffIndex {
331
441
  continuityScore,
332
442
  };
333
443
  })
444
+ // Require actual evidence. Cosine normalises to ~0.5 for unrelated
445
+ // content, so recency and continuity alone were enough to return the
446
+ // entire corpus for a query matching nothing — formatted exactly like a
447
+ // real hit. A unit qualifies on semantic similarity or a keyword match;
448
+ // "no matching sessions" is a more useful answer than a nearest vector.
449
+ .filter((item) => item.rawCosine >= MIN_SEMANTIC_COSINE || item.keywordScore > 0)
334
450
  .sort((left, right) => right.score - left.score);
335
451
  return groupScoredUnits(scored, mode, normalizedLimit);
336
452
  }
@@ -349,7 +465,7 @@ export class SqliteHandoffIndex {
349
465
  JOIN retrieval_units u ON u.id = f.unit_id
350
466
  JOIN sessions s ON s.session_ref = u.session_ref
351
467
  WHERE retrieval_units_fts MATCH ? ${toolWhere}
352
- ORDER BY bm25(retrieval_units_fts), datetime(u.ended_at) DESC
468
+ ORDER BY bm25(retrieval_units_fts), u.ended_at DESC
353
469
  LIMIT ?`)
354
470
  .all(ftsQuery, ...filters, normalizedLimit * MAX_MATCHES_PER_SESSION);
355
471
  }
@@ -365,36 +481,87 @@ export class SqliteHandoffIndex {
365
481
  AND v.model = ?
366
482
  AND v.content_hash = u.content_hash
367
483
  WHERE v.unit_id IS NULL ${toolWhere}
368
- ORDER BY datetime(u.ended_at) DESC`)
484
+ ORDER BY u.ended_at DESC`)
369
485
  .all(this.embeddingProvider.model, ...filters);
370
486
  if (rows.length === 0) {
371
487
  return;
372
488
  }
373
- const vectors = await this.embeddingProvider.embedBatch(rows.map((row) => row.content));
374
- const now = new Date().toISOString();
375
489
  const upsert = db.prepare(`INSERT INTO retrieval_unit_vectors
376
490
  (unit_id, model, dimensions, content_hash, vector, created_at)
377
491
  VALUES (?, ?, ?, ?, ?, ?)
378
- ON CONFLICT(unit_id) DO UPDATE SET
379
- model = excluded.model,
492
+ ON CONFLICT(unit_id, model) DO UPDATE SET
380
493
  dimensions = excluded.dimensions,
381
494
  content_hash = excluded.content_hash,
382
495
  vector = excluded.vector,
383
496
  created_at = excluded.created_at`);
384
- const transaction = db.transaction(() => {
385
- rows.forEach((row, index) => {
386
- const vector = vectors[index];
387
- upsert.run(row.unit_id, this.embeddingProvider.model, vector.length, row.content_hash, serializeVector(vector), now);
497
+ // Bounded batches keep memory flat on a first-time index of a large
498
+ // history, and each batch commits before the next one embeds.
499
+ const unitBatchSize = 64;
500
+ for (let start = 0; start < rows.length; start += unitBatchSize) {
501
+ const batch = rows.slice(start, start + unitBatchSize);
502
+ // Long windows are segmented to the model's sequence budget and
503
+ // mean-pooled, so content beyond the window's opening still shapes
504
+ // the unit's vector.
505
+ const segmented = batch.map((row) => splitTextForEmbedding(row.content));
506
+ const segmentVectors = await this.embeddingProvider.embedBatch(segmented.flat());
507
+ let cursor = 0;
508
+ const pooled = segmented.map((segments) => {
509
+ const slice = segmentVectors.slice(cursor, cursor + segments.length);
510
+ cursor += segments.length;
511
+ return poolVectors(slice);
388
512
  });
389
- });
390
- transaction();
513
+ const now = new Date().toISOString();
514
+ const transaction = db.transaction(() => {
515
+ batch.forEach((row, index) => {
516
+ const vector = pooled[index];
517
+ upsert.run(row.unit_id, this.embeddingProvider.model, vector.length, row.content_hash, serializeVector(vector), now);
518
+ });
519
+ });
520
+ transaction();
521
+ }
391
522
  }
392
523
  async initialize() {
393
524
  await mkdir(dirname(this.dbPath), { recursive: true });
394
- this.db = new Database(this.dbPath);
395
- this.db.pragma("journal_mode = WAL");
396
- this.db.pragma("foreign_keys = ON");
397
- createSchema(this.db);
525
+ try {
526
+ this.db = openDatabase(this.dbPath);
527
+ }
528
+ catch {
529
+ // The index is derived data: a corrupt or schema-incompatible database
530
+ // is discarded and rebuilt from the transcript stores on next refresh.
531
+ await this.deleteDatabaseFiles();
532
+ this.db = openDatabase(this.dbPath);
533
+ }
534
+ // One rule covers every way the index can end up empty — deleted by a
535
+ // user (the recovery the docs invite), rebuilt after corruption, or
536
+ // wiped by hand: an empty index cannot honour a scraper cursor, because
537
+ // the cursor would skip straight past history still sitting on disk.
538
+ // On a genuine first run there are no cursor files, so this is a no-op.
539
+ const sessionCount = this.db.prepare("SELECT COUNT(*) AS count FROM sessions").get().count;
540
+ if (sessionCount === 0) {
541
+ await this.clearScraperCursors();
542
+ }
543
+ }
544
+ async deleteDatabaseFiles() {
545
+ for (const suffix of ["", "-wal", "-shm"]) {
546
+ await rm(`${this.dbPath}${suffix}`, { force: true });
547
+ }
548
+ }
549
+ /**
550
+ * Drop every scraper's saved position so the next refresh re-scans from the
551
+ * beginning. Called whenever the index is empty; see initialize().
552
+ */
553
+ async clearScraperCursors() {
554
+ const stateDir = dirname(this.dbPath);
555
+ try {
556
+ for (const entry of await readdir(stateDir)) {
557
+ if (entry.endsWith("-state.json")) {
558
+ await rm(join(stateDir, entry), { force: true });
559
+ }
560
+ }
561
+ }
562
+ catch {
563
+ // No state directory yet: nothing to reset.
564
+ }
398
565
  }
399
566
  getDb() {
400
567
  if (!this.db) {
@@ -403,6 +570,25 @@ export class SqliteHandoffIndex {
403
570
  return this.db;
404
571
  }
405
572
  }
573
+ function openDatabase(dbPath) {
574
+ const db = new Database(dbPath);
575
+ try {
576
+ db.pragma("journal_mode = WAL");
577
+ db.pragma("foreign_keys = ON");
578
+ const objectCount = db.prepare("SELECT COUNT(*) AS count FROM sqlite_master").get().count;
579
+ const version = db.pragma("user_version", { simple: true });
580
+ if (objectCount > 0 && version !== SCHEMA_VERSION) {
581
+ throw new Error(`xtctx index schema version ${version} does not match supported version ${SCHEMA_VERSION}`);
582
+ }
583
+ createSchema(db);
584
+ db.pragma(`user_version = ${SCHEMA_VERSION}`);
585
+ return db;
586
+ }
587
+ catch (error) {
588
+ db.close();
589
+ throw error;
590
+ }
591
+ }
406
592
  function createSchema(db) {
407
593
  db.exec(`
408
594
  CREATE TABLE IF NOT EXISTS sessions (
@@ -437,9 +623,6 @@ function createSchema(db) {
437
623
  CREATE INDEX IF NOT EXISTS idx_messages_session_order
438
624
  ON messages(session_ref, timestamp, message_index, id);
439
625
 
440
- CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts
441
- USING fts5(session_ref UNINDEXED, tool UNINDEXED, role UNINDEXED, timestamp UNINDEXED, content);
442
-
443
626
  CREATE TABLE IF NOT EXISTS retrieval_units (
444
627
  id TEXT PRIMARY KEY,
445
628
  session_ref TEXT NOT NULL REFERENCES sessions(session_ref) ON DELETE CASCADE,
@@ -462,12 +645,13 @@ function createSchema(db) {
462
645
  USING fts5(unit_id UNINDEXED, session_ref UNINDEXED, tool UNINDEXED, content);
463
646
 
464
647
  CREATE TABLE IF NOT EXISTS retrieval_unit_vectors (
465
- unit_id TEXT PRIMARY KEY REFERENCES retrieval_units(id) ON DELETE CASCADE,
648
+ unit_id TEXT NOT NULL REFERENCES retrieval_units(id) ON DELETE CASCADE,
466
649
  model TEXT NOT NULL,
467
650
  dimensions INTEGER NOT NULL,
468
651
  content_hash TEXT NOT NULL,
469
652
  vector BLOB NOT NULL,
470
- created_at TEXT NOT NULL
653
+ created_at TEXT NOT NULL,
654
+ PRIMARY KEY (unit_id, model)
471
655
  );
472
656
 
473
657
  CREATE INDEX IF NOT EXISTS idx_retrieval_unit_vectors_model
@@ -542,7 +726,7 @@ function formatRetrievalUnitContent(sessionRef, messages) {
542
726
  function groupUnits(rows, keywordScores, retrieval, limit) {
543
727
  const timeRange = getTimeRange(rows.map((row) => row.ended_at));
544
728
  const scored = rows.map((row) => {
545
- const keywordScore = keywordScores.get(row.unit_id) ?? 1;
729
+ const keywordScore = keywordScores.get(row.unit_id) ?? 0;
546
730
  const recencyScore = scoreRecency(row.ended_at, timeRange);
547
731
  const continuityScore = scoreContinuity(row.message_end_index, row.session_message_count);
548
732
  return {
@@ -692,6 +876,9 @@ function setSetting(db, key, value) {
692
876
  VALUES (?, ?)
693
877
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value);
694
878
  }
879
+ function clearSetting(db, key) {
880
+ db.prepare("DELETE FROM settings WHERE key = ?").run(key);
881
+ }
695
882
  function sourcePathFromMetadata(metadata) {
696
883
  const value = metadata.sourcePath;
697
884
  return typeof value === "string" && value.trim().length > 0 ? value : null;