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.
- package/CHANGELOG.md +58 -0
- package/dist/src/cli/hook.d.ts.map +1 -1
- package/dist/src/cli/hook.js +25 -14
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/index.d.ts.map +1 -1
- package/dist/src/cli/index.js +16 -1
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/cli/setup.d.ts.map +1 -1
- package/dist/src/cli/setup.js +1 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/status.d.ts.map +1 -1
- package/dist/src/cli/status.js +54 -5
- package/dist/src/cli/status.js.map +1 -1
- package/dist/src/config/disconnect.d.ts +2 -0
- package/dist/src/config/disconnect.d.ts.map +1 -1
- package/dist/src/config/disconnect.js +109 -27
- package/dist/src/config/disconnect.js.map +1 -1
- package/dist/src/config/managed-block.d.ts +29 -0
- package/dist/src/config/managed-block.d.ts.map +1 -0
- package/dist/src/config/managed-block.js +67 -0
- package/dist/src/config/managed-block.js.map +1 -0
- package/dist/src/config/mcp-config.d.ts +2 -0
- package/dist/src/config/mcp-config.d.ts.map +1 -1
- package/dist/src/config/mcp-config.js +101 -7
- package/dist/src/config/mcp-config.js.map +1 -1
- package/dist/src/config/setup.d.ts +2 -0
- package/dist/src/config/setup.d.ts.map +1 -1
- package/dist/src/config/setup.js +78 -37
- package/dist/src/config/setup.js.map +1 -1
- package/dist/src/config/skills.d.ts.map +1 -1
- package/dist/src/config/skills.js +4 -4
- package/dist/src/config/skills.js.map +1 -1
- package/dist/src/handoff/embeddings.d.ts +11 -0
- package/dist/src/handoff/embeddings.d.ts.map +1 -1
- package/dist/src/handoff/embeddings.js +74 -5
- package/dist/src/handoff/embeddings.js.map +1 -1
- package/dist/src/handoff/sqlite-index.d.ts +9 -0
- package/dist/src/handoff/sqlite-index.d.ts.map +1 -1
- package/dist/src/handoff/sqlite-index.js +285 -98
- package/dist/src/handoff/sqlite-index.js.map +1 -1
- package/dist/src/handoff/types.d.ts +6 -0
- package/dist/src/handoff/types.d.ts.map +1 -1
- package/dist/src/mcp/server.d.ts +1 -1
- package/dist/src/mcp/server.d.ts.map +1 -1
- package/dist/src/mcp/server.js +17 -8
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/continuity.d.ts +19 -1
- package/dist/src/mcp/tools/continuity.d.ts.map +1 -1
- package/dist/src/mcp/tools/continuity.js +27 -2
- package/dist/src/mcp/tools/continuity.js.map +1 -1
- package/dist/src/mcp/tools/manifest.d.ts.map +1 -1
- package/dist/src/mcp/tools/manifest.js +15 -6
- package/dist/src/mcp/tools/manifest.js.map +1 -1
- package/dist/src/mcp/tools/sessions.d.ts +9 -1
- package/dist/src/mcp/tools/sessions.d.ts.map +1 -1
- package/dist/src/mcp/tools/sessions.js +68 -9
- package/dist/src/mcp/tools/sessions.js.map +1 -1
- package/dist/src/runtime/services.d.ts.map +1 -1
- package/dist/src/runtime/services.js +22 -2
- package/dist/src/runtime/services.js.map +1 -1
- package/dist/src/scrapers/antigravity.d.ts +11 -0
- package/dist/src/scrapers/antigravity.d.ts.map +1 -1
- package/dist/src/scrapers/antigravity.js +47 -36
- package/dist/src/scrapers/antigravity.js.map +1 -1
- package/dist/src/scrapers/base.d.ts.map +1 -1
- package/dist/src/scrapers/base.js +4 -1
- package/dist/src/scrapers/base.js.map +1 -1
- package/dist/src/scrapers/claude-code.d.ts +17 -0
- package/dist/src/scrapers/claude-code.d.ts.map +1 -1
- package/dist/src/scrapers/claude-code.js +115 -59
- 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 +166 -153
- package/dist/src/scrapers/codex.js.map +1 -1
- package/dist/src/scrapers/copilot-cli.d.ts +2 -1
- package/dist/src/scrapers/copilot-cli.d.ts.map +1 -1
- package/dist/src/scrapers/copilot-cli.js +88 -3
- package/dist/src/scrapers/copilot-cli.js.map +1 -1
- package/dist/src/scrapers/cursor.d.ts.map +1 -1
- package/dist/src/scrapers/cursor.js +20 -9
- package/dist/src/scrapers/cursor.js.map +1 -1
- package/dist/src/scrapers/index.d.ts +7 -2
- package/dist/src/scrapers/index.d.ts.map +1 -1
- package/dist/src/scrapers/index.js +7 -2
- package/dist/src/scrapers/index.js.map +1 -1
- package/dist/src/scrapers/opencode.d.ts +2 -1
- package/dist/src/scrapers/opencode.d.ts.map +1 -1
- package/dist/src/scrapers/opencode.js +38 -9
- package/dist/src/scrapers/opencode.js.map +1 -1
- package/dist/src/tools/sources.d.ts.map +1 -1
- package/dist/src/tools/sources.js +2 -2
- package/dist/src/tools/sources.js.map +1 -1
- package/dist/src/utils/atomic-file.d.ts +8 -0
- package/dist/src/utils/atomic-file.d.ts.map +1 -0
- package/dist/src/utils/atomic-file.js +15 -0
- package/dist/src/utils/atomic-file.js.map +1 -0
- package/dist/src/utils/errors.d.ts +5 -0
- package/dist/src/utils/errors.d.ts.map +1 -1
- package/dist/src/utils/errors.js +9 -0
- package/dist/src/utils/errors.js.map +1 -1
- package/dist/src/utils/project-scope.d.ts +16 -0
- package/dist/src/utils/project-scope.d.ts.map +1 -1
- package/dist/src/utils/project-scope.js +18 -2
- package/dist/src/utils/project-scope.js.map +1 -1
- 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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
258
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
289
|
-
db.prepare(`INSERT INTO retrieval_units_fts(unit_id, session_ref, tool, content)
|
|
290
|
-
VALUES (?, ?, ?, ?)`)
|
|
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
|
-
|
|
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
|
|
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),
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
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) ??
|
|
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;
|