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.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/dist/src/cli/disconnect.d.ts +1 -0
- package/dist/src/cli/disconnect.d.ts.map +1 -1
- package/dist/src/cli/disconnect.js +23 -1
- package/dist/src/cli/disconnect.js.map +1 -1
- package/dist/src/cli/hook.d.ts.map +1 -1
- package/dist/src/cli/hook.js +53 -5
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/status.d.ts +19 -0
- package/dist/src/cli/status.d.ts.map +1 -1
- package/dist/src/cli/status.js +35 -9
- package/dist/src/cli/status.js.map +1 -1
- package/dist/src/handoff/embeddings.d.ts +14 -0
- package/dist/src/handoff/embeddings.d.ts.map +1 -1
- package/dist/src/handoff/embeddings.js +19 -0
- package/dist/src/handoff/embeddings.js.map +1 -1
- package/dist/src/handoff/sqlite-index.d.ts +42 -3
- package/dist/src/handoff/sqlite-index.d.ts.map +1 -1
- package/dist/src/handoff/sqlite-index.js +263 -38
- package/dist/src/handoff/sqlite-index.js.map +1 -1
- package/dist/src/handoff/types.d.ts +36 -2
- package/dist/src/handoff/types.d.ts.map +1 -1
- package/dist/src/mcp/server.d.ts.map +1 -1
- package/dist/src/mcp/server.js +15 -0
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/manifest.d.ts.map +1 -1
- package/dist/src/mcp/tools/manifest.js +1 -1
- package/dist/src/mcp/tools/manifest.js.map +1 -1
- package/dist/src/mcp/tools/sessions.d.ts +2 -0
- package/dist/src/mcp/tools/sessions.d.ts.map +1 -1
- package/dist/src/mcp/tools/sessions.js +39 -6
- package/dist/src/mcp/tools/sessions.js.map +1 -1
- package/dist/src/scrapers/antigravity.d.ts +1 -0
- package/dist/src/scrapers/antigravity.d.ts.map +1 -1
- package/dist/src/scrapers/antigravity.js +30 -5
- package/dist/src/scrapers/antigravity.js.map +1 -1
- package/dist/src/scrapers/claude-code.d.ts +1 -0
- package/dist/src/scrapers/claude-code.d.ts.map +1 -1
- package/dist/src/scrapers/claude-code.js +24 -13
- package/dist/src/scrapers/claude-code.js.map +1 -1
- package/dist/src/scrapers/codex.d.ts.map +1 -1
- package/dist/src/scrapers/codex.js +22 -5
- package/dist/src/scrapers/codex.js.map +1 -1
- package/dist/src/scrapers/copilot-cli.d.ts.map +1 -1
- package/dist/src/scrapers/copilot-cli.js +22 -5
- package/dist/src/scrapers/copilot-cli.js.map +1 -1
- package/dist/src/scrapers/copilot.d.ts.map +1 -1
- package/dist/src/scrapers/copilot.js +5 -5
- package/dist/src/scrapers/copilot.js.map +1 -1
- package/dist/src/scrapers/cursor.d.ts.map +1 -1
- package/dist/src/scrapers/cursor.js +5 -5
- package/dist/src/scrapers/cursor.js.map +1 -1
- package/dist/src/scrapers/drift-log.d.ts +15 -0
- package/dist/src/scrapers/drift-log.d.ts.map +1 -0
- package/dist/src/scrapers/drift-log.js +81 -0
- package/dist/src/scrapers/drift-log.js.map +1 -0
- package/dist/src/scrapers/opencode.d.ts.map +1 -1
- package/dist/src/scrapers/opencode.js +5 -5
- package/dist/src/scrapers/opencode.js.map +1 -1
- package/dist/src/types/scraper.d.ts +13 -0
- package/dist/src/types/scraper.d.ts.map +1 -1
- 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 =
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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 {
|