rag-memory-epf-mcp 6.4.2 → 6.4.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/README.md CHANGED
@@ -154,6 +154,30 @@ storeDocument(id, content, metadata)
154
154
 
155
155
  ## Changelog
156
156
 
157
+ ### v6.4.3 (2026-11-02)
158
+
159
+ - **Fixed — re-embedding no longer leaks vector slots.** sqlite-vec puts a new row only into a free slot of the latest
160
+ 1,024-slot chunk, so the old DELETE + INSERT of the same rowid left a hole whenever the row sat in an older chunk (one
161
+ project DB reached 84 chunks for 5,578 vectors, 93.5% empty, 337 of 439 MB). Same-rowid re-embeds (document chunks,
162
+ KG chunks, bulk `embedChunks`, entities) now rewrite the vector in place; entity re-embeds keep their rowid.
163
+ - **Added — idle verified rebuild of the vector tables.** When the engine is idle (no tool call, backfill or
164
+ reconciliation; 60 s quiet; checked at most every 10 min) and a table has at least 8 reclaimable chunks that outweigh
165
+ its live vectors, or holds damaged slots, it is rebuilt in one transaction keeping every searchable rowid and its
166
+ exact vector bytes (verified before commit; rolled back otherwise). Skipped at once if another connection holds the
167
+ write lock. Automatic only up to 20,000 live vectors per table (measured on one Mac: 5,600 = 0.44 s, 20,000 =
168
+ 1.24 s and +267 MB transient). No VACUUM: the file stops growing; it shrinks only through a VACUUM.
169
+ - **Fixed — coverage counts only searchable vectors.** A row whose slot validity bit is 0 (invisible to search) was
170
+ counted as embedded; it is now missing and is repaired: writes and deletes that meet such a slot, or a slot shared by
171
+ two rowids, rebuild the table first in the same transaction, then the backfill re-embeds at the original rowid.
172
+ An unresolvable slot position is reported as an error (`missing: null`), never as a count.
173
+ - **Added — `getKnowledgeGraphStats().server.vector_slots`**: per table slots, valid, validity-0, shared, empty,
174
+ reclaimable chunks, whether a rebuild is due and why, and this process's last attempt.
175
+ - **Added — write-seq signal** (`[rag-memory] write-seq N` on stderr when the engine's own connection changed rows),
176
+ used by the framework launcher to record a DB handoff only after the engine wrote.
177
+ - **Changed — an entity metadata row whose vector row is gone is kept unstamped (missing) instead of deleted**, so the
178
+ backfill re-inserts at the same rowid; new entity rowids are allocated past both tables.
179
+ - Not measured: Windows, Drive-synced DBs under two machines, tables above 20,000 vectors.
180
+
157
181
  ### v6.4.2 (2026-10-31)
158
182
 
159
183
  - **Fixed — the engine no longer keeps the peak memory of its largest embedding.** The ONNX session is opened with
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import { EVIDENCE_FORMAT, type EvidenceItemInput, type EvidenceStatus, type Evid
4
4
  import { EmbeddingGate } from './src/embeddingGate.js';
5
5
  import type { EmbedPriority } from './src/embeddingGate.js';
6
6
  import { BackfillCoordinator } from './src/backfillCoordinator.js';
7
+ import type { RebuildResult, VecTable } from './src/vecSlots.js';
7
8
  export declare const LEGACY_QUERY_INSTRUCTION = "Represent this sentence for searching relevant passages: ";
8
9
  export declare function embeddingInput(text: string, isQuery: boolean, defaultModel?: boolean): string;
9
10
  export declare function makeEmbedFn(model: (input: string, opts: any) => Promise<{
@@ -132,6 +133,20 @@ export declare class RAGKnowledgeGraphManager {
132
133
  __testForceFkOff?: boolean;
133
134
  }): Promise<void>;
134
135
  private openConnection;
136
+ writeSeq: number;
137
+ private tcBase;
138
+ private lastActivityAt;
139
+ private lastWriteAt;
140
+ private lastSlotCheckAt;
141
+ readonly vectorSlotLast: Partial<Record<VecTable, RebuildResult & {
142
+ at: string;
143
+ }>>;
144
+ private static slotCheckMs;
145
+ private static slotQuietMs;
146
+ maybeRebuildVectors(now?: number): RebuildResult[] | null;
147
+ vectorSlotStats(): Record<string, unknown>;
148
+ private totalChanges;
149
+ noteWrites(): void;
135
150
  private ensureCurrentProfile;
136
151
  private buildRealLoader;
137
152
  startReconciliation(): Promise<void>;
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import { migrations } from './src/migrations/migrations.js';
34
34
  import { EmbeddingGate, GateNotReadyError, GateDisabledError, TerminalConfigError } from './src/embeddingGate.js';
35
35
  import { resolveModelCacheDir, preflightCacheDir, artifactKey, ModelDownloadLock, handleLoaderFailure } from './src/modelCache.js';
36
36
  import { BackfillCoordinator } from './src/backfillCoordinator.js';
37
+ import { writeVector, deleteVector, registerVecSlotFunctions, InvalidSlotError, slotSummary, rebuildVecTable, rebuildReason, DEFAULT_REBUILD_POLICY } from './src/vecSlots.js';
37
38
  import { calendarDate, resolveCalendarTimeZone, stampDatePrefix, stripDatePrefix } from './src/observations/date-prefix.js';
38
39
  import os from 'node:os';
39
40
  import { createHash } from 'crypto';
@@ -326,6 +327,7 @@ export class RAGKnowledgeGraphManager {
326
327
  openConnection(forceFkOff = false) {
327
328
  this.db = new Database(DB_FILE_PATH);
328
329
  sqliteVec.load(this.db);
330
+ registerVecSlotFunctions(this.db); // vec_slot_valid() for the D1c searchability predicate
329
331
  this.db.pragma('journal_mode = WAL');
330
332
  this.db.pragma('synchronous = NORMAL');
331
333
  this.db.pragma('busy_timeout = 5000');
@@ -356,6 +358,119 @@ export class RAGKnowledgeGraphManager {
356
358
  `for history integrity; refusing to run migrations without it.`);
357
359
  }
358
360
  }
361
+ // Write-sequence baseline for THIS connection (see noteWrites). A fresh connection starts
362
+ // its own total_changes() count, so the baseline is reset on every open; the announced
363
+ // sequence itself never goes back.
364
+ this.tcBase = this.totalChanges();
365
+ }
366
+ // --- Write-sequence signal (test/write-seq.test.mjs) ------------------------------------
367
+ // One stderr line per observation that THIS connection changed rows:
368
+ // [rag-memory] write-seq <N> (N = 1, 2, 3, … for the life of the process)
369
+ // The framework launcher (mcp-proxy.cjs) records "the DB state this machine left" only
370
+ // after such a line. Before this signal it recorded whenever the file changed and went
371
+ // quiet — which also fired when a cloud sync client swapped the other machine's file in
372
+ // under an idle engine, and that record then refused a correct DB (measured 2026-11-02).
373
+ // total_changes() counts rows changed by this connection (vec0 shadow tables included);
374
+ // reads and other connections do not move it. It is read outside a transaction only, so a
375
+ // statement still inside one is announced after it ends. It also counts rows of a
376
+ // transaction that was rolled back: a line means "this connection's change count went up",
377
+ // not "a commit happened". The launcher consumes a pending sequence once the file it last
378
+ // recorded is still there, the WAL is empty and the file is stable; until then a sequence that
379
+ // came from a rollback can still publish a file another writer or a sync client changed, and
380
+ // that period has no fixed upper bound (hub session 251 review r1 M1 · r3. r2: a WAL-size
381
+ // test was tried and removed — a header-only WAL looks non-empty and another connection's
382
+ // checkpoint hides a real commit).
383
+ // Written with fs.writeSync: the last call runs inside process 'exit', where an async
384
+ // stderr pipe write (macOS) can be lost.
385
+ writeSeq = 0;
386
+ tcBase = 0;
387
+ // ---- vec-slot-reuse D1b: idle verified rebuild of the vec0 tables -------------------------------
388
+ // Initial operating values (2_ s253 r2 Q3: starting values, not optima): look at most every 10 min,
389
+ // only after 60 s without a tool call or an own-connection write, never while a tool call, a backfill
390
+ // scan or a reconciliation is running. A repair request (validity-0 rows seen) skips the 10-min wait.
391
+ // The saving threshold and the live-vector cap live in DEFAULT_REBUILD_POLICY (src/vecSlots.ts).
392
+ lastActivityAt = Date.now();
393
+ lastWriteAt = 0;
394
+ lastSlotCheckAt = 0;
395
+ // Last attempt per table. Process memory only: unknown after a restart (stats says so).
396
+ vectorSlotLast = {};
397
+ static slotCheckMs = Number(process.env.RAG_MEMORY_SLOT_CHECK_MS ?? 600_000);
398
+ static slotQuietMs = Number(process.env.RAG_MEMORY_SLOT_QUIET_MS ?? 60_000);
399
+ maybeRebuildVectors(now = Date.now()) {
400
+ if (!this.db || this.shuttingDown || this.inFlight > 0 || this.db.inTransaction)
401
+ return null;
402
+ if (this.coordinator?.busy)
403
+ return null;
404
+ if (now - Math.max(this.lastActivityAt, this.lastWriteAt) < RAGKnowledgeGraphManager.slotQuietMs)
405
+ return null;
406
+ // A repair request skips the 10-min wait once: only if it arrived after the last check (r3 M5 — otherwise
407
+ // a busy / failed / over-cap attempt would be retried on every 1 s tick).
408
+ const repair = this.coordinator?.repairRequested ?? false;
409
+ const freshRepair = repair && (this.coordinator?.repairRequestedAt ?? 0) > this.lastSlotCheckAt;
410
+ if (!freshRepair && now - this.lastSlotCheckAt < RAGKnowledgeGraphManager.slotCheckMs)
411
+ return null;
412
+ this.lastSlotCheckAt = now;
413
+ const results = [];
414
+ for (const table of ['chunks', 'entity_embeddings']) {
415
+ const r = rebuildVecTable(this.db, table);
416
+ this.vectorSlotLast[table] = { ...r, at: new Date(now).toISOString() };
417
+ results.push(r);
418
+ if (r.status === 'rebuilt') {
419
+ console.error(`🧹 vec0 ${table} rebuilt (${r.reason}): chunks ${r.before?.chunks} -> ${r.after?.chunks}, ` +
420
+ `valid ${r.after?.valid}, dropped validity-0 ${r.before?.invalid}, shared-slot ${r.before?.aliased}, ${r.ms} ms`);
421
+ }
422
+ else if (r.status !== 'not-needed') {
423
+ console.error(`⚠️ vec0 ${table} rebuild ${r.status}${r.error ? `: ${r.error}` : ''}`);
424
+ }
425
+ }
426
+ if (repair && results.every(r => r.status === 'rebuilt' || r.status === 'not-needed'))
427
+ this.coordinator?.takeRepairRequest();
428
+ if (results.some(r => r.status === 'rebuilt')) {
429
+ this.coordinator?.invalidateCoverage();
430
+ this.noteWrites();
431
+ this.coordinator?.kick(); // rows dropped as validity-0 are now missing -> re-embedded at their rowid
432
+ }
433
+ return results;
434
+ }
435
+ vectorSlotStats() {
436
+ const out = {};
437
+ for (const table of ['chunks', 'entity_embeddings']) {
438
+ let summary;
439
+ try {
440
+ summary = slotSummary(this.db, table);
441
+ }
442
+ catch (e) {
443
+ summary = { error: e.message };
444
+ }
445
+ const pending = 'error' in summary ? 'position-error'
446
+ : (summary.valid > DEFAULT_REBUILD_POLICY.maxLive ? 'over-cap' : (rebuildReason(summary) ?? null));
447
+ const last = this.vectorSlotLast[table];
448
+ out[table] = { ...summary, due: pending,
449
+ last_attempt: last ? { at: last.at, status: last.status, reason: last.reason ?? null, ms: last.ms, error: last.error ?? null }
450
+ : 'unknown (process memory; none since start)' };
451
+ }
452
+ out.policy = { ...DEFAULT_REBUILD_POLICY, check_ms: RAGKnowledgeGraphManager.slotCheckMs, quiet_ms: RAGKnowledgeGraphManager.slotQuietMs,
453
+ repair_requested: this.coordinator?.repairRequested ?? false, vacuum: 'never (maintenance script only)' };
454
+ return out;
455
+ }
456
+ totalChanges() {
457
+ if (!this.db)
458
+ return 0;
459
+ return Number(this.db.prepare('select total_changes()').pluck().get());
460
+ }
461
+ noteWrites() {
462
+ try {
463
+ if (!this.db || this.db.inTransaction)
464
+ return;
465
+ const tc = this.totalChanges();
466
+ if (tc <= this.tcBase)
467
+ return;
468
+ this.tcBase = tc;
469
+ this.lastWriteAt = Date.now();
470
+ this.writeSeq += 1;
471
+ fsSync.writeSync(2, `[rag-memory] write-seq ${this.writeSeq}\n`);
472
+ }
473
+ catch { /* a dead handle is the reopen path's business; the next tick tries again */ }
359
474
  }
360
475
  // Upsert the stored-vector compatibility profile (spec §6c layer 2) and record
361
476
  // retrieval config in server_meta (layer 3 — never a backfill trigger).
@@ -552,7 +667,7 @@ export class RAGKnowledgeGraphManager {
552
667
  const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
553
668
  .get(entityId);
554
669
  if (meta) {
555
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
670
+ deleteVector(this.db, 'entity_embeddings', Number(meta.rowid));
556
671
  this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
557
672
  }
558
673
  deleteStaleKgChunks(this.db, entityId);
@@ -567,7 +682,7 @@ export class RAGKnowledgeGraphManager {
567
682
  if (!meta)
568
683
  return;
569
684
  const tx = this.db.transaction(() => {
570
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
685
+ deleteVector(this.db, 'entity_embeddings', Number(meta.rowid));
571
686
  this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
572
687
  });
573
688
  tx();
@@ -597,8 +712,7 @@ export class RAGKnowledgeGraphManager {
597
712
  return false; // inputs changed
598
713
  if (this.currentProfileId !== captured.profile_id)
599
714
  return false; // profile changed during inference
600
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${rowid}`);
601
- this.db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(Buffer.from(embedding.buffer));
715
+ writeVector(this.db, 'chunks', rowid, Buffer.from(embedding.buffer)); // same slot when valid (vec-slot-reuse)
602
716
  this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
603
717
  .run(captured.text_sha256, captured.profile_id, rowid);
604
718
  const meta = parseEvidenceMetadata(cur.metadata);
@@ -610,7 +724,17 @@ export class RAGKnowledgeGraphManager {
610
724
  this.db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = 'chunk' AND target_id = ?`).run(String(rowid));
611
725
  return true;
612
726
  });
613
- const written = tx();
727
+ let written;
728
+ try {
729
+ written = tx();
730
+ }
731
+ catch (e) {
732
+ if (e instanceof InvalidSlotError) {
733
+ this.coordinator?.requestRepair();
734
+ return false;
735
+ }
736
+ throw e;
737
+ }
614
738
  this.coordinator?.invalidateCoverage();
615
739
  return written;
616
740
  }
@@ -648,14 +772,23 @@ export class RAGKnowledgeGraphManager {
648
772
  const cur = this.db.prepare(`SELECT text FROM chunk_metadata WHERE rowid = ?`).get(rowid);
649
773
  if (!cur || cur.text !== row.text)
650
774
  return false; // superseded — discard
651
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${safe}`);
652
- this.db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${safe}, ?)`).run(Buffer.from(embedding.buffer));
775
+ writeVector(this.db, 'chunks', safe, Buffer.from(embedding.buffer));
653
776
  this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
654
777
  .run(hash, this.currentProfileId, rowid);
655
778
  this.db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = 'chunk' AND target_id = ?`).run(String(rowid));
656
779
  return true;
657
780
  });
658
- const written = tx();
781
+ let written;
782
+ try {
783
+ written = tx();
784
+ }
785
+ catch (e) {
786
+ if (e instanceof InvalidSlotError) {
787
+ this.coordinator?.requestRepair();
788
+ return false;
789
+ }
790
+ throw e;
791
+ }
659
792
  this.coordinator?.invalidateCoverage();
660
793
  return written;
661
794
  }
@@ -872,6 +1005,7 @@ export class RAGKnowledgeGraphManager {
872
1005
  console.error(`⚠️ reopen storm (${REOPEN_STORM_MAX} in ${REOPEN_STORM_WINDOW_MS / 1000}s) — watch paused, reason=${reason}`);
873
1006
  return false;
874
1007
  }
1008
+ this.noteWrites(); // rows changed on the old connection are announced before its count is gone
875
1009
  const old = this.db;
876
1010
  // Salvage: frames committed to the old WAL after the last checkpoint (≤1 s) go to the main
877
1011
  // file if the old descriptor is still readable. An I/O error here just means they are gone.
@@ -977,14 +1111,22 @@ export class RAGKnowledgeGraphManager {
977
1111
  catch (e) {
978
1112
  console.error(`⚠️ wal tick: ${e.message}`);
979
1113
  }
1114
+ try {
1115
+ this.maybeRebuildVectors();
1116
+ }
1117
+ catch (e) {
1118
+ console.error(`⚠️ vec0 rebuild check: ${e.message}`);
1119
+ }
980
1120
  this.checkpointWal();
1121
+ this.noteWrites();
981
1122
  }, tickMs);
982
1123
  this.walTimer.unref();
983
1124
  }
984
1125
  noteActivity(delayMs = 200) {
1126
+ this.lastActivityAt = Date.now();
985
1127
  if (this.walSoon)
986
1128
  return;
987
- this.walSoon = setTimeout(() => { this.walSoon = null; this.checkpointWal(); }, delayMs);
1129
+ this.walSoon = setTimeout(() => { this.walSoon = null; this.checkpointWal(); this.noteWrites(); }, delayMs);
988
1130
  this.walSoon.unref();
989
1131
  }
990
1132
  cleanup() {
@@ -1006,6 +1148,7 @@ export class RAGKnowledgeGraphManager {
1006
1148
  // close() folds the WAL only for the last connection; with other engines attached it
1007
1149
  // would leave every frame behind.
1008
1150
  this.checkpointWal();
1151
+ this.noteWrites();
1009
1152
  this.db.close();
1010
1153
  this.db = null;
1011
1154
  }
@@ -1319,13 +1462,11 @@ export class RAGKnowledgeGraphManager {
1319
1462
  SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?
1320
1463
  `).get(eid);
