xtctx 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/dist/src/cli/disconnect.d.ts +1 -0
  4. package/dist/src/cli/disconnect.d.ts.map +1 -1
  5. package/dist/src/cli/disconnect.js +23 -1
  6. package/dist/src/cli/disconnect.js.map +1 -1
  7. package/dist/src/cli/hook.d.ts.map +1 -1
  8. package/dist/src/cli/hook.js +53 -5
  9. package/dist/src/cli/hook.js.map +1 -1
  10. package/dist/src/cli/status.d.ts +19 -0
  11. package/dist/src/cli/status.d.ts.map +1 -1
  12. package/dist/src/cli/status.js +35 -9
  13. package/dist/src/cli/status.js.map +1 -1
  14. package/dist/src/handoff/embeddings.d.ts +14 -0
  15. package/dist/src/handoff/embeddings.d.ts.map +1 -1
  16. package/dist/src/handoff/embeddings.js +19 -0
  17. package/dist/src/handoff/embeddings.js.map +1 -1
  18. package/dist/src/handoff/sqlite-index.d.ts +42 -3
  19. package/dist/src/handoff/sqlite-index.d.ts.map +1 -1
  20. package/dist/src/handoff/sqlite-index.js +263 -38
  21. package/dist/src/handoff/sqlite-index.js.map +1 -1
  22. package/dist/src/handoff/types.d.ts +36 -2
  23. package/dist/src/handoff/types.d.ts.map +1 -1
  24. package/dist/src/mcp/server.d.ts.map +1 -1
  25. package/dist/src/mcp/server.js +15 -0
  26. package/dist/src/mcp/server.js.map +1 -1
  27. package/dist/src/mcp/tools/manifest.d.ts.map +1 -1
  28. package/dist/src/mcp/tools/manifest.js +1 -1
  29. package/dist/src/mcp/tools/manifest.js.map +1 -1
  30. package/dist/src/mcp/tools/sessions.d.ts +2 -0
  31. package/dist/src/mcp/tools/sessions.d.ts.map +1 -1
  32. package/dist/src/mcp/tools/sessions.js +39 -6
  33. package/dist/src/mcp/tools/sessions.js.map +1 -1
  34. package/dist/src/scrapers/antigravity.d.ts +1 -0
  35. package/dist/src/scrapers/antigravity.d.ts.map +1 -1
  36. package/dist/src/scrapers/antigravity.js +30 -5
  37. package/dist/src/scrapers/antigravity.js.map +1 -1
  38. package/dist/src/scrapers/claude-code.d.ts +1 -0
  39. package/dist/src/scrapers/claude-code.d.ts.map +1 -1
  40. package/dist/src/scrapers/claude-code.js +24 -13
  41. package/dist/src/scrapers/claude-code.js.map +1 -1
  42. package/dist/src/scrapers/codex.d.ts.map +1 -1
  43. package/dist/src/scrapers/codex.js +22 -5
  44. package/dist/src/scrapers/codex.js.map +1 -1
  45. package/dist/src/scrapers/copilot-cli.d.ts.map +1 -1
  46. package/dist/src/scrapers/copilot-cli.js +22 -5
  47. package/dist/src/scrapers/copilot-cli.js.map +1 -1
  48. package/dist/src/scrapers/copilot.d.ts.map +1 -1
  49. package/dist/src/scrapers/copilot.js +5 -5
  50. package/dist/src/scrapers/copilot.js.map +1 -1
  51. package/dist/src/scrapers/cursor.d.ts.map +1 -1
  52. package/dist/src/scrapers/cursor.js +5 -5
  53. package/dist/src/scrapers/cursor.js.map +1 -1
  54. package/dist/src/scrapers/drift-log.d.ts +15 -0
  55. package/dist/src/scrapers/drift-log.d.ts.map +1 -0
  56. package/dist/src/scrapers/drift-log.js +81 -0
  57. package/dist/src/scrapers/drift-log.js.map +1 -0
  58. package/dist/src/scrapers/opencode.d.ts.map +1 -1
  59. package/dist/src/scrapers/opencode.js +5 -5
  60. package/dist/src/scrapers/opencode.js.map +1 -1
  61. package/dist/src/types/scraper.d.ts +13 -0
  62. package/dist/src/types/scraper.d.ts.map +1 -1
  63. package/package.json +1 -1
