@mxalbert/context-mode 2.0.1 → 2.0.2
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/build/db-base.d.ts +16 -0
- package/build/db-base.js +29 -3
- package/build/store.js +250 -39
- package/cli.bundle.mjs +171 -171
- package/configs/antigravity-cli/plugin.json +1 -1
- package/configs/copilot-cli/.github/plugin/plugin.json +1 -1
- package/hooks/session-db.bundle.mjs +7 -7
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +131 -131
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Claude Code plugins by Mert Koseoğlu",
|
|
9
|
-
"version": "2.0.
|
|
9
|
+
"version": "2.0.2"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "context-mode",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
16
|
-
"version": "2.0.
|
|
16
|
+
"version": "2.0.2",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Mert Koseoğlu"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mode",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mode",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "Context Mode",
|
|
4
4
|
"kind": "tool",
|
|
5
5
|
"description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
6
|
-
"version": "2.0.
|
|
6
|
+
"version": "2.0.2",
|
|
7
7
|
"sandbox": {
|
|
8
8
|
"mode": "permissive",
|
|
9
9
|
"filesystem_access": "full",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mxalbert/context-mode",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
package/build/db-base.d.ts
CHANGED
|
@@ -185,6 +185,22 @@ export declare function renameCorruptDB(dbPath: string): void;
|
|
|
185
185
|
* error) go to stderr via console.error under this prefix.
|
|
186
186
|
*/
|
|
187
187
|
export declare const DB_LOG_PREFIX = "[context-mode:db]";
|
|
188
|
+
/**
|
|
189
|
+
* Extract the SQLite error code (e.g. "SQLITE_IOERR") from an arbitrary
|
|
190
|
+
* thrown value, or "" when absent. better-sqlite3 and node:sqlite set
|
|
191
|
+
* `code` on SqliteError; bun:sqlite encodes it in the message instead.
|
|
192
|
+
*
|
|
193
|
+
* Exported so store.ts can append the same `[<code>]` context to the
|
|
194
|
+
* tool-visible `[context-mode:store] …` error messages without
|
|
195
|
+
* duplicating the throw-shape handling.
|
|
196
|
+
*/
|
|
197
|
+
export declare function extractErrorCode(err: unknown): string;
|
|
198
|
+
/**
|
|
199
|
+
* Normalize the human-readable message of an arbitrary thrown value.
|
|
200
|
+
* Exported alongside extractErrorCode for store.ts's tool-error
|
|
201
|
+
* enrichment (non-Error shapes included).
|
|
202
|
+
*/
|
|
203
|
+
export declare function errorMessage(err: unknown): string;
|
|
188
204
|
/**
|
|
189
205
|
* Log a swallowed DB failure to stderr with the stable
|
|
190
206
|
* `[context-mode:db]` prefix: operation name, error code, error message,
|
package/build/db-base.js
CHANGED
|
@@ -490,6 +490,15 @@ export function withRetry(fn, delays = [100, 500, 2000]) {
|
|
|
490
490
|
throw err;
|
|
491
491
|
}
|
|
492
492
|
lastError = err instanceof Error ? err : new Error(errorSignature(err));
|
|
493
|
+
if (!(err instanceof Error)) {
|
|
494
|
+
// Preserve the SQLite code (e.g. SQLITE_IOERR) on the normalized
|
|
495
|
+
// Error — non-Error throw shapes ({ code } objects, strings) would
|
|
496
|
+
// otherwise lose it, leaving callers unable to classify the failure
|
|
497
|
+
// after retries are exhausted.
|
|
498
|
+
const code = extractErrorCode(err);
|
|
499
|
+
if (code)
|
|
500
|
+
lastError.code = code;
|
|
501
|
+
}
|
|
493
502
|
if (attempt < delays.length) {
|
|
494
503
|
const delay = delays[attempt];
|
|
495
504
|
const start = Date.now();
|
|
@@ -497,8 +506,16 @@ export function withRetry(fn, delays = [100, 500, 2000]) {
|
|
|
497
506
|
}
|
|
498
507
|
}
|
|
499
508
|
}
|
|
500
|
-
|
|
509
|
+
const exhausted = new Error(`SQLITE_BUSY/SQLITE_IOERR: transient SQLite error after ${delays.length} retries. ` +
|
|
501
510
|
`Original error: ${lastError?.message}`);
|
|
511
|
+
// Carry the last error's SQLite code on the wrapper (message alone is
|
|
512
|
+
// ambiguous — it names both SQLITE_BUSY and SQLITE_IOERR). store.ts's
|
|
513
|
+
// `[context-mode:store]` enrichment appends this as ` (<code>)` so the
|
|
514
|
+
// tool-visible error is never a bare "disk I/O error".
|
|
515
|
+
const exhaustedCode = lastError ? extractErrorCode(lastError) : "";
|
|
516
|
+
if (exhaustedCode)
|
|
517
|
+
exhausted.code = exhaustedCode;
|
|
518
|
+
throw exhausted;
|
|
502
519
|
}
|
|
503
520
|
// ─────────────────────────────────────────────────────────
|
|
504
521
|
// Corrupt DB recovery (#244)
|
|
@@ -545,8 +562,12 @@ const _recentDbErrors = new Map();
|
|
|
545
562
|
* Extract the SQLite error code (e.g. "SQLITE_IOERR") from an arbitrary
|
|
546
563
|
* thrown value, or "" when absent. better-sqlite3 and node:sqlite set
|
|
547
564
|
* `code` on SqliteError; bun:sqlite encodes it in the message instead.
|
|
565
|
+
*
|
|
566
|
+
* Exported so store.ts can append the same `[<code>]` context to the
|
|
567
|
+
* tool-visible `[context-mode:store] …` error messages without
|
|
568
|
+
* duplicating the throw-shape handling.
|
|
548
569
|
*/
|
|
549
|
-
function extractErrorCode(err) {
|
|
570
|
+
export function extractErrorCode(err) {
|
|
550
571
|
if (err instanceof Error) {
|
|
551
572
|
const code = err.code;
|
|
552
573
|
return typeof code === "string" ? code : "";
|
|
@@ -557,7 +578,12 @@ function extractErrorCode(err) {
|
|
|
557
578
|
}
|
|
558
579
|
return "";
|
|
559
580
|
}
|
|
560
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Normalize the human-readable message of an arbitrary thrown value.
|
|
583
|
+
* Exported alongside extractErrorCode for store.ts's tool-error
|
|
584
|
+
* enrichment (non-Error shapes included).
|
|
585
|
+
*/
|
|
586
|
+
export function errorMessage(err) {
|
|
561
587
|
if (err instanceof Error)
|
|
562
588
|
return err.message;
|
|
563
589
|
if (typeof err === "string")
|
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";
|
|
@@ -302,6 +302,65 @@ function findMinSpan(positionLists) {
|
|
|
302
302
|
}
|
|
303
303
|
return minSpan;
|
|
304
304
|
}
|
|
305
|
+
// ─────────────────────────────────────────────────────────
|
|
306
|
+
// Store error handling (v2.0.2 — disk-I/O hardening parity with db-base)
|
|
307
|
+
// ─────────────────────────────────────────────────────────
|
|
308
|
+
/**
|
|
309
|
+
* Prefix for tool-visible ContentStore failures. Store failures used to
|
|
310
|
+
* surface as the raw driver message — for SQLITE_IOERR exactly
|
|
311
|
+
* "disk I/O error", with no op, no DB path, no SQLite code, and nothing
|
|
312
|
+
* in any log (the store layer's catches were all silent). Every store
|
|
313
|
+
* open/write failure now (a) logs to stderr via db-base's logDbError
|
|
314
|
+
* (same `[context-mode:db]` prefix, 30s dedupe, 256-key cap) and
|
|
315
|
+
* (b) rethrows with this prefix plus op, path, and code so the isError
|
|
316
|
+
* text the MCP client sees is actionable.
|
|
317
|
+
*/
|
|
318
|
+
const STORE_ERROR_PREFIX = "[context-mode:store]";
|
|
319
|
+
/**
|
|
320
|
+
* Symbol marking Errors that this layer already reported via logDbError
|
|
321
|
+
* (every storeFailure product carries it). Symbol.for so the flag survives
|
|
322
|
+
* module re-imports, mirroring db-base's global-symbol pattern.
|
|
323
|
+
*/
|
|
324
|
+
const kDbErrorLogged = Symbol.for("__context_mode_db_error_logged__");
|
|
325
|
+
/** True when `err` was already logged + enriched by an inner store op —
|
|
326
|
+
* outer catches must not emit a second line for the same failure. */
|
|
327
|
+
function isDbErrorAlreadyLogged(err) {
|
|
328
|
+
try {
|
|
329
|
+
return (typeof err === "object" &&
|
|
330
|
+
err !== null &&
|
|
331
|
+
err[kDbErrorLogged] === true);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Log `err` for store operation `op` and return an Error whose message
|
|
339
|
+
* carries the op, the DB file path, and the SQLite code (when present):
|
|
340
|
+
* `[context-mode:store] <op> failed on <dbPath>: <message> (<code>)`.
|
|
341
|
+
* The returned Error preserves the original `.code` and carries a
|
|
342
|
+
* non-enumerable kDbErrorLogged marker: when it flows through another
|
|
343
|
+
* storeFailure call (e.g. a failed stale re-index surfacing through an
|
|
344
|
+
* outer wrapper) it is returned as-is, so one underlying failure produces
|
|
345
|
+
* exactly one `[context-mode:db]` line, under the op that owns it.
|
|
346
|
+
* `base` replaces `<message>` for callers that must preserve an existing
|
|
347
|
+
* wrap message (the corruption delete-and-recreate retry). Callers throw
|
|
348
|
+
* the result. Logging goes through db-base's logDbError (stderr), never
|
|
349
|
+
* console.log, and logging itself never throws.
|
|
350
|
+
*/
|
|
351
|
+
function storeFailure(op, err, dbPath, base) {
|
|
352
|
+
if (isDbErrorAlreadyLogged(err))
|
|
353
|
+
return err;
|
|
354
|
+
logDbError(op, err, dbPath);
|
|
355
|
+
const message = errorMessage(err);
|
|
356
|
+
const code = extractErrorCode(err);
|
|
357
|
+
const codeSuffix = code ? ` (${code})` : "";
|
|
358
|
+
const enriched = new Error(`${STORE_ERROR_PREFIX} ${op} failed on ${dbPath}: ${base ?? message}${codeSuffix}`);
|
|
359
|
+
if (code)
|
|
360
|
+
enriched.code = code;
|
|
361
|
+
Object.defineProperty(enriched, kDbErrorLogged, { value: true, enumerable: false });
|
|
362
|
+
return enriched;
|
|
363
|
+
}
|
|
305
364
|
export class ContentStore {
|
|
306
365
|
#db;
|
|
307
366
|
#dbPath;
|
|
@@ -367,31 +426,55 @@ export class ContentStore {
|
|
|
367
426
|
this.#dbPath =
|
|
368
427
|
dbPath ?? join(tmpdir(), `context-mode-${process.pid}.db`);
|
|
369
428
|
cleanOrphanedWALFiles(this.#dbPath);
|
|
429
|
+
// One open attempt: create the connection and apply WAL pragmas.
|
|
430
|
+
// Wrapped in withRetry below so a transient SQLITE_IOERR / "disk I/O
|
|
431
|
+
// error" on open is retried with the same exponential backoff as
|
|
432
|
+
// SQLITE_BUSY (db-base, v1.0.187 hardening). Corruption signatures are
|
|
433
|
+
// never retried by withRetry — they rethrow for the recovery path.
|
|
434
|
+
const openOnce = () => {
|
|
435
|
+
const db = new Database(this.#dbPath, { timeout: 30000 });
|
|
436
|
+
applyWALPragmas(db);
|
|
437
|
+
return db;
|
|
438
|
+
};
|
|
370
439
|
let db;
|
|
371
440
|
try {
|
|
372
|
-
db =
|
|
373
|
-
applyWALPragmas(db);
|
|
441
|
+
db = withRetry(openOnce);
|
|
374
442
|
}
|
|
375
443
|
catch (err) {
|
|
376
444
|
const msg = err instanceof Error ? err.message : String(err);
|
|
377
445
|
if (isSQLiteCorruptionError(msg)) {
|
|
446
|
+
// Surface the corruption + delete-and-recreate instead of silently
|
|
447
|
+
// swapping the file — users lose indexed content and deserve a trace.
|
|
448
|
+
logDbError("store.open", err, this.#dbPath);
|
|
378
449
|
deleteDBFiles(this.#dbPath);
|
|
379
450
|
cleanOrphanedWALFiles(this.#dbPath);
|
|
380
451
|
try {
|
|
381
|
-
db =
|
|
382
|
-
applyWALPragmas(db);
|
|
452
|
+
db = withRetry(openOnce);
|
|
383
453
|
}
|
|
384
454
|
catch (retryErr) {
|
|
385
|
-
|
|
455
|
+
// Preserve the existing corruption-retry wrap message, enriched
|
|
456
|
+
// with op + path + code so the tool error is never bare.
|
|
457
|
+
throw storeFailure("store.open", retryErr, this.#dbPath, `Failed to create fresh DB after deleting corrupt file: ${errorMessage(retryErr)}`);
|
|
386
458
|
}
|
|
387
459
|
}
|
|
388
460
|
else {
|
|
389
|
-
|
|
461
|
+
// Transient-retry exhaustion or a non-transient open failure —
|
|
462
|
+
// either way the caller gets op + path + code, and stderr gets a
|
|
463
|
+
// [context-mode:db] line (deduped).
|
|
464
|
+
throw storeFailure("store.open", err, this.#dbPath);
|
|
390
465
|
}
|
|
391
466
|
}
|
|
392
467
|
this.#db = db;
|
|
393
|
-
|
|
394
|
-
|
|
468
|
+
try {
|
|
469
|
+
this.#initSchema();
|
|
470
|
+
this.#prepareStatements();
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
// The connection is already open here, so there is no retry contract
|
|
474
|
+
// for schema init — but the failure must not be silent: log the
|
|
475
|
+
// [context-mode:db] line and rethrow with op + path + code context.
|
|
476
|
+
throw storeFailure("store.initSchema", err, this.#dbPath);
|
|
477
|
+
}
|
|
395
478
|
}
|
|
396
479
|
/** Delete this session's DB files. Call on process exit. */
|
|
397
480
|
cleanup() {
|
|
@@ -781,7 +864,16 @@ export class ContentStore {
|
|
|
781
864
|
// Stale detection: store file_path + SHA-256 for file-backed sources
|
|
782
865
|
const filePath = path ?? undefined;
|
|
783
866
|
const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
|
|
784
|
-
|
|
867
|
+
// withRetry absorbs transient SQLITE_BUSY/SQLITE_IOERR with exponential
|
|
868
|
+
// backoff; on exhaustion the failure is logged ([context-mode:db]) and
|
|
869
|
+
// rethrown with op + path + code so the tool error is never a bare
|
|
870
|
+
// "disk I/O error".
|
|
871
|
+
try {
|
|
872
|
+
return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
|
|
873
|
+
}
|
|
874
|
+
catch (err) {
|
|
875
|
+
throw storeFailure("store.index", err, this.#dbPath);
|
|
876
|
+
}
|
|
785
877
|
}
|
|
786
878
|
// ── Index Directory (#687) ──
|
|
787
879
|
/**
|
|
@@ -836,10 +928,25 @@ export class ContentStore {
|
|
|
836
928
|
*/
|
|
837
929
|
indexPlainText(content, source, linesPerChunk = 20, attribution, maxChunkBytes = MAX_CHUNK_BYTES) {
|
|
838
930
|
if (!content || content.trim().length === 0) {
|
|
839
|
-
|
|
931
|
+
// Empty fast path still writes to the DB (a 0-chunk source row for
|
|
932
|
+
// dedup bookkeeping), so it gets the same retry + failure-context
|
|
933
|
+
// contract as the regular write path instead of bypassing both.
|
|
934
|
+
// indexJSON("") and its 0-chunk fallbacks delegate to this method,
|
|
935
|
+
// which routes them through the same contract.
|
|
936
|
+
try {
|
|
937
|
+
return withRetry(() => this.#insertChunks([], source, "", undefined, undefined, attribution));
|
|
938
|
+
}
|
|
939
|
+
catch (err) {
|
|
940
|
+
throw storeFailure("store.indexPlainText", err, this.#dbPath);
|
|
941
|
+
}
|
|
840
942
|
}
|
|
841
943
|
const chunks = this.#chunkPlainText(content, linesPerChunk, maxChunkBytes);
|
|
842
|
-
|
|
944
|
+
try {
|
|
945
|
+
return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content, undefined, undefined, attribution));
|
|
946
|
+
}
|
|
947
|
+
catch (err) {
|
|
948
|
+
throw storeFailure("store.indexPlainText", err, this.#dbPath);
|
|
949
|
+
}
|
|
843
950
|
}
|
|
844
951
|
// ── Index JSON ──
|
|
845
952
|
/**
|
|
@@ -865,7 +972,12 @@ export class ContentStore {
|
|
|
865
972
|
if (chunks.length === 0) {
|
|
866
973
|
return this.indexPlainText(content, source, undefined, attribution, maxChunkBytes);
|
|
867
974
|
}
|
|
868
|
-
|
|
975
|
+
try {
|
|
976
|
+
return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
throw storeFailure("store.indexJSON", err, this.#dbPath);
|
|
980
|
+
}
|
|
869
981
|
}
|
|
870
982
|
// ── Shared DB Insertion ──
|
|
871
983
|
/**
|
|
@@ -970,7 +1082,12 @@ export class ContentStore {
|
|
|
970
1082
|
stmt = this.#stmtSearchPorter;
|
|
971
1083
|
params = [sanitized, limit];
|
|
972
1084
|
}
|
|
973
|
-
|
|
1085
|
+
try {
|
|
1086
|
+
return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
|
|
1087
|
+
}
|
|
1088
|
+
catch (err) {
|
|
1089
|
+
throw storeFailure("store.search", err, this.#dbPath);
|
|
1090
|
+
}
|
|
974
1091
|
}
|
|
975
1092
|
// ── Trigram Search (Layer 2) ──
|
|
976
1093
|
searchTrigram(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
|
|
@@ -999,7 +1116,12 @@ export class ContentStore {
|
|
|
999
1116
|
stmt = this.#stmtSearchTrigram;
|
|
1000
1117
|
params = [sanitized, limit];
|
|
1001
1118
|
}
|
|
1002
|
-
|
|
1119
|
+
try {
|
|
1120
|
+
return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
|
|
1121
|
+
}
|
|
1122
|
+
catch (err) {
|
|
1123
|
+
throw storeFailure("store.searchTrigram", err, this.#dbPath);
|
|
1124
|
+
}
|
|
1003
1125
|
}
|
|
1004
1126
|
// ── Fuzzy Correction (Layer 3) ──
|
|
1005
1127
|
fuzzyCorrect(query) {
|
|
@@ -1014,7 +1136,17 @@ export class ContentStore {
|
|
|
1014
1136
|
return cached;
|
|
1015
1137
|
}
|
|
1016
1138
|
const maxDist = maxEditDistance(word.length);
|
|
1017
|
-
|
|
1139
|
+
let candidates;
|
|
1140
|
+
try {
|
|
1141
|
+
// The vocab lookup is a read whose raw driver errors used to
|
|
1142
|
+
// propagate unclassified through the search fallback — same retry +
|
|
1143
|
+
// [context-mode:db] logging + op/path/code context as the rest of
|
|
1144
|
+
// the store.
|
|
1145
|
+
candidates = withRetry(() => this.#stmtFuzzyVocab.all(word.length - maxDist, word.length + maxDist));
|
|
1146
|
+
}
|
|
1147
|
+
catch (err) {
|
|
1148
|
+
throw storeFailure("store.fuzzyCorrect", err, this.#dbPath);
|
|
1149
|
+
}
|
|
1018
1150
|
let bestWord = null;
|
|
1019
1151
|
let bestDist = maxDist + 1;
|
|
1020
1152
|
let exactMatch = false;
|
|
@@ -1176,7 +1308,16 @@ export class ContentStore {
|
|
|
1176
1308
|
*/
|
|
1177
1309
|
#refreshStaleSources() {
|
|
1178
1310
|
this.lastRefreshCount = 0;
|
|
1179
|
-
|
|
1311
|
+
let sources;
|
|
1312
|
+
try {
|
|
1313
|
+
// Staleness scan is a read, but it runs ahead of every search — a
|
|
1314
|
+
// transient IOERR here gets the same retry, and exhaustion the same
|
|
1315
|
+
// op/path/code context, as the write paths.
|
|
1316
|
+
sources = withRetry(() => this.#db.prepare("SELECT label, file_path, content_hash, indexed_at FROM sources WHERE file_path IS NOT NULL").all());
|
|
1317
|
+
}
|
|
1318
|
+
catch (err) {
|
|
1319
|
+
throw storeFailure("store.refreshStaleSources", err, this.#dbPath);
|
|
1320
|
+
}
|
|
1180
1321
|
for (const src of sources) {
|
|
1181
1322
|
try {
|
|
1182
1323
|
if (!existsSync(src.file_path))
|
|
@@ -1216,20 +1357,46 @@ export class ContentStore {
|
|
|
1216
1357
|
this.index({ content: newContent, path: src.file_path, source: src.label });
|
|
1217
1358
|
this.lastRefreshCount++;
|
|
1218
1359
|
}
|
|
1219
|
-
catch {
|
|
1220
|
-
// Graceful degradation — never break search for stale detection
|
|
1360
|
+
catch (err) {
|
|
1361
|
+
// Graceful degradation — never break search for stale detection.
|
|
1362
|
+
// But the failure (a failed re-index write, a vanished file) is no
|
|
1363
|
+
// longer silent. Errors already enriched by an inner store op carry
|
|
1364
|
+
// the kDbErrorLogged marker: their [context-mode:db] line was
|
|
1365
|
+
// already emitted under the owning op (e.g. store.index) — logging
|
|
1366
|
+
// again here would emit a second, code-stripped line for the same
|
|
1367
|
+
// failure.
|
|
1368
|
+
if (!isDbErrorAlreadyLogged(err)) {
|
|
1369
|
+
logDbError("store.refreshStaleSources", err, this.#dbPath);
|
|
1370
|
+
}
|
|
1221
1371
|
}
|
|
1222
1372
|
}
|
|
1223
1373
|
}
|
|
1224
1374
|
// ── Sources ──
|
|
1225
1375
|
getSourceMeta(label) {
|
|
1226
|
-
|
|
1376
|
+
// Raw stmt.get() — the ctx_fetch_and_index cache probe (server.ts
|
|
1377
|
+
// fetchOneUrl) runs through here BEFORE any fetch/indexing, so an
|
|
1378
|
+
// IOERR used to reject the whole batch as a bare driver error with
|
|
1379
|
+
// nothing in any log. Same retry + context contract as the writes.
|
|
1380
|
+
let row;
|
|
1381
|
+
try {
|
|
1382
|
+
row = withRetry(() => this.#stmtSourceMeta.get(label));
|
|
1383
|
+
}
|
|
1384
|
+
catch (err) {
|
|
1385
|
+
throw storeFailure("store.getSourceMeta", err, this.#dbPath);
|
|
1386
|
+
}
|
|
1227
1387
|
if (!row)
|
|
1228
1388
|
return null;
|
|
1229
1389
|
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
1390
|
}
|
|
1231
1391
|
listSources() {
|
|
1232
|
-
|
|
1392
|
+
// ctx_search's no-results path lists sources here — same retry +
|
|
1393
|
+
// context contract as the write paths.
|
|
1394
|
+
try {
|
|
1395
|
+
return withRetry(() => this.#stmtListSources.all());
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
throw storeFailure("store.listSources", err, this.#dbPath);
|
|
1399
|
+
}
|
|
1233
1400
|
}
|
|
1234
1401
|
/**
|
|
1235
1402
|
* Aggregate snapshot of the persistent content store. Returns total
|
|
@@ -1238,9 +1405,15 @@ export class ContentStore {
|
|
|
1238
1405
|
* round trip instead of inferring it from snapshot diffs.
|
|
1239
1406
|
*/
|
|
1240
1407
|
getIndexState() {
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1408
|
+
let row;
|
|
1409
|
+
try {
|
|
1410
|
+
row = withRetry(() => this.#db
|
|
1411
|
+
.prepare("SELECT COALESCE(SUM(chunk_count), 0) AS total_chunks, COUNT(*) AS total_sources, MAX(indexed_at) AS last_indexed_at FROM sources")
|
|
1412
|
+
.get());
|
|
1413
|
+
}
|
|
1414
|
+
catch (err) {
|
|
1415
|
+
throw storeFailure("store.getIndexState", err, this.#dbPath);
|
|
1416
|
+
}
|
|
1244
1417
|
return {
|
|
1245
1418
|
totalChunks: row.total_chunks ?? 0,
|
|
1246
1419
|
totalSources: row.total_sources ?? 0,
|
|
@@ -1252,7 +1425,15 @@ export class ContentStore {
|
|
|
1252
1425
|
* Use this for inventory/listing where you need all sections, not search.
|
|
1253
1426
|
*/
|
|
1254
1427
|
getChunksBySource(sourceId) {
|
|
1255
|
-
|
|
1428
|
+
let rows;
|
|
1429
|
+
try {
|
|
1430
|
+
// Batch/introspection path (ctx_index directory mode, chunk listing)
|
|
1431
|
+
// — same retry + context contract as the write paths.
|
|
1432
|
+
rows = withRetry(() => this.#stmtChunksBySource.all(sourceId));
|
|
1433
|
+
}
|
|
1434
|
+
catch (err) {
|
|
1435
|
+
throw storeFailure("store.getChunksBySource", err, this.#dbPath);
|
|
1436
|
+
}
|
|
1256
1437
|
return rows.map((r) => ({
|
|
1257
1438
|
title: r.title,
|
|
1258
1439
|
content: r.content,
|
|
@@ -1263,23 +1444,41 @@ export class ContentStore {
|
|
|
1263
1444
|
}
|
|
1264
1445
|
// ── Vocabulary ──
|
|
1265
1446
|
getDistinctiveTerms(sourceId, maxTerms = 40) {
|
|
1266
|
-
|
|
1447
|
+
let stats;
|
|
1448
|
+
try {
|
|
1449
|
+
stats = withRetry(() => this.#stmtSourceChunkCount.get(sourceId));
|
|
1450
|
+
}
|
|
1451
|
+
catch (err) {
|
|
1452
|
+
throw storeFailure("store.getDistinctiveTerms", err, this.#dbPath);
|
|
1453
|
+
}
|
|
1267
1454
|
if (!stats || stats.chunk_count < 3)
|
|
1268
1455
|
return [];
|
|
1269
1456
|
const totalChunks = stats.chunk_count;
|
|
1270
1457
|
const minAppearances = 2;
|
|
1271
1458
|
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
|
-
|
|
1459
|
+
// Stream chunks one at a time to avoid loading all content into memory.
|
|
1460
|
+
// Count document frequency (how many sections contain each word).
|
|
1461
|
+
// The whole aggregation is wrapped so a transient IOERR mid-stream
|
|
1462
|
+
// retries from a clean slate — docFreq is rebuilt, never half-populated.
|
|
1463
|
+
const collectDocFreq = () => {
|
|
1464
|
+
const docFreq = new Map();
|
|
1465
|
+
for (const row of this.#stmtChunkContent.iterate(sourceId)) {
|
|
1466
|
+
const words = new Set(row.content
|
|
1467
|
+
.toLowerCase()
|
|
1468
|
+
.split(/[^\p{L}\p{N}_-]+/u)
|
|
1469
|
+
.filter((w) => w.length >= 3 && !STOPWORDS.has(w)));
|
|
1470
|
+
for (const word of words) {
|
|
1471
|
+
docFreq.set(word, (docFreq.get(word) ?? 0) + 1);
|
|
1472
|
+
}
|
|
1282
1473
|
}
|
|
1474
|
+
return docFreq;
|
|
1475
|
+
};
|
|
1476
|
+
let docFreq;
|
|
1477
|
+
try {
|
|
1478
|
+
docFreq = withRetry(collectDocFreq);
|
|
1479
|
+
}
|
|
1480
|
+
catch (err) {
|
|
1481
|
+
throw storeFailure("store.getDistinctiveTerms", err, this.#dbPath);
|
|
1283
1482
|
}
|
|
1284
1483
|
const filtered = Array.from(docFreq.entries())
|
|
1285
1484
|
.filter(([, count]) => count >= minAppearances && count <= maxAppearances);
|
|
@@ -1317,8 +1516,16 @@ export class ContentStore {
|
|
|
1317
1516
|
this.#stmtCleanupChunksTrigram.run(days);
|
|
1318
1517
|
return this.#stmtCleanupSources.run(days);
|
|
1319
1518
|
});
|
|
1320
|
-
|
|
1321
|
-
|
|
1519
|
+
// Write path — same transient-IOERR/BUSY retry + failure context as
|
|
1520
|
+
// store.index. better-sqlite3 and the db-base adapters roll the
|
|
1521
|
+
// transaction back on throw, so re-invoking after a retry is safe.
|
|
1522
|
+
try {
|
|
1523
|
+
const info = withRetry(() => cleanup(maxAgeDays));
|
|
1524
|
+
return info.changes;
|
|
1525
|
+
}
|
|
1526
|
+
catch (err) {
|
|
1527
|
+
throw storeFailure("store.cleanupStaleSources", err, this.#dbPath);
|
|
1528
|
+
}
|
|
1322
1529
|
}
|
|
1323
1530
|
/** Get DB file size in bytes. */
|
|
1324
1531
|
getDBSizeBytes() {
|
|
@@ -1335,7 +1542,11 @@ export class ContentStore {
|
|
|
1335
1542
|
this.#db.exec("INSERT INTO chunks(chunks) VALUES('optimize')");
|
|
1336
1543
|
this.#db.exec("INSERT INTO chunks_trigram(chunks_trigram) VALUES('optimize')");
|
|
1337
1544
|
}
|
|
1338
|
-
catch {
|
|
1545
|
+
catch (err) {
|
|
1546
|
+
// Best effort — don't block indexing, but don't stay silent either:
|
|
1547
|
+
// 'optimize' is a write and its failures were previously invisible.
|
|
1548
|
+
logDbError("store.optimizeFTS", err, this.#dbPath);
|
|
1549
|
+
}
|
|
1339
1550
|
}
|
|
1340
1551
|
close() {
|
|
1341
1552
|
this.#optimizeFTS(); // defragment before close
|