@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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "2.0.1"
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.1",
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.1",
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.1",
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.1",
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.1",
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",
@@ -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
- throw new Error(`SQLITE_BUSY/SQLITE_IOERR: transient SQLite error after ${delays.length} retries. ` +
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
- function errorMessage(err) {
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 = new Database(this.#dbPath, { timeout: 30000 });
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 = new Database(this.#dbPath, { timeout: 30000 });
382
- applyWALPragmas(db);
452
+ db = withRetry(openOnce);
383
453
  }
384
454
  catch (retryErr) {
385
- throw new Error(`Failed to create fresh DB after deleting corrupt file: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
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
- throw err;
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
- this.#initSchema();
394
- this.#prepareStatements();
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
- return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
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
- return this.#insertChunks([], source, "", undefined, undefined, attribution);
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
- return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content, undefined, undefined, attribution));
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
- return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
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
- return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
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
- return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
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
- const candidates = this.#stmtFuzzyVocab.all(word.length - maxDist, word.length + maxDist);
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
- const sources = this.#db.prepare("SELECT label, file_path, content_hash, indexed_at FROM sources WHERE file_path IS NOT NULL").all();
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
- const row = this.#stmtSourceMeta.get(label);
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
- return this.#stmtListSources.all();
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
- const row = this.#db
1242
- .prepare("SELECT COALESCE(SUM(chunk_count), 0) AS total_chunks, COUNT(*) AS total_sources, MAX(indexed_at) AS last_indexed_at FROM sources")
1243
- .get();
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
- const rows = this.#stmtChunksBySource.all(sourceId);
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
- const stats = this.#stmtSourceChunkCount.get(sourceId);
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
- const docFreq = new Map();
1275
- for (const row of this.#stmtChunkContent.iterate(sourceId)) {
1276
- const words = new Set(row.content
1277
- .toLowerCase()
1278
- .split(/[^\p{L}\p{N}_-]+/u)
1279
- .filter((w) => w.length >= 3 && !STOPWORDS.has(w)));
1280
- for (const word of words) {
1281
- docFreq.set(word, (docFreq.get(word) ?? 0) + 1);
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
- const info = cleanup(maxAgeDays);
1321
- return info.changes;
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 { /* best effort — don't block indexing */ }
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