@@ -4,12 +4,36 @@ import { dirname, join } from "node:path";
4
4
  import Database from "better-sqlite3";
5
5
  import { DEFAULT_EMBEDDING_MODEL, TransformersEmbeddingProvider, poolVectors, splitTextForEmbedding, } from "./embeddings.js";
6
6
  import { cosineSimilarity, deserializeVector, serializeVector } from "./vector.js";
7
+ /**
8
+ * How long a tool call will wait for a scan of every transcript store on the
9
+ * machine.
10
+ *
11
+ * A cold scan here takes about 55 seconds — 18GB of codex history is most of
12
+ * it — and it used to run inside the caller's first tool call. MCP servers are
13
+ * spawned per agent session, so that was paid on every handoff, and a host
14
+ * with a 30s tool timeout never got a first answer at all.
15
+ *
16
+ * The scan is not cancelled when the budget expires; the caller just stops
17
+ * waiting for it. Everything it has already written stays written, so the next
18
+ * call sees more, and the call after that sees all of it.
19
+ */
20
+ const DEFAULT_REFRESH_BUDGET_MS = 4_000;
21
+ /**
22
+ * How long one search spends building vectors before answering.
23
+ *
24
+ * The first semantic search used to vectorize the entire corpus inline: 530
25
+ * seconds on a 1,145-window index, inside a single tool call, with no cap and
26
+ * nothing to show for the wait. Each batch commits on its own, so stopping
27
+ * early costs nothing — the next search picks up where this one stopped, and
28
+ * recall improves call over call until the corpus is covered.
29
+ */
30
+ const DEFAULT_VECTOR_BUDGET_MS = 6_000;
7
31
  /**
8
32
  * Bumped whenever the schema shape changes. The index is derived data, so a
9
33
  * version mismatch (older or newer) triggers a full rebuild rather than a
10
34
  * migration — the transcript stores remain authoritative.
11
35
  */
12
- const SCHEMA_VERSION = 1;
36
+ const SCHEMA_VERSION = 2;
13
37
  const DEFAULT_LIMIT = 5;
14
38
  const MAX_LIMIT = 100;
15
39
  const DEFAULT_WINDOW_SIZE = 8;
@@ -17,10 +41,34 @@ const DEFAULT_WINDOW_STRIDE = 4;
17
41
  const MAX_MATCHES_PER_SESSION = 3;
18
42
  /**
19
43
  * 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
44
+ * match.
45
+ *
46
+ * Unrelated sentence-transformer pairs sit near 0; related ones are
21
47
  * comfortably above this.
22
48
  */
23
49
  const MIN_SEMANTIC_COSINE = 0.15;
50
+ /**
51
+ * How similar the *best* window has to be before a query counts as having
52
+ * found anything semantically.
53
+ *
54
+ * The per-window floor above cannot do this job. Raising it high enough to
55
+ * reject a nonsense query — which cleared 0.15 on 927 of 1,145 windows, 81% of
56
+ * the corpus — also discards genuine mid-range matches, and pure vector search
57
+ * has no keyword hits to fall back on: at a 0.35 per-window floor the eval
58
+ * lost recall@5 from 0.70 to 0.50.
59
+ *
60
+ * Whether a query found anything is a property of the query, not of each
61
+ * window. So when nothing clears this bar, semantic matches are dropped
62
+ * wholesale and only keyword hits remain — usually meaning "no matching
63
+ * sessions", which is the honest answer. When something does clear it, the
64
+ * weaker windows around it are kept.
65
+ *
66
+ * The value is bounded from both sides and the gap is narrow: gibberish tops
67
+ * out near 0.31 against this index, genuine queries reach 0.44-0.59, and the
68
+ * ranking eval starts losing vector recall above 0.32. If it needs to move,
69
+ * move it against the eval rather than against one query.
70
+ */
71
+ const MIN_CONFIDENT_COSINE = 0.32;
24
72
  /**
25
73
  * Weight of the recency/continuity tie-break in the relevance modes. Small
26
74
  * enough that it only ever separates candidates that are otherwise equal.
@@ -36,7 +84,22 @@ export class SqliteHandoffIndex {
36
84
  initialized;
37
85
  refreshPromise = null;
38
86
  lastRefreshMs = 0;
39
- refreshTtlMs = 5_000;
87
+ /**
88
+ * How long an indexed view is treated as current.
89
+ *
90
+ * A scan re-reads every transcript store on the machine, so at five seconds
91
+ * almost every tool call in a session started a fresh one and paid the wait
92
+ * budget again. The transcripts being read belong to sessions that ended
93
+ * before this one started; they do not change second to second.
94
+ */
95
+ refreshTtlMs = 30_000;
96
+ refreshBudgetMs;
97
+ vectorBudgetMs;
98
+ scanStartedMs = 0;
99
+ /** The embedding model is loading, so this answer came from keyword search alone. */
100
+ embeddingWarming = false;
101
+ /** Windows still waiting to be vectorized after the last search gave up its budget. */
102
+ vectorBacklog = 0;
40
103
  embeddingProvider;
