@mxalbert/context-mode 2.0.0 → 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/README.md +4 -14
- package/build/adapters/opencode/plugin.d.ts +2 -0
- package/build/adapters/opencode/plugin.js +92 -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
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
|