@mxalbert/context-mode 2.0.1 → 2.0.3
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +1 -1
- package/build/adapters/opencode/plugin.d.ts +60 -2
- package/build/adapters/opencode/plugin.js +89 -1
- package/build/adapters/opencode/v2.d.ts +11 -0
- package/build/db-base.d.ts +16 -0
- package/build/db-base.js +29 -3
- package/build/server.js +98 -46
- package/build/session/purge.js +2 -1
- package/build/store.d.ts +22 -3
- package/build/store.js +282 -73
- package/cli.bundle.mjs +165 -165
- package/configs/antigravity-cli/plugin.json +1 -1
- package/configs/copilot-cli/.github/plugin/plugin.json +1 -1
- package/hooks/core/routing.mjs +168 -5
- package/hooks/session-db.bundle.mjs +7 -7
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +128 -128
package/build/store.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* you need EXACT text later — not summaries.
|
|
9
9
|
*/
|
|
10
10
|
var _a;
|
|
11
|
-
import { loadDatabase, applyWALPragmas, closeDB, cleanOrphanedWALFiles, withRetry, deleteDBFiles, isSQLiteCorruptionError } from "./db-base.js";
|
|
11
|
+
import { loadDatabase, applyWALPragmas, closeDB, cleanOrphanedWALFiles, withRetry, deleteDBFiles, isSQLiteCorruptionError, logDbError, extractErrorCode, errorMessage } from "./db-base.js";
|
|
12
12
|
import { readFileSync, readdirSync, unlinkSync, existsSync, statSync, openSync, fstatSync, closeSync } from "node:fs";
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
@@ -158,24 +158,30 @@ export function cleanupStaleDBs() {
|
|
|
158
158
|
catch { /* ignore readdir errors */ }
|
|
159
159
|
return cleaned;
|
|
160
160
|
}
|
|
161
|
-
/**
|
|
162
|
-
* Check if a PID is still alive (not a zombie holding a WAL lock).
|
|
163
|
-
* Returns true if the process exists, false if it's dead.
|
|
164
|
-
*/
|
|
165
|
-
function isProcessAlive(pid) {
|
|
166
|
-
try {
|
|
167
|
-
process.kill(pid, 0);
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
161
|
/**
|
|
175
162
|
* Clean up stale per-project content store DBs older than maxAgeDays.
|
|
176
|
-
* Scans the given directory for *.db files and
|
|
177
|
-
*
|
|
178
|
-
*
|
|
163
|
+
* Scans the given directory for *.db files and unlinks each (plus its
|
|
164
|
+
* -wal/-shm sidecars) when its EFFECTIVE last-write time — the newer of the
|
|
165
|
+
* main .db mtime and a non-empty -wal mtime — exceeds the cutoff.
|
|
166
|
+
*
|
|
167
|
+
* Why the -wal mtime participates: in WAL mode commits touch only the -wal
|
|
168
|
+
* file; the main .db mtime advances at checkpoint. A main-only check can
|
|
169
|
+
* delete an actively-used store that simply hasn't checkpointed within the
|
|
170
|
+
* window. Conversely, a non-empty -wal older than some threshold is NOT
|
|
171
|
+
* proof the owning process is dead — a live-but-idle connection (a session
|
|
172
|
+
* quiet over lunch) is indistinguishable from a crashed process by mtime
|
|
173
|
+
* alone. The pre-fix heuristic deleted such DBs unconditionally after 1h,
|
|
174
|
+
* which on macOS unlinks the files under an open connection and surfaces
|
|
175
|
+
* as disk I/O error (SQLITE_IOERR_VNODE) on every later write — no amount
|
|
176
|
+
* of retrying recovers an invalidated fd.
|
|
177
|
+
*
|
|
178
|
+
* Contract: a -wal can only EXTEND a DB's life (its mtime counts as the
|
|
179
|
+
* latest write), never shorten it. A WAL never triggers deletion on its
|
|
180
|
+
* own — the way the old 1-hour zombie rule did — and once the whole DB
|
|
181
|
+
* (main + non-empty wal) is beyond maxAgeDays, the caller's retention
|
|
182
|
+
* policy owns the consequences. Callers that cannot tolerate deleting a
|
|
183
|
+
* possibly-live DB (the per-platform content dir — see getStore() in
|
|
184
|
+
* src/server.ts) must not call this with a nonzero maxAgeDays.
|
|
179
185
|
*/
|
|
180
186
|
export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
|
|
181
187
|
let cleaned = 0;
|
|
@@ -187,26 +193,18 @@ export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
|
|
|
187
193
|
for (const file of files) {
|
|
188
194
|
try {
|
|
189
195
|
const filePath = join(contentDir, file);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
try {
|
|
199
|
-
const walStat = statSync(walPath);
|
|
200
|
-
// If WAL file is non-empty and DB hasn't been modified in >1 hour,
|
|
201
|
-
// the owning process may be dead — check via mtime staleness
|
|
202
|
-
if (walStat.size > 0 && (Date.now() - walStat.mtimeMs) > 3600_000) {
|
|
203
|
-
shouldClean = true;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
catch { /* ignore WAL check errors */ }
|
|
196
|
+
// Effective last-write: newer of main .db and non-empty -wal. A fresh
|
|
197
|
+
// WAL is evidence of a live (or recently-live) connection — it can
|
|
198
|
+
// only push the cutoff later, never mark a fresh DB stale.
|
|
199
|
+
let effectiveMs = statSync(filePath).mtimeMs;
|
|
200
|
+
try {
|
|
201
|
+
const walStat = statSync(filePath + "-wal");
|
|
202
|
+
if (walStat.size > 0 && walStat.mtimeMs > effectiveMs) {
|
|
203
|
+
effectiveMs = walStat.mtimeMs;
|
|
207
204
|
}
|
|
208
205
|
}
|
|
209
|
-
|
|
206
|
+
catch { /* no -wal — main mtime stands */ }
|
|
207
|
+
if (effectiveMs < cutoff) {
|
|
210
208
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
211
209
|
try {
|
|
212
210
|
unlinkSync(filePath + suffix);
|
|
@@ -302,6 +300,65 @@ function findMinSpan(positionLists) {
|
|
|
302
300
|
}
|
|
303
301
|
return minSpan;
|
|
304
302
|
}
|
|
303
|
+
// ─────────────────────────────────────────────────────────
|
|
304
|
+
// Store error handling (v2.0.2 — disk-I/O hardening parity with db-base)
|
|
305
|
+
// ─────────────────────────────────────────────────────────
|
|
306
|
+
/**
|
|
307
|
+
* Prefix for tool-visible ContentStore failures. Store failures used to
|
|
308
|
+
* surface as the raw driver message — for SQLITE_IOERR exactly
|
|
309
|
+
* "disk I/O error", with no op, no DB path, no SQLite code, and nothing
|
|
310
|
+
* in any log (the store layer's catches were all silent). Every store
|
|
311
|
+
* open/write failure now (a) logs to stderr via db-base's logDbError
|
|
312
|
+
* (same `[context-mode:db]` prefix, 30s dedupe, 256-key cap) and
|
|
313
|
+
* (b) rethrows with this prefix plus op, path, and code so the isError
|
|
314
|
+
* text the MCP client sees is actionable.
|
|
315
|
+
*/
|
|
316
|
+
const STORE_ERROR_PREFIX = "[context-mode:store]";
|
|
317
|
+
/**
|
|
318
|
+
* Symbol marking Errors that this layer already reported via logDbError
|
|
319
|
+
* (every storeFailure product carries it). Symbol.for so the flag survives
|
|
320
|
+
* module re-imports, mirroring db-base's global-symbol pattern.
|
|
321
|
+
*/
|
|
322
|
+
const kDbErrorLogged = Symbol.for("__context_mode_db_error_logged__");
|
|
323
|
+
/** True when `err` was already logged + enriched by an inner store op —
|
|
324
|
+
* outer catches must not emit a second line for the same failure. */
|
|
325
|
+
function isDbErrorAlreadyLogged(err) {
|
|
326
|
+
try {
|
|
327
|
+
return (typeof err === "object" &&
|
|
328
|
+
err !== null &&
|
|
329
|
+
err[kDbErrorLogged] === true);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Log `err` for store operation `op` and return an Error whose message
|
|
337
|
+
* carries the op, the DB file path, and the SQLite code (when present):
|
|
338
|
+
* `[context-mode:store] <op> failed on <dbPath>: <message> (<code>)`.
|
|
339
|
+
* The returned Error preserves the original `.code` and carries a
|
|
340
|
+
* non-enumerable kDbErrorLogged marker: when it flows through another
|
|
341
|
+
* storeFailure call (e.g. a failed stale re-index surfacing through an
|
|
342
|
+
* outer wrapper) it is returned as-is, so one underlying failure produces
|
|
343
|
+
* exactly one `[context-mode:db]` line, under the op that owns it.
|
|
344
|
+
* `base` replaces `<message>` for callers that must preserve an existing
|
|
345
|
+
* wrap message (the corruption delete-and-recreate retry). Callers throw
|
|
346
|
+
* the result. Logging goes through db-base's logDbError (stderr), never
|
|
347
|
+
* console.log, and logging itself never throws.
|
|
348
|
+
*/
|
|
349
|
+
function storeFailure(op, err, dbPath, base) {
|
|
350
|
+
if (isDbErrorAlreadyLogged(err))
|
|
351
|
+
return err;
|
|
352
|
+
logDbError(op, err, dbPath);
|
|
353
|
+
const message = errorMessage(err);
|
|
354
|
+
const code = extractErrorCode(err);
|
|
355
|
+
const codeSuffix = code ? ` (${code})` : "";
|
|
356
|
+
const enriched = new Error(`${STORE_ERROR_PREFIX} ${op} failed on ${dbPath}: ${base ?? message}${codeSuffix}`);
|
|
357
|
+
if (code)
|
|
358
|
+
enriched.code = code;
|
|
359
|
+
Object.defineProperty(enriched, kDbErrorLogged, { value: true, enumerable: false });
|
|
360
|
+
return enriched;
|
|
361
|
+
}
|
|
305
362
|
export class ContentStore {
|
|
306
363
|
#db;
|
|
307
364
|
#dbPath;
|
|
@@ -367,31 +424,55 @@ export class ContentStore {
|
|
|
367
424
|
this.#dbPath =
|
|
368
425
|
dbPath ?? join(tmpdir(), `context-mode-${process.pid}.db`);
|
|
369
426
|
cleanOrphanedWALFiles(this.#dbPath);
|
|
427
|
+
// One open attempt: create the connection and apply WAL pragmas.
|
|
428
|
+
// Wrapped in withRetry below so a transient SQLITE_IOERR / "disk I/O
|
|
429
|
+
// error" on open is retried with the same exponential backoff as
|
|
430
|
+
// SQLITE_BUSY (db-base, v1.0.187 hardening). Corruption signatures are
|
|
431
|
+
// never retried by withRetry — they rethrow for the recovery path.
|
|
432
|
+
const openOnce = () => {
|
|
433
|
+
const db = new Database(this.#dbPath, { timeout: 30000 });
|
|
434
|
+
applyWALPragmas(db);
|
|
435
|
+
return db;
|
|
436
|
+
};
|
|
370
437
|
let db;
|
|
371
438
|
try {
|
|
372
|
-
db =
|
|
373
|
-
applyWALPragmas(db);
|
|
439
|
+
db = withRetry(openOnce);
|
|
374
440
|
}
|
|
375
441
|
catch (err) {
|
|
376
442
|
const msg = err instanceof Error ? err.message : String(err);
|
|
377
443
|
if (isSQLiteCorruptionError(msg)) {
|
|
444
|
+
// Surface the corruption + delete-and-recreate instead of silently
|
|
445
|
+
// swapping the file — users lose indexed content and deserve a trace.
|
|
446
|
+
logDbError("store.open", err, this.#dbPath);
|
|
378
447
|
deleteDBFiles(this.#dbPath);
|
|
379
448
|
cleanOrphanedWALFiles(this.#dbPath);
|
|
380
449
|
try {
|
|
381
|
-
db =
|
|
382
|
-
applyWALPragmas(db);
|
|
450
|
+
db = withRetry(openOnce);
|
|
383
451
|
}
|
|
384
452
|
catch (retryErr) {
|
|
385
|
-
|
|
453
|
+
// Preserve the existing corruption-retry wrap message, enriched
|
|
454
|
+
// with op + path + code so the tool error is never bare.
|
|
455
|
+
throw storeFailure("store.open", retryErr, this.#dbPath, `Failed to create fresh DB after deleting corrupt file: ${errorMessage(retryErr)}`);
|
|
386
456
|
}
|
|
387
457
|
}
|
|
388
458
|
else {
|
|
389
|
-
|
|
459
|
+
// Transient-retry exhaustion or a non-transient open failure —
|
|
460
|
+
// either way the caller gets op + path + code, and stderr gets a
|
|
461
|
+
// [context-mode:db] line (deduped).
|
|
462
|
+
throw storeFailure("store.open", err, this.#dbPath);
|
|
390
463
|
}
|
|
391
464
|
}
|
|
392
465
|
this.#db = db;
|
|
393
|
-
|
|
394
|
-
|
|
466
|
+
try {
|
|
467
|
+
this.#initSchema();
|
|
468
|
+
this.#prepareStatements();
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
// The connection is already open here, so there is no retry contract
|
|
472
|
+
// for schema init — but the failure must not be silent: log the
|
|
473
|
+
// [context-mode:db] line and rethrow with op + path + code context.
|
|
474
|
+
throw storeFailure("store.initSchema", err, this.#dbPath);
|
|
475
|
+
}
|
|
395
476
|
}
|
|
396
477
|
/** Delete this session's DB files. Call on process exit. */
|
|
397
478
|
cleanup() {
|
|
@@ -781,7 +862,16 @@ export class ContentStore {
|
|
|
781
862
|
// Stale detection: store file_path + SHA-256 for file-backed sources
|
|
782
863
|
const filePath = path ?? undefined;
|
|
783
864
|
const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
|
|
784
|
-
|
|
865
|
+
// withRetry absorbs transient SQLITE_BUSY/SQLITE_IOERR with exponential
|
|
866
|
+
// backoff; on exhaustion the failure is logged ([context-mode:db]) and
|
|
867
|
+
// rethrown with op + path + code so the tool error is never a bare
|
|
868
|
+
// "disk I/O error".
|
|
869
|
+
try {
|
|
870
|
+
return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
|
|
871
|
+
}
|
|
872
|
+
catch (err) {
|
|
873
|
+
throw storeFailure("store.index", err, this.#dbPath);
|
|
874
|
+
}
|
|
785
875
|
}
|
|
786
876
|
// ── Index Directory (#687) ──
|
|
787
877
|
/**
|
|
@@ -836,10 +926,25 @@ export class ContentStore {
|
|
|
836
926
|
*/
|
|
837
927
|
indexPlainText(content, source, linesPerChunk = 20, attribution, maxChunkBytes = MAX_CHUNK_BYTES) {
|
|
838
928
|
if (!content || content.trim().length === 0) {
|
|
839
|
-
|
|
929
|
+
// Empty fast path still writes to the DB (a 0-chunk source row for
|
|
930
|
+
// dedup bookkeeping), so it gets the same retry + failure-context
|
|
931
|
+
// contract as the regular write path instead of bypassing both.
|
|
932
|
+
// indexJSON("") and its 0-chunk fallbacks delegate to this method,
|
|
933
|
+
// which routes them through the same contract.
|
|
934
|
+
try {
|
|
935
|
+
return withRetry(() => this.#insertChunks([], source, "", undefined, undefined, attribution));
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
throw storeFailure("store.indexPlainText", err, this.#dbPath);
|
|
939
|
+
}
|
|
840
940
|
}
|
|
841
941
|
const chunks = this.#chunkPlainText(content, linesPerChunk, maxChunkBytes);
|
|
842
|
-
|
|
942
|
+
try {
|
|
943
|
+
return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content, undefined, undefined, attribution));
|
|
944
|
+
}
|
|
945
|
+
catch (err) {
|
|
946
|
+
throw storeFailure("store.indexPlainText", err, this.#dbPath);
|
|
947
|
+
}
|
|
843
948
|
}
|
|
844
949
|
// ── Index JSON ──
|
|
845
950
|
/**
|
|
@@ -865,7 +970,12 @@ export class ContentStore {
|
|
|
865
970
|
if (chunks.length === 0) {
|
|
866
971
|
return this.indexPlainText(content, source, undefined, attribution, maxChunkBytes);
|
|
867
972
|
}
|
|
868
|
-
|
|
973
|
+
try {
|
|
974
|
+
return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
throw storeFailure("store.indexJSON", err, this.#dbPath);
|
|
978
|
+
}
|
|
869
979
|
}
|
|
870
980
|
// ── Shared DB Insertion ──
|
|
871
981
|
/**
|
|
@@ -970,7 +1080,12 @@ export class ContentStore {
|
|
|
970
1080
|
stmt = this.#stmtSearchPorter;
|
|
971
1081
|
params = [sanitized, limit];
|
|
972
1082
|
}
|
|
973
|
-
|
|
1083
|
+
try {
|
|
1084
|
+
return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
|
|
1085
|
+
}
|
|
1086
|
+
catch (err) {
|
|
1087
|
+
throw storeFailure("store.search", err, this.#dbPath);
|
|
1088
|
+
}
|
|
974
1089
|
}
|
|
975
1090
|
// ── Trigram Search (Layer 2) ──
|
|
976
1091
|
searchTrigram(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
|
|
@@ -999,7 +1114,12 @@ export class ContentStore {
|
|
|
999
1114
|
stmt = this.#stmtSearchTrigram;
|
|
1000
1115
|
params = [sanitized, limit];
|
|
1001
1116
|
}
|
|
1002
|
-
|
|
1117
|
+
try {
|
|
1118
|
+
return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
|
|
1119
|
+
}
|
|
1120
|
+
catch (err) {
|
|
1121
|
+
throw storeFailure("store.searchTrigram", err, this.#dbPath);
|
|
1122
|
+
}
|
|
1003
1123
|
}
|
|
1004
1124
|
// ── Fuzzy Correction (Layer 3) ──
|
|
1005
1125
|
fuzzyCorrect(query) {
|
|
@@ -1014,7 +1134,17 @@ export class ContentStore {
|
|
|
1014
1134
|
return cached;
|
|
1015
1135
|
}
|
|
1016
1136
|
const maxDist = maxEditDistance(word.length);
|
|
1017
|
-
|
|
1137
|
+
let candidates;
|
|
1138
|
+
try {
|
|
1139
|
+
// The vocab lookup is a read whose raw driver errors used to
|
|
1140
|
+
// propagate unclassified through the search fallback — same retry +
|
|
1141
|
+
// [context-mode:db] logging + op/path/code context as the rest of
|
|
1142
|
+
// the store.
|
|
1143
|
+
candidates = withRetry(() => this.#stmtFuzzyVocab.all(word.length - maxDist, word.length + maxDist));
|
|
1144
|
+
}
|
|
1145
|
+
catch (err) {
|
|
1146
|
+
throw storeFailure("store.fuzzyCorrect", err, this.#dbPath);
|
|
1147
|
+
}
|
|
1018
1148
|
let bestWord = null;
|
|
1019
1149
|
let bestDist = maxDist + 1;
|
|
1020
1150
|
let exactMatch = false;
|
|
@@ -1176,7 +1306,16 @@ export class ContentStore {
|
|
|
1176
1306
|
*/
|
|
1177
1307
|
#refreshStaleSources() {
|
|
1178
1308
|
this.lastRefreshCount = 0;
|
|
1179
|
-
|
|
1309
|
+
let sources;
|
|
1310
|
+
try {
|
|
1311
|
+
// Staleness scan is a read, but it runs ahead of every search — a
|
|
1312
|
+
// transient IOERR here gets the same retry, and exhaustion the same
|
|
1313
|
+
// op/path/code context, as the write paths.
|
|
1314
|
+
sources = withRetry(() => this.#db.prepare("SELECT label, file_path, content_hash, indexed_at FROM sources WHERE file_path IS NOT NULL").all());
|
|
1315
|
+
}
|
|
1316
|
+
catch (err) {
|
|
1317
|
+
throw storeFailure("store.refreshStaleSources", err, this.#dbPath);
|
|
1318
|
+
}
|
|
1180
1319
|
for (const src of sources) {
|
|
1181
1320
|
try {
|
|
1182
1321
|
if (!existsSync(src.file_path))
|
|
@@ -1216,20 +1355,46 @@ export class ContentStore {
|
|
|
1216
1355
|
this.index({ content: newContent, path: src.file_path, source: src.label });
|
|
1217
1356
|
this.lastRefreshCount++;
|
|
1218
1357
|
}
|
|
1219
|
-
catch {
|
|
1220
|
-
// Graceful degradation — never break search for stale detection
|
|
1358
|
+
catch (err) {
|
|
1359
|
+
// Graceful degradation — never break search for stale detection.
|
|
1360
|
+
// But the failure (a failed re-index write, a vanished file) is no
|
|
1361
|
+
// longer silent. Errors already enriched by an inner store op carry
|
|
1362
|
+
// the kDbErrorLogged marker: their [context-mode:db] line was
|
|
1363
|
+
// already emitted under the owning op (e.g. store.index) — logging
|
|
1364
|
+
// again here would emit a second, code-stripped line for the same
|
|
1365
|
+
// failure.
|
|
1366
|
+
if (!isDbErrorAlreadyLogged(err)) {
|
|
1367
|
+
logDbError("store.refreshStaleSources", err, this.#dbPath);
|
|
1368
|
+
}
|
|
1221
1369
|
}
|
|
1222
1370
|
}
|
|
1223
1371
|
}
|
|
1224
1372
|
// ── Sources ──
|
|
1225
1373
|
getSourceMeta(label) {
|
|
1226
|
-
|
|
1374
|
+
// Raw stmt.get() — the ctx_fetch_and_index cache probe (server.ts
|
|
1375
|
+
// fetchOneUrl) runs through here BEFORE any fetch/indexing, so an
|
|
1376
|
+
// IOERR used to reject the whole batch as a bare driver error with
|
|
1377
|
+
// nothing in any log. Same retry + context contract as the writes.
|
|
1378
|
+
let row;
|
|
1379
|
+
try {
|
|
1380
|
+
row = withRetry(() => this.#stmtSourceMeta.get(label));
|
|
1381
|
+
}
|
|
1382
|
+
catch (err) {
|
|
1383
|
+
throw storeFailure("store.getSourceMeta", err, this.#dbPath);
|
|
1384
|
+
}
|
|
1227
1385
|
if (!row)
|
|
1228
1386
|
return null;
|
|
1229
1387
|
return { label: row.label, chunkCount: row.chunk_count, codeChunkCount: row.code_chunk_count, indexedAt: row.indexed_at, filePath: row.file_path ?? null, contentHash: row.content_hash ?? null };
|
|
1230
1388
|
}
|
|
1231
1389
|
listSources() {
|
|
1232
|
-
|
|
1390
|
+
// ctx_search's no-results path lists sources here — same retry +
|
|
1391
|
+
// context contract as the write paths.
|
|
1392
|
+
try {
|
|
1393
|
+
return withRetry(() => this.#stmtListSources.all());
|
|
1394
|
+
}
|
|
1395
|
+
catch (err) {
|
|
1396
|
+
throw storeFailure("store.listSources", err, this.#dbPath);
|
|
1397
|
+
}
|
|
1233
1398
|
}
|
|
1234
1399
|
/**
|
|
1235
1400
|
* Aggregate snapshot of the persistent content store. Returns total
|
|
@@ -1238,9 +1403,15 @@ export class ContentStore {
|
|
|
1238
1403
|
* round trip instead of inferring it from snapshot diffs.
|
|
1239
1404
|
*/
|
|
1240
1405
|
getIndexState() {
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1406
|
+
let row;
|
|
1407
|
+
try {
|
|
1408
|
+
row = withRetry(() => this.#db
|
|
1409
|
+
.prepare("SELECT COALESCE(SUM(chunk_count), 0) AS total_chunks, COUNT(*) AS total_sources, MAX(indexed_at) AS last_indexed_at FROM sources")
|
|
1410
|
+
.get());
|
|
1411
|
+
}
|
|
1412
|
+
catch (err) {
|
|
1413
|
+
throw storeFailure("store.getIndexState", err, this.#dbPath);
|
|
1414
|
+
}
|
|
1244
1415
|
return {
|
|
1245
1416
|
totalChunks: row.total_chunks ?? 0,
|
|
1246
1417
|
totalSources: row.total_sources ?? 0,
|
|
@@ -1252,7 +1423,15 @@ export class ContentStore {
|
|
|
1252
1423
|
* Use this for inventory/listing where you need all sections, not search.
|
|
1253
1424
|
*/
|
|
1254
1425
|
getChunksBySource(sourceId) {
|
|
1255
|
-
|
|
1426
|
+
let rows;
|
|
1427
|
+
try {
|
|
1428
|
+
// Batch/introspection path (ctx_index directory mode, chunk listing)
|
|
1429
|
+
// — same retry + context contract as the write paths.
|
|
1430
|
+
rows = withRetry(() => this.#stmtChunksBySource.all(sourceId));
|
|
1431
|
+
}
|
|
1432
|
+
catch (err) {
|
|
1433
|
+
throw storeFailure("store.getChunksBySource", err, this.#dbPath);
|
|
1434
|
+
}
|
|
1256
1435
|
return rows.map((r) => ({
|
|
1257
1436
|
title: r.title,
|
|
1258
1437
|
content: r.content,
|
|
@@ -1263,23 +1442,41 @@ export class ContentStore {
|
|
|
1263
1442
|
}
|
|
1264
1443
|
// ── Vocabulary ──
|
|
1265
1444
|
getDistinctiveTerms(sourceId, maxTerms = 40) {
|
|
1266
|
-
|
|
1445
|
+
let stats;
|
|
1446
|
+
try {
|
|
1447
|
+
stats = withRetry(() => this.#stmtSourceChunkCount.get(sourceId));
|
|
1448
|
+
}
|
|
1449
|
+
catch (err) {
|
|
1450
|
+
throw storeFailure("store.getDistinctiveTerms", err, this.#dbPath);
|
|
1451
|
+
}
|
|
1267
1452
|
if (!stats || stats.chunk_count < 3)
|
|
1268
1453
|
return [];
|
|
1269
1454
|
const totalChunks = stats.chunk_count;
|
|
1270
1455
|
const minAppearances = 2;
|
|
1271
1456
|
const maxAppearances = Math.max(3, Math.ceil(totalChunks * 0.4));
|
|
1272
|
-
// Stream chunks one at a time to avoid loading all content into memory
|
|
1273
|
-
// Count document frequency (how many sections contain each word)
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1457
|
+
// Stream chunks one at a time to avoid loading all content into memory.
|
|
1458
|
+
// Count document frequency (how many sections contain each word).
|
|
1459
|
+
// The whole aggregation is wrapped so a transient IOERR mid-stream
|
|
1460
|
+
// retries from a clean slate — docFreq is rebuilt, never half-populated.
|
|
1461
|
+
const collectDocFreq = () => {
|
|
1462
|
+
const docFreq = new Map();
|
|
1463
|
+
for (const row of this.#stmtChunkContent.iterate(sourceId)) {
|
|
1464
|
+
const words = new Set(row.content
|
|
1465
|
+
.toLowerCase()
|
|
1466
|
+
.split(/[^\p{L}\p{N}_-]+/u)
|
|
1467
|
+
.filter((w) => w.length >= 3 && !STOPWORDS.has(w)));
|
|
1468
|
+
for (const word of words) {
|
|
1469
|
+
docFreq.set(word, (docFreq.get(word) ?? 0) + 1);
|
|
1470
|
+
}
|
|
1282
1471
|
}
|
|
1472
|
+
return docFreq;
|
|
1473
|
+
};
|
|
1474
|
+
let docFreq;
|
|
1475
|
+
try {
|
|
1476
|
+
docFreq = withRetry(collectDocFreq);
|
|
1477
|
+
}
|
|
1478
|
+
catch (err) {
|
|
1479
|
+
throw storeFailure("store.getDistinctiveTerms", err, this.#dbPath);
|
|
1283
1480
|
}
|
|
1284
1481
|
const filtered = Array.from(docFreq.entries())
|
|
1285
1482
|
.filter(([, count]) => count >= minAppearances && count <= maxAppearances);
|
|
@@ -1317,8 +1514,16 @@ export class ContentStore {
|
|
|
1317
1514
|
this.#stmtCleanupChunksTrigram.run(days);
|
|
1318
1515
|
return this.#stmtCleanupSources.run(days);
|
|
1319
1516
|
});
|
|
1320
|
-
|
|
1321
|
-
|
|
1517
|
+
// Write path — same transient-IOERR/BUSY retry + failure context as
|
|
1518
|
+
// store.index. better-sqlite3 and the db-base adapters roll the
|
|
1519
|
+
// transaction back on throw, so re-invoking after a retry is safe.
|
|
1520
|
+
try {
|
|
1521
|
+
const info = withRetry(() => cleanup(maxAgeDays));
|
|
1522
|
+
return info.changes;
|
|
1523
|
+
}
|
|
1524
|
+
catch (err) {
|
|
1525
|
+
throw storeFailure("store.cleanupStaleSources", err, this.#dbPath);
|
|
1526
|
+
}
|
|
1322
1527
|
}
|
|
1323
1528
|
/** Get DB file size in bytes. */
|
|
1324
1529
|
getDBSizeBytes() {
|
|
@@ -1335,7 +1540,11 @@ export class ContentStore {
|
|
|
1335
1540
|
this.#db.exec("INSERT INTO chunks(chunks) VALUES('optimize')");
|
|
1336
1541
|
this.#db.exec("INSERT INTO chunks_trigram(chunks_trigram) VALUES('optimize')");
|
|
1337
1542
|
}
|
|
1338
|
-
catch {
|
|
1543
|
+
catch (err) {
|
|
1544
|
+
// Best effort — don't block indexing, but don't stay silent either:
|
|
1545
|
+
// 'optimize' is a write and its failures were previously invisible.
|
|
1546
|
+
logDbError("store.optimizeFTS", err, this.#dbPath);
|
|
1547
|
+
}
|
|
1339
1548
|
}
|
|
1340
1549
|
close() {
|
|
1341
1550
|
this.#optimizeFTS(); // defragment before close
|