41
104
  windowSize;
42
105
  windowStride;
@@ -48,32 +111,66 @@ export class SqliteHandoffIndex {
48
111
  options.embeddingProvider ?? new TransformersEmbeddingProvider(DEFAULT_EMBEDDING_MODEL);
49
112
  this.windowSize = Math.max(2, Math.floor(options.windowSize ?? DEFAULT_WINDOW_SIZE));
50
113
  this.windowStride = Math.max(1, Math.floor(options.windowStride ?? DEFAULT_WINDOW_STRIDE));
114
+ this.refreshBudgetMs = Math.max(0, options.refreshBudgetMs ?? DEFAULT_REFRESH_BUDGET_MS);
115
+ this.vectorBudgetMs = Math.max(0, options.vectorBudgetMs ?? DEFAULT_VECTOR_BUDGET_MS);
51
116
  this.initialized = this.initialize();
52
117
  // Attach a no-op handler so a failed open cannot become an unhandled
53
118
  // rejection (which would kill the process) before the first caller
54
119
  // awaits; each awaiter of `initialized` still observes the rejection.
55
120
  this.initialized.catch(() => { });
56
121
  }
57
- async listRecentSessions(limit, toolFilter) {
122
+ async listRecentSessions(limit, toolFilter, branchFilter) {
58
123
  await this.refresh({ toolFilter });
59
124
  const db = this.getDb();
60
125
  const normalizedLimit = normalizeLimit(limit, DEFAULT_LIMIT);
61
126
  const filters = normalizeToolFilter(toolFilter);
62
- const where = filters.length > 0 ? `WHERE tool IN (${placeholders(filters.length)})` : "";
127
+ const branches = normalizeToolFilter(branchFilter);
128
+ const clauses = [];
129
+ if (filters.length > 0) {
130
+ clauses.push(`tool IN (${placeholders(filters.length)})`);
131
+ }
132
+ if (branches.length > 0) {
133
+ // A session with no recorded branch is not evidence that it was on the
134
+ // requested one, so it is excluded rather than assumed in.
135
+ clauses.push(`git_branch IN (${placeholders(branches.length)})`);
136
+ }
137
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
63
138
  const rows = db
64
- .prepare(`SELECT session_ref, tool, started_at, last_activity_at, message_count, preview, source_path
139
+ .prepare(`SELECT session_ref, tool, started_at, last_activity_at, message_count, preview, source_path,
140
+ git_branch, git_commit
65
141
  FROM sessions
66
142
  ${where}
67
143
  ORDER BY last_activity_at DESC
68
144
  LIMIT ?`)
69
- .all(...filters, normalizedLimit);
145
+ .all(...filters, ...branches, normalizedLimit);
146
+ return rows.map(formatSessionRow);
147
+ }
148
+ /**
149
+ * What is already indexed, with no scan and no waiting.
150
+ *
151
+ * The SessionStart hook runs before the user has typed anything, so it
152
+ * cannot afford the scan `listRecentSessions` starts — even bounded, that
153
+ * is four seconds added to every agent startup. Priming with slightly
154
+ * stale context instantly beats priming with fresh context late.
155
+ */
156
+ async listIndexedSessions(limit) {
157
+ await this.initialized;
158
+ const db = this.getDb();
159
+ const rows = db
160
+ .prepare(`SELECT session_ref, tool, started_at, last_activity_at, message_count, preview, source_path,
161
+ git_branch, git_commit
162
+ FROM sessions
163
+ ORDER BY last_activity_at DESC
164
+ LIMIT ?`)
165
+ .all(normalizeLimit(limit, DEFAULT_LIMIT));
70
166
  return rows.map(formatSessionRow);
71
167
  }
72
168
  async getSessionByRef(sessionRef) {
73
169
  await this.refresh({ sessionRef });
74
170
  const db = this.getDb();
75
171
  const row = db
76
- .prepare(`SELECT session_ref, tool, started_at, last_activity_at, message_count, preview, source_path
172
+ .prepare(`SELECT session_ref, tool, started_at, last_activity_at, message_count, preview, source_path,
173
+ git_branch, git_commit
77
174
  FROM sessions
78
175
  WHERE session_ref = ?`)
79
176
  .get(sessionRef);
@@ -98,7 +195,7 @@ export class SqliteHandoffIndex {
98
195
  source_pointer: row.source_pointer ?? undefined,
99
196
  }));
100
197
  }
