clawmem 0.10.7 → 0.11.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/src/store.ts CHANGED
@@ -21,6 +21,9 @@ import {
21
21
  getDefaultLlamaCpp,
22
22
  formatQueryForEmbedding,
23
23
  formatDocForEmbedding,
24
+ sanitizeExpandedQueries,
25
+ expansionFallback,
26
+ isFallbackExpansion,
24
27
  type RerankDocument,
25
28
  } from "./llm.ts";
26
29
  import {
@@ -592,6 +595,25 @@ function initializeDatabase(db: Database): void {
592
595
  }
593
596
  }
594
597
 
598
+ // Centralize the embed-lifecycle reset on content change: ANY path that updates
599
+ // documents.hash (updateDocument, reactivateDocument, indexer reactivation,
600
+ // decision/antipattern merges, saveMemory, beads sync, …) gets the doc's embed
601
+ // state reset, so the new content is re-embedded with a fresh retry budget and is
602
+ // never excluded by the OLD content's exhausted embed_attempts. One trigger covers
603
+ // every caller (codex HIGH, INCIDENT-2026-06-22). It fires only on an actual change
604
+ // (`IS NOT` is null-safe) and does not touch hash, so it cannot recurse. Created
605
+ // right after the embed_* column migrations above, so the columns it references
606
+ // exist. NOT wrapped in try/catch: a creation failure means this load-bearing
607
+ // reset is silently absent, so let it surface loudly rather than hide a footgun.
608
+ db.exec(`
609
+ CREATE TRIGGER IF NOT EXISTS reset_embed_on_hash_change
610
+ AFTER UPDATE OF hash ON documents
611
+ FOR EACH ROW WHEN OLD.hash IS NOT NEW.hash
612
+ BEGIN
613
+ UPDATE documents SET embed_state = 'pending', embed_attempts = 0, embed_error = NULL WHERE id = NEW.id;
614
+ END;
615
+ `);
616
+
595
617
  // Migration: add A-MEM columns to documents