1321
1464
  if (embeddingMetadata) {
1322
- const embeddings = this.db.prepare(`
1323
- DELETE FROM entity_embeddings WHERE rowid = ?
1324
- `).run(embeddingMetadata.rowid);
1465
+ const vecState = deleteVector(this.db, 'entity_embeddings', Number(embeddingMetadata.rowid));
1325
1466
  const metadata = this.db.prepare(`
1326
1467
  DELETE FROM entity_embedding_metadata WHERE entity_id = ?
1327
1468
  `).run(eid);
1328
- if (embeddings.changes > 0 || metadata.changes > 0) {
1469
+ if (vecState === 'valid' || metadata.changes > 0) {
1329
1470
  console.error(` ├─ Removed entity embeddings for '${name}'`);
1330
1471
  }
1331
1472
  }
@@ -1446,7 +1587,7 @@ export class RAGKnowledgeGraphManager {
1446
1587
  const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
1447
1588
  .get(p.entityId);
1448
1589
  if (meta) {
1449
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
1590
+ deleteVector(this.db, 'entity_embeddings', Number(meta.rowid));
1450
1591
  this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`)
1451
1592
  .run(p.entityId);
1452
1593
  }
@@ -2059,19 +2200,40 @@ export class RAGKnowledgeGraphManager {
2059
2200
  SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?
2060
2201
  `).get(entityId);
2061
2202
  if (existingMetadata) {
2062
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(existingMetadata.rowid)}`);
2063
- this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
2203
+ // vec-slot-reuse: keep the rowid and rewrite the vector in place (DELETE + INSERT with a new
2204
+ // rowid leaked one vec0 slot per re-embed). The metadata row is updated, not replaced.
2205
+ writeVector(this.db, 'entity_embeddings', Number(existingMetadata.rowid), Buffer.from(embedding.buffer));
2206
+ this.db.prepare(`
2207
+ UPDATE entity_embedding_metadata
2208
+ SET embedding_text = ?, input_hash = ?, profile_id = ?, provenance_state = 'verified'
2209
+ WHERE rowid = ?
2210
+ `).run(embeddingText, inputHash, this.currentProfileId, existingMetadata.rowid);
2211
+ return true;
2064
2212
  }
2065
- const result = this.db.prepare(`
2066
- INSERT INTO entity_embeddings (embedding) VALUES (?)
2067
- `).run(Buffer.from(embedding.buffer));
2213
+ // r4 M2: metadata rows can now outlive their vector (kept unstamped to keep the rowid), so the vec0
2214
+ // auto rowid could collide with one. Allocate past both tables, in this transaction.
2215
+ const nextRowid = BigInt(this.db.prepare(`SELECT MAX(r) m FROM (
2216
+ SELECT MAX(rowid) r FROM entity_embedding_metadata UNION ALL SELECT MAX(rowid) r FROM entity_embeddings_rowids)`)
2217
+ .safeIntegers(true).get().m ?? 0n) + 1n;
2218
+ writeVector(this.db, 'entity_embeddings', nextRowid, Buffer.from(embedding.buffer));
2219
+ const result = { lastInsertRowid: nextRowid };
2068
2220
  this.db.prepare(`
2069
2221
  INSERT INTO entity_embedding_metadata (rowid, entity_id, embedding_text, input_hash, profile_id, provenance_state)
2070
2222
  VALUES (?, ?, ?, ?, ?, 'verified')
2071
2223
  `).run(result.lastInsertRowid, entityId, embeddingText, inputHash, this.currentProfileId);
2072
2224
  return true;
2073
2225
  });
2074
- const written = writeTx();
2226
+ let written;
2227
+ try {
2228
+ written = writeTx();
2229
+ }
2230
+ catch (e) {
2231
+ if (e instanceof InvalidSlotError) {
2232
+ this.coordinator?.requestRepair();
2233
+ return false;
2234
+ }
2235
+ throw e;
2236
+ }
2075
2237
  if (!written) {
2076
2238
  console.error(`⏭️ discarded superseded embedding for ${entityId} (entity changed during inference)`);
2077
2239
  }
@@ -2293,7 +2455,7 @@ export class RAGKnowledgeGraphManager {
2293
2455
  let deletedAssociations = 0;
2294
2456
  for (const chunk of existingChunks) {
2295
2457
  // Delete vector embeddings (vec0 needs literal integer, not parameterized)
2296
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(chunk.rowid)}`);
2458
+ deleteVector(this.db, 'chunks', safeRowid(chunk.rowid));
2297
2459
  deletedVectors++;
2298
2460
  const associations = this.db.prepare(`
2299
2461
  DELETE FROM chunk_entities WHERE chunk_rowid = ?
@@ -2638,7 +2800,7 @@ export class RAGKnowledgeGraphManager {
2638
2800
  const existing = db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id = ?`).all(documentId);
2639
2801
  for (const ch of existing) {
2640
2802
  db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(ch.rowid);
2641
- db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(ch.rowid)}`);
2803
+ deleteVector(db, 'chunks', safeRowid(ch.rowid));
2642
2804
  }
2643
2805
  db.prepare(`DELETE FROM chunk_metadata WHERE document_id = ?`).run(documentId);
2644
2806
  deleteKgChunkRows(db, citedEvidence);
@@ -2653,9 +2815,23 @@ export class RAGKnowledgeGraphManager {
2653
2815
  `).run(chunkId, documentId, seg.chunk_index, seg.text, seg.start_pos, seg.end_pos, seg.start_token, seg.end_token);