101
- async searchSessions(query, limit, toolFilter, mode = "hybrid") {
198
+ async searchSessions(query, limit, toolFilter, mode = "hybrid", branchFilter) {
102
199
  await this.refresh({ toolFilter });
103
200
  const trimmed = query.trim();
104
201
  if (!trimmed) {
@@ -106,10 +203,21 @@ export class SqliteHandoffIndex {
106
203
  }
107
204
  const normalizedMode = normalizeSearchMode(mode);
108
205
  if (normalizedMode === "keyword") {
109
- return this.keywordSearch(trimmed, limit, toolFilter);
206
+ return this.keywordSearch(trimmed, limit, toolFilter, branchFilter);
207
+ }
208
+ // Loading the embedding model is a one-off that takes minutes on a cold
209
+ // cache. Hybrid is the default mode, so blocking it on that made the first
210
+ // search of a session look broken. Start the load, answer from keyword,
211
+ // and let the next search use the model. An explicit `vector` request is a
212
+ // different matter: there is no other route, so that one waits.
213
+ if (normalizedMode === "hybrid" && this.embeddingProvider.isReady?.() === false) {
214
+ this.embeddingProvider.warm?.();
215
+ this.embeddingWarming = true;
216
+ return this.keywordSearch(trimmed, limit, toolFilter, branchFilter);
110
217
  }
218
+ this.embeddingWarming = false;
111
219
  try {
112
- const results = await this.semanticSearch(trimmed, limit, toolFilter, normalizedMode);
220
+ const results = await this.semanticSearch(trimmed, limit, toolFilter, normalizedMode, branchFilter);
113
221
  clearSetting(this.getDb(), "last_error:embeddings");
114
222
  return results;
115
223
  }
@@ -121,7 +229,7 @@ export class SqliteHandoffIndex {
121
229
  const message = error instanceof Error ? error.message : String(error);
122
230
  setSetting(this.getDb(), "last_error:embeddings", message);
123
231
  process.stderr.write(`xtctx: semantic search unavailable, using keyword only (${message})\n`);
124
- return this.keywordSearch(trimmed, limit, toolFilter);
232
+ return this.keywordSearch(trimmed, limit, toolFilter, branchFilter);
125
233
  }
126
234
  throw error;
127
235
  }
@@ -171,6 +279,10 @@ export class SqliteHandoffIndex {
171
279
  }
172
280
  async close() {
173
281
  await this.initialized.catch(() => { });
282
+ // A scan may still be running because a caller stopped waiting for it.
283
+ // Closing the database underneath it would turn an ordinary shutdown into
284
+ // a write to a closed handle.
285
+ await this.whenScanSettled();
174
286
  this.db?.close();
175
287
  this.db = null;
176
288
  }
@@ -183,14 +295,66 @@ export class SqliteHandoffIndex {
183
295
  return;
184
296
  }
185
297
  if (!this.refreshPromise) {
186
- this.refreshPromise = this.refreshNow().finally(() => {
298
+ const running = this.refreshNow().finally(() => {
187
299
  // Stamp the TTL on failure as well as success so a persistently
188
300
  // broken refresh backs off instead of re-running on every call.
189
301
  this.lastRefreshMs = Date.now();
190
302
  this.refreshPromise = null;
191
303
  });
304
+ // A caller may stop waiting on this promise, so it needs its own
305
+ // handler: an unhandled rejection would take the process down.
306
+ running.catch(() => { });
307
+ this.refreshPromise = running;
308
+ this.scanStartedMs = Date.now();
309
+ }
310
+ await this.waitWithBudget(this.refreshPromise);
311
+ }
312
+ /**
313
+ * Wait for the scan, but not past the budget. Nothing is cancelled on
314
+ * timeout — the scan keeps running and keeps committing — so a caller that
315
+ * stops waiting costs the index nothing, and the next call finds more.
316
+ */
317
+ async waitWithBudget(scan) {
318
+ if (this.refreshBudgetMs === 0) {
319
+ return;
320
+ }
321
+ // The budget is spent by the scan, not by each caller. Measuring it from
322
+ // when the scan started means one call pays the wait and the calls behind
323
+ // it return straight away with whatever has landed so far — rather than
324
+ // every call in a session paying the full budget over again.
325
+ const remaining = this.scanStartedMs + this.refreshBudgetMs - Date.now();
326
+ if (remaining <= 0) {
327
+ return;
328
+ }
329
+ let timer;
330
+ const budget = new Promise((resolve) => {
331
+ timer = setTimeout(resolve, remaining);
332
+ // Do not hold the process open just to enforce a deadline.
333
+ timer.unref?.();
334
+ });
335
+ try {
336
+ await Promise.race([scan.catch(() => { }), budget]);
337
+ }
338
+ finally {
339
+ clearTimeout(timer);
340
+ }
341
+ }
342
+ /** True when a scan started by an earlier call is still running. */
343
+ isScanning() {
344
+ return this.refreshPromise !== null;
345
+ }
346
+ getIndexProgress() {
347
+ return {
348
+ scanning: this.isScanning(),
349
+ vectorBacklog: this.vectorBacklog,
350
+ embeddingWarming: this.embeddingWarming,
351
+ };
352
+ }
353
+ /** Resolves when no scan is in flight. Used by close() and by tests. */
354
+ async whenScanSettled() {
355
+ while (this.refreshPromise) {
356
+ await this.refreshPromise.catch(() => { });
192
357
  }
193
- await this.refreshPromise;
194
358
  }
195
359
  async refreshNow() {
196
360
  const db = this.getDb();
@@ -259,6 +423,8 @@ export class SqliteHandoffIndex {
259
423
  chunk.tool,
260
424
  chunk.sessionId,
261
425
  this.projectRoot,
426
+ chunk.metadata?.gitBranch ?? null,
427
+ chunk.metadata?.gitCommit ?? null,
262
428
  timestamp,
263
429
  timestamp,
264
430
  sourcePointer,
@@ -345,9 +511,9 @@ export class SqliteHandoffIndex {
345
511
  }
346
512
  const db = this.getDb();
347
513
  const upsertSession = db.prepare(`INSERT INTO sessions
348
- (session_ref, tool, source_session_id, project_root, started_at, last_activity_at,
349
- message_count, preview, source_path, updated_at)
350
- VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?, ?)
514
+ (session_ref, tool, source_session_id, project_root, git_branch, git_commit,
515
+ started_at, last_activity_at, message_count, preview, source_path, updated_at)
516
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?)
351
517
  ON CONFLICT(session_ref) DO UPDATE SET
352
518
  started_at = CASE
353
519
  WHEN excluded.started_at < started_at THEN excluded.started_at
@@ -357,6 +523,10 @@ export class SqliteHandoffIndex {
357
523
  WHEN excluded.last_activity_at > last_activity_at THEN excluded.last_activity_at
358
524
  ELSE last_activity_at
359
525
  END,
526
+ -- First non-null wins: a session keeps the branch it started on
527
+ -- even if later records omit it.
528
+ git_branch = COALESCE(git_branch, excluded.git_branch),
529
+ git_commit = COALESCE(git_commit, excluded.git_commit),
360
530
  source_path = COALESCE(source_path, excluded.source_path),
361
531
  updated_at = excluded.updated_at`);
362
532
  const insertMessage = db.prepare(`INSERT OR IGNORE INTO messages
@@ -402,17 +572,21 @@ export class SqliteHandoffIndex {
402
572
  };
403
573
  return this.stmts;
404
574
  }
405
- async keywordSearch(query, limit, toolFilter) {
406
- const rows = this.queryKeywordUnits(query, limit, toolFilter);
575
+ async keywordSearch(query, limit, toolFilter, branchFilter) {
576
+ const rows = this.queryKeywordUnits(query, limit, toolFilter, branchFilter);
407
577
  // Rank by BM25 position so relevance, not recency, dominates ordering.
408
578
  return groupUnits(rows, rankKeywordRows(rows), "keyword", normalizeLimit(limit, DEFAULT_LIMIT));
409
579
  }
410
- async semanticSearch(query, limit, toolFilter, mode) {
580
+ async semanticSearch(query, limit, toolFilter, mode, branchFilter) {
411
581
  const normalizedLimit = normalizeLimit(limit, DEFAULT_LIMIT);
412
582
  await this.ensureVectors(toolFilter);
413
583
  const db = this.getDb();
414
584
  const filters = normalizeToolFilter(toolFilter);
415
585
  const toolWhere = filters.length > 0 ? `AND u.tool IN (${placeholders(filters.length)})` : "";
586
+ const branches = normalizeToolFilter(branchFilter);
587
+ // Sessions with no recorded branch are excluded rather than assumed in:
588
+ // no branch is not evidence of this branch.
589
+ const branchWhere = branches.length > 0 ? `AND s.git_branch IN (${placeholders(branches.length)})` : "";
416
590
  const rows = db
417
591
  .prepare(`${retrievalUnitSelect()},
418
592
  v.vector,
@@ -420,12 +594,12 @@ export class SqliteHandoffIndex {
420
594
  FROM retrieval_units u
421
595
  JOIN retrieval_unit_vectors v ON v.unit_id = u.id
422
596
  JOIN sessions s ON s.session_ref = u.session_ref
423
- WHERE v.model = ? ${toolWhere}`)
424
- .all(this.embeddingProvider.model, ...filters);
597
+ WHERE v.model = ? ${toolWhere} ${branchWhere}`)
598
+ .all(this.embeddingProvider.model, ...filters, ...branches);
425
599
  if (rows.length === 0) {
426
600
  return [];
427
601
  }
428
- const keywordRows = mode === "hybrid" ? this.queryKeywordUnits(query, limit, toolFilter) : [];
602
+ const keywordRows = mode === "hybrid" ? this.queryKeywordUnits(query, limit, toolFilter, branchFilter) : [];
429
603
  const keywordScores = rankKeywordRows(keywordRows);
430
604
  const queryVector = await this.embeddingProvider.embed(query);
431
605
  const timeRange = getTimeRange(rows.map((row) => row.ended_at));
@@ -446,30 +620,48 @@ export class SqliteHandoffIndex {
446
620
  // on semantic similarity or a keyword match; "no matching sessions" is
447
621
  // a more useful answer than a nearest vector.
448
622
  .filter((item) => item.rawCosine >= MIN_SEMANTIC_COSINE || item.keywordScore > 0);
449
- // Rescale cosine across this query's surviving candidates before blending.
450
- // Mapping [-1,1] onto [0,1] globally left every candidate bunched near
451
- // 0.5, so the semantic term barely varied while recency swung across its
452
- // whole range recency effectively outranked relevance. Measured on the
453
- // ranking eval, this one change took hybrid from MRR 0.488 / recall@5
454
- // 0.65 to 0.621 / 0.85.
455
- const cosines = candidates.map((item) => item.rawCosine);
623
+ // Nothing here is actually similar to the query keep only what matched
624
+ // on words. For a query that means nothing to this corpus that leaves
625
+ // nothing at all, which is the answer.
626
+ const bestCosine = candidates.reduce((best, item) => Math.max(best, item.rawCosine), 0);
627
+ const semanticallyConfident = bestCosine >= MIN_CONFIDENT_COSINE;
628
+ const surviving = semanticallyConfident
629
+ ? candidates
630
+ : candidates.filter((item) => item.keywordScore > 0);
631
+ if (surviving.length === 0) {
632
+ return [];
633
+ }
634
+ // Ordering and reporting are two different jobs, and conflating them is
635
+ // what made the score meaningless.
636
+ //
637
+ // Ordering wants contrast: rescaling this query's survivors onto [0,1]
638
+ // spreads them out and measurably ranks better (hybrid MRR 0.566 -> 0.613
639
+ // on the eval). Reporting wants an absolute: the rescale forces the best
640
+ // survivor to exactly 1.0 however weak it is, so three nonsense words
641
+ // scored 0.901 against a real query's 0.872 and an agent had no way to
642
+ // tell a find from a shrug.
643
+ //
644
+ // So candidates are ranked on the rescaled value and reported with the
645
+ // cosine itself.
646
+ const cosines = surviving.map((item) => item.rawCosine);
456
647
  const lowest = Math.min(...cosines);
457
648
  const highest = Math.max(...cosines);
458
649
  const spread = highest - lowest;
459
- const scored = candidates
650
+ const scored = surviving
460
651
  .map((item) => {
461
652
  // A lone survivor is the best match by definition, not the worst.
462
653
  const semanticScore = spread > 0 ? (item.rawCosine - lowest) / spread : 1;
463
654
  return {
464
655
  ...item,
465
656
  semanticScore,
657
+ relevance: Math.max(0, Math.min(1, item.rawCosine)),
466
658
  score: blendScores(mode, semanticScore, item.keywordScore, item.recencyScore, item.continuityScore),
467
659
  };
468
660
  })
469
661
  .sort((left, right) => right.score - left.score);
470
662
  return groupScoredUnits(scored, mode, normalizedLimit);
471
663
  }
472
- queryKeywordUnits(query, limit, toolFilter) {
664
+ queryKeywordUnits(query, limit, toolFilter, branchFilter) {
473
665
  const ftsQuery = toFtsQuery(query);
474
666
  if (!ftsQuery) {
475
667
  return [];
@@ -478,15 +670,19 @@ export class SqliteHandoffIndex {
478
670
  const normalizedLimit = normalizeLimit(limit, DEFAULT_LIMIT);
479
671
  const filters = normalizeToolFilter(toolFilter);
480
672
  const toolWhere = filters.length > 0 ? `AND u.tool IN (${placeholders(filters.length)})` : "";
673
+ const branches = normalizeToolFilter(branchFilter);
674
+ // Sessions with no recorded branch are excluded rather than assumed in:
675
+ // no branch is not evidence of this branch.
676
+ const branchWhere = branches.length > 0 ? `AND s.git_branch IN (${placeholders(branches.length)})` : "";
481
677
  return db
482
678
  .prepare(`${retrievalUnitSelect()}
483
679
  FROM retrieval_units_fts f
484
680
  JOIN retrieval_units u ON u.id = f.unit_id
485
681
  JOIN sessions s ON s.session_ref = u.session_ref
486
- WHERE retrieval_units_fts MATCH ? ${toolWhere}
682
+ WHERE retrieval_units_fts MATCH ? ${toolWhere} ${branchWhere}
487
683
  ORDER BY bm25(retrieval_units_fts), u.ended_at DESC
488
684
  LIMIT ?`)
489
- .all(ftsQuery, ...filters, normalizedLimit * MAX_MATCHES_PER_SESSION);
685
+ .all(ftsQuery, ...filters, ...branches, normalizedLimit * MAX_MATCHES_PER_SESSION);
490
686
  }
491
687
  async ensureVectors(toolFilter) {
492
688
  const db = this.getDb();
@@ -502,6 +698,7 @@ export class SqliteHandoffIndex {
502
698
  WHERE v.unit_id IS NULL ${toolWhere}
503
699
  ORDER BY u.ended_at DESC`)
504
700
  .all(this.embeddingProvider.model, ...filters);
701
+ this.vectorBacklog = 0;
505
702
  if (rows.length === 0) {
506
703
  return;
507
704
  }
@@ -515,8 +712,19 @@ export class SqliteHandoffIndex {
515
712
  created_at = excluded.created_at`);
516
713
  // Bounded batches keep memory flat on a first-time index of a large
517
714
  // history, and each batch commits before the next one embeds.
518
- const unitBatchSize = 64;
715
+ // Small enough that the budget below can actually bite. At 64 windows a
716
+ // single batch took 20-30s on the real index, so the deadline — checked
717
+ // between batches — could not stop a search from blowing straight past it.
718
+ const unitBatchSize = 8;
719
+ // Answer with the vectors that exist rather than making the caller wait
720
+ // for the whole corpus. Every batch below commits before the next starts,
721
+ // so an unfinished pass is progress, not wasted work.
722
+ const deadline = this.vectorBudgetMs > 0 ? Date.now() + this.vectorBudgetMs : Infinity;
519
723
  for (let start = 0; start < rows.length; start += unitBatchSize) {
724
+ if (Date.now() >= deadline) {
725
+ this.vectorBacklog = rows.length - start;
726
+ break;
727
+ }
520
728
  const batch = rows.slice(start, start + unitBatchSize);
521
729
  // Long windows are segmented to the model's sequence budget and
522
730
  // mean-pooled, so content beyond the window's opening still shapes
@@ -615,6 +823,8 @@ function createSchema(db) {
615
823
  tool TEXT NOT NULL,
616
824
  source_session_id TEXT NOT NULL,
617
825
  project_root TEXT NOT NULL,
826
+ git_branch TEXT,
827
+ git_commit TEXT,
618
828
  started_at TEXT NOT NULL,
619
829
  last_activity_at TEXT NOT NULL,
620
830
  message_count INTEGER NOT NULL DEFAULT 0,
@@ -707,6 +917,8 @@ function formatSessionRow(row) {
707
917
  message_count: row.message_count,
708
918
  preview: row.preview ?? undefined,
709
919
  source_path: row.source_path ?? undefined,
920
+ git_branch: row.git_branch ?? undefined,
921
+ git_commit: row.git_commit ?? undefined,
710
922
  };
711
923
  }
712
924
  function buildMessageWindows(messages, windowSize, windowStride) {
@@ -751,6 +963,10 @@ function groupUnits(rows, keywordScores, retrieval, limit) {
751
963
  return {
752
964
  row,
753
965
  score: blendScores("keyword", 0, keywordScore, recencyScore, continuityScore),
966
+ // Deliberately no relevance: keyword scores are reciprocal rank, so the
967
+ // top FTS hit is 1.0 whatever it actually matched. Reporting that as a
968
+ // strength of match is the same lie the cosine rescale was telling.
969
+ relevance: undefined,
754
970
  semanticScore: 0,
755
971
  keywordScore,
756
972
  recencyScore,
@@ -760,6 +976,10 @@ function groupUnits(rows, keywordScores, retrieval, limit) {
760
976
  return groupScoredUnits(scored, retrieval, limit);
761
977
  }
762
978
  function groupScoredUnits(scored, retrieval, limit) {
979
+ // `score` orders; `relevance` is what the caller is told. Kept apart here so
980
+ // the ranking the eval measures and the number an agent reads about a match
981
+ // can each be the right thing.
982
+ const ranks = new Map();
763
983
  const sessions = new Map();
764
984
  for (const item of scored) {
765
985
  const existing = sessions.get(item.row.session_ref);
@@ -768,9 +988,14 @@ function groupScoredUnits(scored, retrieval, limit) {
768
988
  if ((existing.matches?.length ?? 0) < MAX_MATCHES_PER_SESSION) {
769
989
  existing.matches = [...(existing.matches ?? []), match];
770
990
  }
771
- existing.score = Math.max(existing.score ?? 0, item.score);
991
+ existing.score =
992
+ item.relevance === undefined
993
+ ? existing.score
994
+ : Math.max(existing.score ?? 0, item.relevance);
995
+ ranks.set(item.row.session_ref, Math.max(ranks.get(item.row.session_ref) ?? 0, item.score));
772
996
  continue;
773
997
  }
998
+ ranks.set(item.row.session_ref, item.score);
774
999
  sessions.set(item.row.session_ref, {
775
1000
  session_ref: item.row.session_ref,
776
1001
  tool: item.row.tool,
@@ -779,7 +1004,7 @@ function groupScoredUnits(scored, retrieval, limit) {
779
1004
  message_count: item.row.session_message_count,
780
1005
  preview: item.row.session_preview ?? previewText(item.row.content),
781
1006
  source_path: item.row.source_path ?? undefined,
782
- score: item.score,
1007
+ score: item.relevance,
783
1008
  retrieval,
784
1009
  matches: [match],
785
1010
  });
@@ -787,7 +1012,7 @@ function groupScoredUnits(scored, retrieval, limit) {
787
1012
  break;
788
1013
  }
789
1014
  }
790
- return [...sessions.values()].sort((left, right) => (right.score ?? 0) - (left.score ?? 0));
1015
+ return [...sessions.values()].sort((left, right) => (ranks.get(right.session_ref) ?? 0) - (ranks.get(left.session_ref) ?? 0));
791
1016
  }
792
1017
  function formatMatch(item) {
793
1018
  return {