596
618
  const amemMigrations: [string, string][] = [
597
619
  ["amem_keywords", "ALTER TABLE documents ADD COLUMN amem_keywords TEXT"],
@@ -923,32 +945,153 @@ function initializeDatabase(db: Database): void {
923
945
  }
924
946
 
925
947
 
926
- // Per-database dimension cache (WeakMap keyed by db object — no collisions for in-memory DBs)
927
- const vecTableDimsCache = new WeakMap<Database, number>();
928
-
929
948
  // v0.8.1 Ext 6b: per-database cache for the query_text column presence on
930
949
  // context_usage. Set once at migration time so insertUsageFn can pick the
931
950
  // correct INSERT shape without running PRAGMA on every write. Falls back
932
951
  // to `false` (safe — equivalent to pre-migration behavior) when absent.
933
952
  const contextUsageHasQueryTextCache = new WeakMap<Database, boolean>();
934
953
 
935
- function ensureVecTableInternal(db: Database, dimensions: number): void {
936
- if (vecTableDimsCache.get(db) === dimensions) return;
937
-
938
- const tableInfo = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get() as { sql: string } | null;
939
- if (tableInfo) {
940
- const match = tableInfo.sql.match(/float\[(\d+)\]/);
941
- const hasHashSeq = tableInfo.sql.includes('hash_seq');
942
- const hasCosine = tableInfo.sql.includes('distance_metric=cosine');
943
- const existingDims = match?.[1] ? parseInt(match[1], 10) : null;
944
- if (existingDims === dimensions && hasHashSeq && hasCosine) {
945
- vecTableDimsCache.set(db, dimensions);
954
+ /**
955
+ * Fatal, non-recoverable vector-store errors. These abort the embed run rather
956
+ * than being swallowed as per-fragment failures. A dimension/schema mismatch must
957
+ * NEVER silently drop an existing table the only path that clears vectors is the
958
+ * explicit clearAllEmbeddings (gated behind `embed --force`). See
959
+ * INCIDENT-2026-06-22 §12 + EMBED-LEASE-RENEWAL-DESIGN.md.
960
+ */
961
+ export class FatalVectorError extends Error {}
962
+
963
+ export class VecDimensionMismatchError extends FatalVectorError {
964
+ constructor(public readonly existingDim: number, public readonly requestedDim: number) {
965
+ super(`Embedding dimension changed: vectors_vec is float[${existingDim}] but the model now returns float[${requestedDim}]. Run 'clawmem embed --force' to clear and rebuild the full vault.`);
966
+ this.name = "VecDimensionMismatchError";
967
+ }
968
+ }
969
+
970
+ export class VecSchemaError extends FatalVectorError {
971
+ constructor(message: string) {
972
+ super(message);
973
+ this.name = "VecSchemaError";
974
+ }
975
+ }
976
+
977
+ export class VecModelMismatchError extends FatalVectorError {
978
+ constructor(public readonly expectedModel: string, public readonly actualModel: string) {
979
+ super(`Embedding model changed mid-run: the vault is being built with "${expectedModel}" but the endpoint now returns "${actualModel}". Mixing different models in one vector space (even at the same dimension) makes cosine similarity meaningless. Run 'clawmem embed --force' to rebuild with the current model.`);
980
+ this.name = "VecModelMismatchError";
981
+ }
982
+ }
983
+
984
+ // A FatalVectorError so it propagates out of the per-fragment catch and aborts the
985
+ // embed run. Thrown both by cmdEmbed (between fragments) and INSIDE insertEmbedding's
986
+ // write transaction (atomic lease-token fence) when the lease was reclaimed.
987
+ export class EmbedLeaseLostError extends FatalVectorError {
988
+ constructor() {
989
+ super("Embedding lease lost (another embed process took over). Re-run 'clawmem embed'.");
990
+ this.name = "EmbedLeaseLostError";
991
+ }
992
+ }
993
+
994
+ /** A held embedding-lease identity, passed into vector mutations so each can verify
995
+ * ownership before mutating — a process that lost the lease cannot wipe/recreate/write
996
+ * the vector store under the new holder. */
997
+ export type LeaseGuard = { workerName: string; token: string };
998
+
999
+ /**
1000
+ * Throw EmbedLeaseLostError if the caller no longer holds the named lease. Call as the
1001
+ * FIRST statement inside a write transaction (before any mutation), so the check and
1002
+ * the writes are atomic under SQLite's single-writer serialization — no other holder
1003
+ * can interleave a reclaim between the check and the mutation. No-op when leaseGuard
1004
+ * is omitted (non-embed callers).
1005
+ */
1006
+ function assertLeaseHeld(db: Database, leaseGuard?: LeaseGuard): void {
1007
+ if (!leaseGuard) return;
1008
+ const row = db.prepare(`SELECT lease_token FROM worker_leases WHERE worker_name = ?`).get(leaseGuard.workerName) as { lease_token: string } | null;
1009
+ if (!row || row.lease_token !== leaseGuard.token) {
1010
+ throw new EmbedLeaseLostError();
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Single source of truth for vectors_vec schema validation. Reads the table DDL
1016
+ * and returns:
1017
+ * - null → table ABSENT
1018
+ * - <integer> → table VALID (vec0 schema: hash_seq + float[N] + cosine); the N
1019
+ * - throws VecSchemaError → table PRESENT but its schema is unexpected
1020
+ * (missing float[N], hash_seq, or distance_metric=cosine; malformed/legacy DDL).
1021
+ * No caching: a stale per-process dim cache could bypass this validation when
1022
+ * another process changed the table. The embed lease serializes embeds, and the
1023
+ * old cache fast-path is removed so validation is unconditional on every call.
1024
+ * Case-insensitive, whitespace-tolerant.
1025
+ */
1026
+ function readVecTableDim(db: Database): number | null {
1027
+ const info = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get() as { sql: string } | null;
1028
+ if (!info) return null;
1029
+ const m = info.sql.match(/float\s*\[\s*(\d+)\s*\]/i);
1030
+ const hasHashSeq = /\bhash_seq\b/i.test(info.sql);
1031
+ const hasCosine = /distance_metric\s*=\s*cosine/i.test(info.sql);
1032
+ if (!m || !hasHashSeq || !hasCosine) {
1033
+ throw new VecSchemaError(`vectors_vec exists but its schema is unexpected (need hash_seq + float[N] + distance_metric=cosine): ${info.sql}`);
1034
+ }
1035
+ return parseInt(m[1]!, 10);
1036
+ }
1037
+
1038
+ function ensureVecTableInternal(db: Database, dimensions: number, leaseGuard?: LeaseGuard): void {
1039
+ const existing = readVecTableDim(db); // null=absent, N=valid, throws VecSchemaError if malformed
1040
+ if (existing !== null) {
1041
+ // NEVER drop an existing table on a dimension mismatch — that silently destroys
1042
+ // the vault's vectors while the metadata-based worklist skips the now-vectorless
1043
+ // docs (INCIDENT-2026-06-22). Throw; the run aborts. Clear+rebuild happens only
1044
+ // via clearAllEmbeddings (`embed --force`).
1045
+ if (existing !== dimensions) throw new VecDimensionMismatchError(existing, dimensions);
1046
+ return; // exists at the right dim + valid schema → nothing to do
1047
+ }
1048
+ // Table absent → create it ATOMICALLY. The lease assertion, a RE-CHECK of absence,
1049
+ // and the CREATE run in ONE immediate-write-lock transaction, so no other process
1050
+ // can clear/recreate between the check and the CREATE (closes the TOCTOU): a process
1051
+ // that lost its lease cannot create an empty table at the wrong dimension under the
1052
+ // new holder, and two creators cannot race. (vec0 CREATE works inside a transaction.)
1053
+ db.transaction(() => {
1054
+ assertLeaseHeld(db, leaseGuard);
1055
+ const recheck = readVecTableDim(db);
1056
+ if (recheck !== null) {
1057
+ // Another holder created it while we waited for the write lock.
1058
+ if (recheck !== dimensions) throw new VecDimensionMismatchError(recheck, dimensions);
946
1059
  return;
947
1060
  }
948
- db.exec("DROP TABLE IF EXISTS vectors_vec");
949
- }
950
- db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[${dimensions}] distance_metric=cosine)`);
951
- vecTableDimsCache.set(db, dimensions);
1061
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[${dimensions}] distance_metric=cosine)`);
1062
+ }).immediate();
1063
+ }
1064
+
1065
+ /**
1066
+ * Public accessor for the vectors_vec dimension. Returns null if absent, the
1067
+ * integer dimension if valid, or throws VecSchemaError if the table exists with
1068
+ * a malformed/unexpected schema (see readVecTableDim).
1069
+ */
1070
+ export function getVecTableDim(db: Database): number | null {
1071
+ return readVecTableDim(db);
1072
+ }
1073
+
1074
+ /**
1075
+ * The DISTINCT non-empty embedding models stored in the vault (empty array if no
1076
+ * embeddings exist). Used to detect model drift BETWEEN runs — a different model at
1077
+ * the SAME dimension produces a heterogeneous vector space that dimension checks
1078
+ * cannot catch. Returning ALL distinct models (not just the majority) lets callers
1079
+ * also detect an ALREADY-heterogeneous vault (length > 1) instead of hiding the
1080
+ * minority behind a majority match. The implicit embed path aborts and requires
1081
+ * `embed --force` on any drift.
1082
+ */
1083
+ export function getVecModels(db: Database): string[] {
1084
+ // Join to ACTIVE documents so stale/orphaned content_vectors rows (an obsolete
1085
+ // model on an inactive hash, not yet cleaned) cannot trigger a permanent
1086
+ // "mixed models" abort that would block the very cleanup that removes them.
1087
+ const rows = db.prepare(
1088
+ `SELECT DISTINCT cv.model
1089
+ FROM content_vectors cv
1090
+ JOIN documents d ON d.hash = cv.hash AND d.active = 1
1091
+ WHERE cv.model IS NOT NULL AND cv.model != ''
1092
+ ORDER BY cv.model`
1093
+ ).all() as { model: string }[];
1094
+ return rows.map(r => r.model);
952
1095
  }
953
1096
 
954
1097
  // =============================================================================
@@ -959,7 +1102,9 @@ export type Store = {
959
1102
  db: Database;
960
1103
  dbPath: string;
961
1104
  close: () => void;
962
- ensureVecTable: (dimensions: number) => void;
1105
+ ensureVecTable: (dimensions: number, leaseGuard?: LeaseGuard) => void;
1106
+ getVecTableDim: () => number | null;
1107
+ getVecModels: () => string[];
963
1108
 
964
1109
  // Index health
965
1110
  getHashesNeedingEmbedding: () => number;
@@ -976,7 +1121,6 @@ export type Store = {
976
1121
  deleteLLMCache: () => number;
977
1122
  deleteInactiveDocuments: () => number;
978
1123
  cleanupOrphanedContent: () => number;
979
- cleanupOrphanedVectors: () => number;
980
1124
  vacuumDatabase: () => void;
981
1125
 
982
1126
  // Context
@@ -998,7 +1142,7 @@ export type Store = {
998
1142
  searchVec: (query: string, model: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }) => Promise<SearchResult[]>;
999
1143
 
1000
1144
  // Query expansion & reranking
1001
- expandQuery: (query: string, model?: string, intent?: string) => Promise<string[]>;
1145
+ expandQuery: (query: string, model?: string, intent?: string) => Promise<ExpandedQuery[]>;
1002
1146
  rerank: (query: string, documents: { file: string; text: string }[], model?: string, intent?: string) => Promise<{ file: string; score: number }[]>;
1003
1147
 
1004
1148
  // Document retrieval
@@ -1025,9 +1169,10 @@ export type Store = {
1025
1169
  // Vector/embedding operations
1026
1170
  getHashesForEmbedding: () => { hash: string; body: string; path: string }[];
1027
1171
  getHashesNeedingFragments: () => { hash: string; body: string; path: string; title: string; collection: string }[];
1028
- clearAllEmbeddings: () => void;
1029
- insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string) => void;
1030
- cleanStaleEmbeddings: () => number;
1172
+ clearAllEmbeddings: (leaseGuard?: LeaseGuard) => void;
1173
+ getVectorConsistency: () => { cvCount: number; vvCount: number; cvMissingVv: number; vvOrphan: number; pending: number };
1174
+ insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string, leaseGuard?: LeaseGuard) => void;
1175
+ cleanStaleEmbeddings: (leaseGuard?: LeaseGuard) => number;
1031
1176
 
1032
1177
  // SAME: Observation metadata
1033
1178
  updateObservationFields: (docPath: string, collectionName: string, fields: { observation_type?: string; facts?: string; narrative?: string; concepts?: string; files_read?: string; files_modified?: string }) => void;
@@ -1052,6 +1197,7 @@ export type Store = {
1052
1197
  snoozeDocument: (collection: string, path: string, until: string | null) => void;
1053
1198
 
1054
1199
  // Embed state tracking
1200
+ markEmbedStart: (hash: string) => void;
1055
1201
  markEmbedSynced: (hash: string) => void;
1056
1202
  markEmbedFailed: (hash: string, error: string) => void;
1057
1203
  getEmbedStats: () => { pending: number; synced: number; failed: number };
@@ -1150,7 +1296,9 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
1150
1296
  db,
1151
1297
  dbPath: resolvedPath,
1152
1298
  close: () => db.close(),
1153
- ensureVecTable: (dimensions: number) => ensureVecTableInternal(db, dimensions),
1299
+ ensureVecTable: (dimensions: number, leaseGuard?: LeaseGuard) => ensureVecTableInternal(db, dimensions, leaseGuard),
1300
+ getVecTableDim: () => getVecTableDim(db),
1301
+ getVecModels: () => getVecModels(db),
1154
1302
 
1155
1303
  // Index health
1156
1304
  getHashesNeedingEmbedding: () => getHashesNeedingEmbedding(db),
@@ -1167,7 +1315,6 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
1167
1315
  deleteLLMCache: () => deleteLLMCache(db),
1168
1316
  deleteInactiveDocuments: () => deleteInactiveDocuments(db),
1169
1317
  cleanupOrphanedContent: () => cleanupOrphanedContent(db),
1170
- cleanupOrphanedVectors: () => cleanupOrphanedVectors(db),
1171
1318
  vacuumDatabase: () => vacuumDatabase(db),
1172
1319
 
1173
1320
  // Context
@@ -1216,9 +1363,10 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
1216
1363
  // Vector/embedding operations
1217
1364
  getHashesForEmbedding: () => getHashesForEmbedding(db),
1218
1365
  getHashesNeedingFragments: () => getHashesNeedingFragments(db),
1219
- clearAllEmbeddings: () => clearAllEmbeddings(db),
1220
- insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string) => insertEmbedding(db, hash, seq, pos, embedding, model, embeddedAt, fragmentType, fragmentLabel, canonicalId),
1221
- cleanStaleEmbeddings: () => cleanStaleEmbeddings(db),
1366
+ clearAllEmbeddings: (leaseGuard?: LeaseGuard) => clearAllEmbeddings(db, leaseGuard),
1367
+ getVectorConsistency: () => getVectorConsistency(db),
1368
+ insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string, leaseGuard?: LeaseGuard) => insertEmbedding(db, hash, seq, pos, embedding, model, embeddedAt, fragmentType, fragmentLabel, canonicalId, leaseGuard),
1369
+ cleanStaleEmbeddings: (leaseGuard?: LeaseGuard) => cleanStaleEmbeddings(db, leaseGuard),
1222
1370
 
1223
1371
  // SAME: Observation metadata
1224
1372
  updateObservationFields: (docPath: string, collectionName: string, fields) => updateObservationFieldsFn(db, docPath, collectionName, fields),
@@ -1243,11 +1391,23 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
1243
1391
  snoozeDocument: (collection: string, path: string, until: string | null) => snoozeDocumentFn(db, collection, path, until),
1244
1392
 
1245
1393
  // Embed state tracking
1394
+ markEmbedStart: (hash: string) => {
1395
+ // Increment embed_attempts exactly ONCE per attempt, at the start, and set
1396
+ // 'pending' so a crash mid-document leaves the doc retryable (and selected by
1397
+ // getHashesNeedingFragments). The completion setters below are state-only — no
1398
+ // further increment — so a start + a failure for the same attempt cannot
1399
+ // double-count the retry budget.
1400
+ db.prepare(`UPDATE documents SET embed_state = 'pending', embed_error = NULL, embed_attempts = COALESCE(embed_attempts, 0) + 1 WHERE hash = ? AND active = 1`).run(hash);
1401
+ },
1246
1402
  markEmbedSynced: (hash: string) => {
1247
- db.prepare(`UPDATE documents SET embed_state = 'synced' WHERE hash = ? AND active = 1`).run(hash);
1403
+ // Success resets the retry budget: embed_attempts counts CONSECUTIVE failures
1404
+ // of the current content, so a successful (re-)embed must clear it — otherwise
1405
+ // a doc re-embedded many times (repeated content edits) accumulates attempts
1406
+ // and is wrongly excluded by the worklist's `embed_attempts < 3` guard.
1407
+ db.prepare(`UPDATE documents SET embed_state = 'synced', embed_attempts = 0, embed_error = NULL WHERE hash = ? AND active = 1`).run(hash);
1248
1408
  },
1249
1409
  markEmbedFailed: (hash: string, error: string) => {
1250
- db.prepare(`UPDATE documents SET embed_state = 'failed', embed_error = ?, embed_attempts = COALESCE(embed_attempts, 0) + 1 WHERE hash = ? AND active = 1`).run(error, hash);
1410
+ db.prepare(`UPDATE documents SET embed_state = 'failed', embed_error = ? WHERE hash = ? AND active = 1`).run(error, hash);
1251
1411
  },
1252
1412
  getEmbedStats: () => {
1253
1413
  const stats = db.prepare(`
@@ -1923,51 +2083,9 @@ export function cleanupOrphanedContent(db: Database): number {
1923
2083
  return result.changes;
1924
2084
  }
1925
2085
 
1926
- /**
1927
- * Remove orphaned vector embeddings that are not referenced by any active document.
1928
- * Returns the number of orphaned embedding chunks deleted.
1929
- */
1930
- export function cleanupOrphanedVectors(db: Database): number {
1931
- // Check if vectors_vec table exists
1932
- const tableExists = db.prepare(`
1933
- SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'
1934
- `).get();
1935
-
1936
- if (!tableExists) {
1937
- return 0;
1938
- }
1939
-
1940
- // Count orphaned vectors first
1941
- const countResult = db.prepare(`
1942
- SELECT COUNT(*) as c FROM content_vectors cv
1943
- WHERE NOT EXISTS (
1944
- SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
1945
- )
1946
- `).get() as { c: number };
1947
-
1948
- if (countResult.c === 0) {
1949
- return 0;
1950
- }
1951
-
1952
- // Delete from vectors_vec first
1953
- db.exec(`
1954
- DELETE FROM vectors_vec WHERE hash_seq IN (
1955
- SELECT cv.hash || '_' || cv.seq FROM content_vectors cv
1956
- WHERE NOT EXISTS (
1957
- SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
1958
- )
1959
- )
1960
- `);
1961
-
1962
- // Delete from content_vectors
1963
- db.exec(`
1964
- DELETE FROM content_vectors WHERE hash NOT IN (
1965
- SELECT hash FROM documents WHERE active = 1
1966
- )
1967
- `);
1968
-
1969
- return countResult.c;
1970
- }
2086
+ // (Removed cleanupOrphanedVectors — an exposed, unfenced, non-transactional vector
2087
+ // mutation with no production caller. Use cleanStaleEmbeddings, which is lease-fenced
2088
+ // and atomic. See INCIDENT-2026-06-22 / codex review.)
1971
2089
 
1972
2090
  /**
1973
2091
  * Run VACUUM to reclaim unused space in the database.
@@ -1996,35 +2114,40 @@ export function canonicalDocId(collection: string, path: string): string {
1996
2114
  * to any active document. Also cleans the corresponding vectors_vec rows.
1997
2115
  * Returns the number of stale embeddings removed.
1998
2116
  */
1999
- export function cleanStaleEmbeddings(db: Database): number {
2000
- // Find orphaned hashes in content_vectors that have no active document
2001
- const staleRows = db.prepare(`
2002
- SELECT DISTINCT cv.hash
2003
- FROM content_vectors cv
2004
- LEFT JOIN documents d ON d.hash = cv.hash AND d.active = 1
2005
- WHERE d.id IS NULL
2006
- `).all() as { hash: string }[];
2007
-
2008
- if (staleRows.length === 0) return 0;
2009
-
2010
- const staleHashes = staleRows.map(r => r.hash);
2011
-
2012
- // Get all hash_seq keys for stale rows to clean vectors_vec
2013
- const placeholders = staleHashes.map(() => '?').join(',');
2014
- const staleVecKeys = db.prepare(`
2015
- SELECT hash || '_' || seq as hash_seq FROM content_vectors WHERE hash IN (${placeholders})
2016
- `).all(...staleHashes) as { hash_seq: string }[];
2017
-
2018
- // Delete from vectors_vec
2019
- if (staleVecKeys.length > 0) {
2020
- const vecPlaceholders = staleVecKeys.map(() => '?').join(',');
2021
- db.prepare(`DELETE FROM vectors_vec WHERE hash_seq IN (${vecPlaceholders})`).run(...staleVecKeys.map(r => r.hash_seq));
2022
- }
2117
+ export function cleanStaleEmbeddings(db: Database, leaseGuard?: LeaseGuard): number {
2118
+ // Atomic + lease-fenced: the ownership check and the deletes share one transaction
2119
+ // so a process that lost its lease cannot delete vectors out from under the new holder.
2120
+ return db.transaction(() => {
2121
+ assertLeaseHeld(db, leaseGuard);
2122
+ // Find orphaned hashes in content_vectors that have no active document
2123
+ const staleRows = db.prepare(`
2124
+ SELECT DISTINCT cv.hash
2125
+ FROM content_vectors cv
2126
+ LEFT JOIN documents d ON d.hash = cv.hash AND d.active = 1
2127
+ WHERE d.id IS NULL
2128
+ `).all() as { hash: string }[];
2129
+
2130
+ if (staleRows.length === 0) return 0;
2131
+
2132
+ const staleHashes = staleRows.map(r => r.hash);
2133
+
2134
+ // Get all hash_seq keys for stale rows to clean vectors_vec
2135
+ const placeholders = staleHashes.map(() => '?').join(',');
2136
+ const staleVecKeys = db.prepare(`
2137
+ SELECT hash || '_' || seq as hash_seq FROM content_vectors WHERE hash IN (${placeholders})
2138
+ `).all(...staleHashes) as { hash_seq: string }[];
2139
+
2140
+ // Delete from vectors_vec
2141
+ if (staleVecKeys.length > 0) {
2142
+ const vecPlaceholders = staleVecKeys.map(() => '?').join(',');
2143
+ db.prepare(`DELETE FROM vectors_vec WHERE hash_seq IN (${vecPlaceholders})`).run(...staleVecKeys.map(r => r.hash_seq));
2144
+ }
2023
2145
 
2024
- // Delete from content_vectors
2025
- db.prepare(`DELETE FROM content_vectors WHERE hash IN (${placeholders})`).run(...staleHashes);
2146
+ // Delete from content_vectors
2147
+ db.prepare(`DELETE FROM content_vectors WHERE hash IN (${placeholders})`).run(...staleHashes);
2026
2148
 
2027
- return staleVecKeys.length;
2149
+ return staleVecKeys.length;
2150
+ }).immediate(); // immediate write lock: assert ownership under the lock before the deletes
2028
2151
  }
2029
2152
 
2030
2153
  // =============================================================================
@@ -2410,6 +2533,8 @@ export function reactivateDocument(
2410
2533
  modifiedAt: string
2411
2534
  ): void {
2412
2535
  const safeTitle = (typeof title === "string") ? title : String(title ?? "Untitled");
2536
+ // The reset_embed_on_hash_change trigger resets embed_state/attempts/error iff the
2537
+ // hash actually changes, so re-adding unchanged content preserves its valid vectors.
2413
2538
  db.prepare(`UPDATE documents SET active = 1, title = ?, hash = ?, modified_at = ? WHERE id = ?`)
2414
2539
  .run(safeTitle, hash, modifiedAt, documentId);
2415
2540
  }
@@ -2439,6 +2564,8 @@ export function updateDocument(
2439
2564
  modifiedAt: string
2440
2565
  ): void {
2441
2566
  const safeTitle = (typeof title === "string") ? title : String(title ?? "Untitled");
2567
+ // The reset_embed_on_hash_change trigger resets embed_state/attempts/error when the
2568
+ // hash actually changes, so this only needs to set the content fields.
2442
2569
  db.prepare(`UPDATE documents SET title = ?, hash = ?, modified_at = ? WHERE id = ?`)
2443
2570
  .run(safeTitle, hash, modifiedAt, documentId);
2444
2571
  }
@@ -3305,6 +3432,9 @@ export function getHashesForEmbedding(db: Database): { hash: string; body: strin
3305
3432
  export function getHashesNeedingFragments(db: Database): { hash: string; body: string; path: string; title: string; collection: string }[] {
3306
3433
  // Select docs that either have no fragments at all OR are missing the primary (seq=0) fragment.
3307
3434
  // The seq=0 embedding is critical — surprisal scoring, semantic graph, and health checks depend on it.
3435
+ // Also retry docs left 'pending' (crash mid-doc) or 'failed' (partial fragment failure) so partial
3436
+ // embeds are not permanently silent — bounded by embed_attempts < 3. The OR-branch is parenthesized
3437
+ // so embed_attempts < 3 and d.active = 1 always apply to every selected row (SQL precedence).
3308
3438
  return db.prepare(`
3309
3439
  SELECT d.hash, c.doc as body, MIN(d.path) as path, MIN(d.title) as title, MIN(d.collection) as collection
3310
3440
  FROM documents d
@@ -3312,8 +3442,8 @@ export function getHashesNeedingFragments(db: Database): { hash: string; body: s
3312
3442
  LEFT JOIN content_vectors v ON d.hash = v.hash AND v.fragment_type IS NOT NULL
3313
3443
  LEFT JOIN content_vectors v0 ON d.hash = v0.hash AND v0.seq = 0
3314
3444
  WHERE d.active = 1
3315
- AND (v.hash IS NULL OR v0.hash IS NULL)
3316
3445
  AND COALESCE(d.embed_attempts, 0) < 3
3446
+ AND ((v.hash IS NULL OR v0.hash IS NULL) OR d.embed_state IN ('pending', 'failed'))
3317
3447
  GROUP BY d.hash
3318
3448
  `).all() as { hash: string; body: string; path: string; title: string; collection: string }[];
3319
3449
  }
@@ -3322,12 +3452,53 @@ export function getHashesNeedingFragments(db: Database): { hash: string; body: s
3322
3452
  * Clear all embeddings from the database (force re-index).
3323
3453
  * Deletes all rows from content_vectors and drops the vectors_vec table.
3324
3454
  */
3325
- export function clearAllEmbeddings(db: Database): void {
3326
- db.exec(`DELETE FROM content_vectors`);
3327
- db.exec(`DROP TABLE IF EXISTS vectors_vec`);
3328
- // Reset embed state so failed docs get retried after force re-embed
3329
- try { db.exec(`UPDATE documents SET embed_state = 'pending', embed_error = NULL, embed_attempts = 0 WHERE active = 1`); } catch { /* column may not exist yet */ }
3330
- vecTableDimsCache.delete(db);
3455
+ export function clearAllEmbeddings(db: Database, leaseGuard?: LeaseGuard): void {
3456
+ // Atomic: the lease check + DELETE content_vectors + DROP vectors_vec + reset
3457
+ // embed_state all commit together. The in-transaction assertLeaseHeld means a
3458
+ // `--force` process that lost its lease during the endpoint probe cannot wipe the
3459
+ // vault out from under the new holder (the destructive op is the highest-risk
3460
+ // mutation, so it MUST be fenced). A crash/concurrent reader never observes a
3461
+ // half-cleared state.
3462
+ db.transaction(() => {
3463
+ assertLeaseHeld(db, leaseGuard);
3464
+ db.exec(`DELETE FROM content_vectors`);
3465
+ db.exec(`DROP TABLE IF EXISTS vectors_vec`);
3466
+ // Reset embed state so failed docs get retried after force re-embed
3467
+ try { db.exec(`UPDATE documents SET embed_state = 'pending', embed_error = NULL, embed_attempts = 0 WHERE active = 1`); } catch { /* column may not exist yet */ }
3468
+ }).immediate(); // immediate write lock: assert ownership under the lock before this destructive op
3469
+ }
3470
+
3471
+ /**
3472
+ * Vault-wide content_vectors ↔ vectors_vec consistency snapshot for `doctor`.
3473
+ * Computes BOTH key-set differences (not just counts — one missing + one orphan
3474
+ * cancel in a count check) under a single read transaction so both scans see the
3475
+ * same committed snapshot. Any nonzero cvMissingVv / vvOrphan is an invariant
3476
+ * violation (every content_vectors row must have a vectors_vec entry and vice-versa).
3477
+ * `pending` is reported separately (pending docs are in neither table, so they are
3478
+ * not a desync). See INCIDENT-2026-06-22 §12.
3479
+ */
3480
+ export function getVectorConsistency(db: Database): {
3481
+ cvCount: number; vvCount: number; cvMissingVv: number; vvOrphan: number; pending: number;
3482
+ } {
3483
+ return db.transaction(() => {
3484
+ const cvKeys = new Set<string>(
3485
+ (db.prepare(`SELECT hash || '_' || seq AS k FROM content_vectors`).all() as { k: string }[]).map(r => r.k)
3486
+ );
3487
+ let vvKeys = new Set<string>();
3488
+ try {
3489
+ vvKeys = new Set<string>(
3490
+ (db.prepare(`SELECT hash_seq FROM vectors_vec`).all() as { hash_seq: string }[]).map(r => r.hash_seq)
3491
+ );
3492
+ } catch { /* vectors_vec absent → empty set (cvMissingVv will surface it) */ }
3493
+ let cvMissingVv = 0;
3494
+ for (const k of cvKeys) if (!vvKeys.has(k)) cvMissingVv++;
3495
+ let vvOrphan = 0;
3496
+ for (const k of vvKeys) if (!cvKeys.has(k)) vvOrphan++;
3497
+ const pending = (db.prepare(
3498
+ `SELECT COUNT(*) AS n FROM documents WHERE active = 1 AND (embed_state = 'pending' OR embed_state IS NULL)`
3499
+ ).get() as { n: number }).n;
3500
+ return { cvCount: cvKeys.size, vvCount: vvKeys.size, cvMissingVv, vvOrphan, pending };
3501
+ })();
3331
3502
  }
3332
3503
 
3333
3504
  /**
@@ -3344,44 +3515,115 @@ export function insertEmbedding(
3344
3515
  embeddedAt: string,
3345
3516
  fragmentType?: string,
3346
3517
  fragmentLabel?: string,
3347
- canonicalId?: string
3518
+ canonicalId?: string,
3519
+ leaseGuard?: LeaseGuard
3348
3520
  ): void {
3349
3521
  const hashSeq = `${hash}_${seq}`;
3350
- // vec0 virtual tables don't support INSERT OR REPLACEdelete first if exists.
3351
- // Try-catch: table may not exist yet during dimension migration (ensureVecTable drops+recreates).
3352
- try { db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`).run(hashSeq); } catch {}
3353
- db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`).run(hashSeq, embedding);
3354
- db.prepare(
3355
- `INSERT OR REPLACE INTO content_vectors (hash, seq, pos, model, embedded_at, fragment_type, fragment_label, canonical_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
3356
- ).run(hash, seq, pos, model, embeddedAt, fragmentType ?? null, fragmentLabel ?? null, canonicalId ?? null);
3522
+ // Atomic vec0 + metadata write: the DELETE (vec0's "upsert"no INSERT OR
3523
+ // REPLACE on vec0), the vector INSERT, and the content_vectors UPSERT commit
3524
+ // together, so an interruption can never leave a vector without its metadata
3525
+ // row or vice-versa (the cv↔vv invariant the doctor check enforces).
3526
+ // ensureVecTable is always called before this and now throws (never drops)
3527
+ // on a dimension mismatch, so vectors_vec reliably exists here the old
3528
+ // DELETE error-suppression for the "table missing mid-migration" case is gone.
3529
+ //
3530
+ // leaseGuard (optional): an in-transaction embedding-lease fence. The token
3531
+ // check and the writes share ONE transaction, so a process that lost the lease
3532
+ // while awaiting the model (between its loop-level leaseLost check and this
3533
+ // write) cannot commit a stale vector — SQLite serializes the transaction, so
3534
+ // no other holder can interleave a reclaim between the check and the INSERTs.
3535
+ db.transaction(() => {
3536
+ assertLeaseHeld(db, leaseGuard);
3537
+ db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`).run(hashSeq);
3538
+ db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`).run(hashSeq, embedding);
3539
+ db.prepare(
3540
+ `INSERT OR REPLACE INTO content_vectors (hash, seq, pos, model, embedded_at, fragment_type, fragment_label, canonical_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
3541
+ ).run(hash, seq, pos, model, embeddedAt, fragmentType ?? null, fragmentLabel ?? null, canonicalId ?? null);
3542
+ }).immediate(); // immediate write lock: the lease assert reads under the lock, so a lost-lease write can't slip through
3357
3543
  }
3358
3544
 
3359
3545
  // =============================================================================
3360
3546
  // Query expansion
3361
3547
  // =============================================================================
3362
3548
 
3363
- export async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL, db: Database, intent?: string): Promise<string[]> {
3364
- // Check cache first (include intent in cache key)
3365
- const cacheKey = getCacheKey("expandQuery", { query, model, ...(intent && { intent }) });
3549
+ /**
3550
+ * A typed query-expansion result. Decoupled from llm.ts's internal Queryable —
3551
+ * same information, but store.ts owns its own public API type and field name.
3552
+ *
3553
+ * Routing contract (every consumer MUST honor it):
3554
+ * - lex → FTS (BM25) only
3555
+ * - vec → vector only
3556
+ * - hyde → vector only (hypothetical-document embedding)
3557
+ * The original query is searched on BOTH backends and is NOT included here —
3558
+ * callers add it explicitly with the 2× RRF anchor weight.
3559
+ */
3560
+ export type ExpandedQuery = {
3561
+ type: 'lex' | 'vec' | 'hyde';
3562
+ query: string;
3563
+ };
3564
+
3565
+ // Cache version + provider fingerprint for query expansion. Bumping the version
3566
+ // invalidates every stale entry automatically: old newline-format and pre-terse-
3567
+ // prompt garbage simply never hit again and age out of llm_cache via LRU (no manual
3568
+ // purge). The provider fingerprint will distinguish qmd from a future zegen lex
3569
+ // provider (P4) so a provider swap also invalidates the cache by construction.
3570
+ const EXPAND_CACHE_VERSION = "v3-qmd-terse-typed";
3571
+ const EXPAND_PROVIDER_FINGERPRINT = "qmd-terse";
3572
+
3573
+ export async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL, db: Database, intent?: string): Promise<ExpandedQuery[]> {
3574
+ // Typed-JSON cache. Versioned key (include intent + provider fingerprint).
3575
+ const cacheKey = getCacheKey(`expandQuery:${EXPAND_CACHE_VERSION}`, {
3576
+ query,
3577
+ model,
3578
+ provider: EXPAND_PROVIDER_FINGERPRINT,
3579
+ ...(intent && { intent }),
3580
+ });
3366
3581
  const cached = getCachedResult(db, cacheKey);
3367
3582
  if (cached) {
3368
- const lines = cached.split('\n').map(l => l.trim()).filter(l => l.length > 0);
3369
- return [query, ...lines.slice(0, 2)];
3583
+ try {
3584
+ const parsed = JSON.parse(cached) as unknown;
3585
+ // Accept ONLY a fully-valid, already-clean typed payload. A shape error on ANY
3586
+ // element, an empty array, or anything sanitization would drop/rewrite → treat
3587
+ // the entry as stale and re-expand (never return partial or dirty cached data).
3588
+ if (Array.isArray(parsed) && parsed.length > 0
3589
+ && parsed.every(r => r !== null && typeof r === "object"
3590
+ && typeof (r as Record<string, unknown>).query === "string"
3591
+ && ((r as Record<string, unknown>).type === "lex"
3592
+ || (r as Record<string, unknown>).type === "vec"
3593
+ || (r as Record<string, unknown>).type === "hyde"))) {
3594
+ const rows = parsed as Array<{ type: ExpandedQuery["type"]; query: string }>;
3595
+ const sanitized = sanitizeExpandedQueries(rows.map(r => ({ type: r.type, text: r.query })));
3596
+ const clean = sanitized.length === rows.length
3597
+ && sanitized.every((s, i) => s.type === rows[i]!.type && s.text === rows[i]!.query);
3598
+ if (clean) return rows;
3599
+ }
3600
+ } catch {
3601
+ // Malformed JSON — fall through and re-expand.
3602
+ }
3370
3603
  }
3371
3604
 
3372
3605
  const llm = getDefaultLlamaCpp();
3373
- // Note: LlamaCpp uses hardcoded model, model parameter is ignored
3374
- // Pass intent to steer expansion when provided
3606
+ // Note: LlamaCpp uses a hardcoded model; the model parameter is ignored here.
3607
+ // Pass intent to steer expansion when provided.
3375
3608
  const results = await llm.expandQuery(query, { intent });
3376
- const queryTexts = results.map(r => r.text);
3377
3609
 
3378
- // Cache the expanded queries (excluding original)
3379
- const expandedOnly = queryTexts.filter(t => t !== query);
3380
- if (expandedOnly.length > 0) {
3381
- setCachedResult(db, cacheKey, expandedOnly.join('\n'));
3610
+ // Defense-in-depth: re-run the shared guard (also covers the local GBNF path and
3611
+ // any future provider), then drop entries that just echo the original query.
3612
+ // llm.expandQuery substitutes its OWN typed fallback on any generation failure
3613
+ // (remote-empty, cooldown under NO_LOCAL_MODELS, local parse-empty/error). Detect
3614
+ // that leaked fallback AND the all-junk case, and return an expansions-only set
3615
+ // that is NOT cached — a transient failure must not poison the cache or break the
3616
+ // "expansions only, original excluded" contract.
3617
+ const cleaned = sanitizeExpandedQueries(results).filter(r => r.text !== query);
3618
+ if (cleaned.length === 0 || isFallbackExpansion(results, query)) {
3619
+ return expansionFallback(query)
3620
+ .filter(r => r.text !== query) // expansions-only per the ExpandedQuery contract
3621
+ .map(r => ({ type: r.type, query: r.text }));
3382
3622
  }
3383
3623
 
3384
- return Array.from(new Set([query, ...queryTexts]));
3624
+ const expanded: ExpandedQuery[] = cleaned.map(r => ({ type: r.type, query: r.text }));
3625
+ setCachedResult(db, cacheKey, JSON.stringify(expanded));
3626
+ return expanded;
3385
3627
  }
3386
3628
 
3387
3629
  // =============================================================================