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/AGENTS.md +2 -2
- package/CLAUDE.md +2 -2
- package/package.json +2 -2
- package/src/clawmem.ts +352 -188
- package/src/hooks/context-surfacing.ts +8 -2
- package/src/llm.ts +107 -36
- package/src/mcp.ts +32 -12
- package/src/store.ts +373 -131
- package/src/worker-lease.ts +34 -0
- package/src/hermes/__pycache__/__init__.cpython-312.pyc +0 -0
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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(
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
|
|
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<
|
|
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
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1928
|
-
|
|
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
|
-
//
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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
|
-
|
|
2025
|
-
|
|
2146
|
+
// Delete from content_vectors
|
|
2147
|
+
db.prepare(`DELETE FROM content_vectors WHERE hash IN (${placeholders})`).run(...staleHashes);
|
|
2026
2148
|
|
|
2027
|
-
|
|
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
|
-
|
|
3327
|
-
|
|
3328
|
-
//
|
|
3329
|
-
|
|
3330
|
-
|
|
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
|
|
3351
|
-
//
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
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
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
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
|
-
|
|
3369
|
-
|
|
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
|
|
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
|
-
//
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
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
|
-
|
|
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
|
// =============================================================================
|