2654
2816
  const rowid = Number(info.lastInsertRowid);
2655
2817
  if (vec) {
2656
- db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(vec);
2657
- db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = ? WHERE rowid = ?`)
2658
- .run(shaHex(seg.text), this.currentProfileId, provenance, rowid);
2818
+ // A validity-0 row left at a reused rowid cannot be written over (vec-slot-reuse): the chunk then stays
2819
+ // vectorless (missing) until the idle rebuild drops that row and the backfill embeds it.
2820
+ let wrote = true;
2821
+ try {
2822
+ writeVector(db, 'chunks', rowid, vec);
2823
+ }
2824
+ catch (e) {
2825
+ if (e instanceof InvalidSlotError) {
2826
+ wrote = false;
2827
+ this.coordinator?.requestRepair();
2828
+ }
2829
+ else
2830
+ throw e;
2831
+ }
2832
+ if (wrote)
2833
+ db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = ? WHERE rowid = ?`)
2834
+ .run(shaHex(seg.text), this.currentProfileId, provenance, rowid);
2659
2835
  }
2660
2836
  }
2661
2837
  if (oldRowids.length > 0) {
@@ -2802,10 +2978,7 @@ export class RAGKnowledgeGraphManager {
2802
2978
  // Store in vector table (+ verified provenance, §6a-2 atomic)
2803
2979
  try {
2804
2980
  const tx = this.db.transaction(() => {
2805
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(rowid)}`);
2806
- this.db.prepare(`
2807
- INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)
2808
- `).run(Buffer.from(embedding.buffer));
2981
+ writeVector(this.db, 'chunks', safeRowid(rowid), Buffer.from(embedding.buffer));
2809
2982
  this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
2810
2983
  .run(createHash('sha256').update(chunk.text).digest('hex'), this.currentProfileId, rowid);
2811
2984
  });
@@ -2813,6 +2986,8 @@ export class RAGKnowledgeGraphManager {
2813
2986
  embeddedCount++;
2814
2987
  }
2815
2988
  catch (error) {
2989
+ if (error instanceof InvalidSlotError)
2990
+ this.coordinator?.requestRepair();
2816
2991
  const errMsg = `chunk ${chunk.chunk_id} (rowid=${rowid}, type=${typeof chunk.rowid}): ${error instanceof Error ? error.message : String(error)}`;
2817
2992
  console.error(`Failed to embed ${errMsg}`);
2818
2993
  errors.push(errMsg);
@@ -3251,7 +3426,7 @@ export class RAGKnowledgeGraphManager {
3251
3426
  `).run(chunk.rowid);
3252
3427
  deletedAssociations += associations.changes;
3253
3428
  // Delete vector embeddings (vec0 needs literal integer, not parameterized)
3254
- db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(chunk.rowid)}`);
3429
+ deleteVector(db, 'chunks', safeRowid(chunk.rowid));
3255
3430
  deletedVectors++;
3256
3431
  }
3257
3432
  const metadata = db.prepare(`
@@ -3499,10 +3674,12 @@ export class RAGKnowledgeGraphManager {
3499
3674
  const orphanChunks = this.db.prepare(`SELECT rowid FROM chunk_metadata WHERE chunk_type IN ('entity','relationship')`)
3500
3675
  .all();
3501
3676
  for (const c of orphanChunks) {
3502
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${Number(c.rowid)}`);
3677
+ deleteVector(this.db, 'chunks', Number(c.rowid));
3503
3678
  this.db.prepare(`DELETE FROM chunk_metadata WHERE rowid = ?`).run(c.rowid);
3504
3679
  }
3505
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid IN (SELECT rowid FROM entity_embedding_metadata)`);
3680
+ for (const m of this.db.prepare(`SELECT rowid FROM entity_embedding_metadata`).all()) {
3681
+ deleteVector(this.db, 'entity_embeddings', Number(m.rowid)); // validity-0 rows are left to the rebuild
3682
+ }
3506
3683
  this.db.exec(`DELETE FROM entity_embedding_metadata`);
3507
3684
  console.error('🗑️ Cleared existing data for full import');
3508
3685
  }
@@ -4387,9 +4564,17 @@ export class RAGKnowledgeGraphManager {
4387
4564
  reconciliation_state: this.coordinator?.reconState ?? 'n/a',
4388
4565
  reconciliation_last_error: this.coordinator?.reconLastError ?? null,
4389
4566
  coverage: cov ? {
4390
- chunk: { total: cov.chunk.total, embedded: cov.chunk.embedded, verified: cov.chunk.verified, legacy_assumed: cov.chunk.legacy_assumed, missing: cov.chunk.total - cov.chunk.embedded },
4391
- entity: { total: cov.entity.total, embedded: cov.entity.embedded, verified: cov.entity.verified, legacy_assumed: cov.entity.legacy_assumed, missing: cov.entity.total - cov.entity.embedded },
4567
+ // vec-slot-reuse D1c: an unresolvable slot map is reported as error with missing = null, not as a count.
4568
+ chunk: cov.chunk.error
4569
+ ? { total: cov.chunk.total, embedded: null, verified: null, legacy_assumed: null, missing: null, error: cov.chunk.error }
4570
+ : { total: cov.chunk.total, embedded: cov.chunk.embedded, verified: cov.chunk.verified, legacy_assumed: cov.chunk.legacy_assumed, missing: cov.chunk.total - cov.chunk.embedded },
4571
+ entity: cov.entity.error
4572
+ ? { total: cov.entity.total, embedded: null, verified: null, legacy_assumed: null, missing: null, error: cov.entity.error }
4573
+ : { total: cov.entity.total, embedded: cov.entity.embedded, verified: cov.entity.verified, legacy_assumed: cov.entity.legacy_assumed, missing: cov.entity.total - cov.entity.embedded },
4392
4574
  } : null,
4575
+ // vec-slot-reuse: per vec0 table = slots, searchable, validity-0 rows, reclaimable chunks, why a rebuild
4576
+ // is (not) due, and the last attempt of this process. Read-only: computing it writes nothing.
4577
+ vector_slots: this.db ? this.vectorSlotStats() : null,
4393
4578
  }
4394
4579
  };
4395
4580
  }
@@ -4901,6 +5086,7 @@ async function main() {
4901
5086
  // Synchronously, before anything is awaited: a host may SIGKILL before the settle
4902
5087
  // sequence below completes (codex: ~185 ms after SIGTERM).
4903
5088
  ragKgManager.checkpointWal();
5089
+ ragKgManager.noteWrites();
4904
5090
  void (async () => {
4905
5091
  try {
4906
5092
  await server.close();
@@ -6,12 +6,14 @@ export interface CoverageSnapshot {
6
6
  embedded: number;
7
7
  verified: number;
8
8
  legacy_assumed: number;
9
+ error?: string;
9
10
  };
10
11
  entity: {
11
12
  total: number;
12
13
  embedded: number;
13
14
  verified: number;
14
15
  legacy_assumed: number;
16
+ error?: string;
15
17
  };
16
18
  }
17
19
  export interface CoordinatorDeps {
@@ -37,17 +39,25 @@ export declare class BackfillCoordinator {
37
39
  private shuttingDown;
38
40
  private connLossReruns;
39
41
  private scanning;
42
+ private repairWanted;
43
+ readonly slotErrors: Map<string, string>;
44
+ repairRequestedAt: number;
45
+ requestRepair(): void;
46
+ takeRepairRequest(): boolean;
47
+ get repairRequested(): boolean;
40
48
  private scanPromise;
41
49
  private rerunRequested;
42
50
  private snapshot;
43
51
  constructor(deps: CoordinatorDeps);
44
52
  get reconState(): ReconState;
53
+ get busy(): boolean;
45
54
  get reconLastError(): string | undefined;
46
55
  get eligible(): boolean;
47
56
  private countNullWithVector;
48
57
  private countRepairables;
49
58
  private sanitize;
50
59
  runReconciliation(): Promise<void>;
60
+ private reconActive;
51
61
  private reconcileOnce;
52
62
  private reconcileEntities;
53
63
  private reconcileChunks;