clawmem 0.10.7 → 0.11.0
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 +1 -1
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/src/clawmem.ts +333 -172
- package/src/llm.ts +1 -0
- package/src/store.ts +300 -117
- package/src/worker-lease.ts +34 -0
- package/src/hermes/__pycache__/__init__.cpython-312.pyc +0 -0
package/AGENTS.md
CHANGED
|
@@ -523,7 +523,7 @@ The `memory_relations` table is populated by multiple independent sources:
|
|
|
523
523
|
|
|
524
524
|
**Graph traversal asymmetry:** `adaptiveTraversal()` traverses all edge types outbound (source→target) but only `semantic` and `entity` edges inbound (target→source). Temporal and causal edges are directional only.
|
|
525
525
|
|
|
526
|
-
**MPFP graph retrieval (v0.2.0):** Multi-Path Fact Propagation runs predefined meta-path patterns in parallel (`[semantic, semantic]`, `[entity, temporal]`, `[semantic, causal]`, etc.), fuses via RRF. Hop-synchronized edge cache batches DB queries per hop instead of per pattern. Forward Push with α=0.15 teleport probability bounds active nodes sublinearly. Tier 3 only (`query`/`intent_search`), not hooks. Patterns selected per MAGMA intent: WHY → `[semantic, causal]`, ENTITY → `[entity, semantic]`, WHEN → `[temporal, semantic]`.
|
|
526
|
+
**MPFP graph retrieval (v0.2.0):** Multi-Path Fact Propagation runs predefined meta-path patterns in parallel (`[semantic, semantic]`, `[entity, temporal]`, `[semantic, causal]`, etc.), fuses via **max-score** (best supporting path wins). Note: meta-path fusion is max-score, NOT RRF — Forward Push yields absolute propagation mass where magnitude carries signal, so rank-only fusion would discard the difference between a strong path hit and a barely-surviving tail hit (`src/graph-traversal.ts:413-425`). This is distinct from the `query`/`intent_search` *outer* retrieval, which does fuse BM25+vector via RRF. Hop-synchronized edge cache batches DB queries per hop instead of per pattern. Forward Push with α=0.15 teleport probability bounds active nodes sublinearly. Tier 3 only (`query`/`intent_search`), not hooks. Patterns selected per MAGMA intent: WHY → `[semantic, causal]`, ENTITY → `[entity, semantic]`, WHEN → `[temporal, semantic]`.
|
|
527
527
|
|
|
528
528
|
### When to Run `build_graphs`
|
|
529
529
|
|
package/CLAUDE.md
CHANGED
|
@@ -523,7 +523,7 @@ The `memory_relations` table is populated by multiple independent sources:
|
|
|
523
523
|
|
|
524
524
|
**Graph traversal asymmetry:** `adaptiveTraversal()` traverses all edge types outbound (source→target) but only `semantic` and `entity` edges inbound (target→source). Temporal and causal edges are directional only.
|
|
525
525
|
|
|
526
|
-
**MPFP graph retrieval (v0.2.0):** Multi-Path Fact Propagation runs predefined meta-path patterns in parallel (`[semantic, semantic]`, `[entity, temporal]`, `[semantic, causal]`, etc.), fuses via RRF. Hop-synchronized edge cache batches DB queries per hop instead of per pattern. Forward Push with α=0.15 teleport probability bounds active nodes sublinearly. Tier 3 only (`query`/`intent_search`), not hooks. Patterns selected per MAGMA intent: WHY → `[semantic, causal]`, ENTITY → `[entity, semantic]`, WHEN → `[temporal, semantic]`.
|
|
526
|
+
**MPFP graph retrieval (v0.2.0):** Multi-Path Fact Propagation runs predefined meta-path patterns in parallel (`[semantic, semantic]`, `[entity, temporal]`, `[semantic, causal]`, etc.), fuses via **max-score** (best supporting path wins). Note: meta-path fusion is max-score, NOT RRF — Forward Push yields absolute propagation mass where magnitude carries signal, so rank-only fusion would discard the difference between a strong path hit and a barely-surviving tail hit (`src/graph-traversal.ts:413-425`). This is distinct from the `query`/`intent_search` *outer* retrieval, which does fuse BM25+vector via RRF. Hop-synchronized edge cache batches DB queries per hop instead of per pattern. Forward Push with α=0.15 teleport probability bounds active nodes sublinearly. Tier 3 only (`query`/`intent_search`), not hooks. Patterns selected per MAGMA intent: WHY → `[semantic, causal]`, ENTITY → `[entity, semantic]`, WHEN → `[temporal, semantic]`.
|
|
527
527
|
|
|
528
528
|
### When to Run `build_graphs`
|
|
529
529
|
|
package/package.json
CHANGED
package/src/clawmem.ts
CHANGED
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
DEFAULT_RERANK_MODEL,
|
|
19
19
|
DEFAULT_GLOB,
|
|
20
20
|
extractSnippet,
|
|
21
|
+
FatalVectorError,
|
|
22
|
+
VecDimensionMismatchError,
|
|
23
|
+
VecModelMismatchError,
|
|
24
|
+
EmbedLeaseLostError,
|
|
21
25
|
} from "./store.ts";
|
|
22
26
|
import {
|
|
23
27
|
getDefaultLlamaCpp,
|
|
@@ -28,6 +32,11 @@ import {
|
|
|
28
32
|
LlamaCpp,
|
|
29
33
|
type Queryable,
|
|
30
34
|
} from "./llm.ts";
|
|
35
|
+
import {
|
|
36
|
+
acquireWorkerLease,
|
|
37
|
+
releaseWorkerLease,
|
|
38
|
+
renewWorkerLease,
|
|
39
|
+
} from "./worker-lease.ts";
|
|
31
40
|
import {
|
|
32
41
|
loadConfig,
|
|
33
42
|
addCollection as collectionsAdd,
|
|
@@ -385,200 +394,331 @@ async function cmdEmbed(args: string[]) {
|
|
|
385
394
|
|
|
386
395
|
const s = getStore();
|
|
387
396
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
397
|
+
// Embedding lease: serialize embed commands (manual / embed timer / update --embed)
|
|
398
|
+
// so two embeds cannot run at once. It is RENEWABLE (token-fenced heartbeat), not a
|
|
399
|
+
// fixed-TTL lease — a full-vault rebuild outlasts any fixed TTL and would be reclaimed
|
|
400
|
+
// mid-run. Without serialization, two embeds using different same-dimension models can
|
|
401
|
+
// silently build a heterogeneous vector space (dimension checks can't catch that).
|
|
402
|
+
// See EMBED-LEASE-RENEWAL-DESIGN.md / INCIDENT-2026-06-22.
|
|
403
|
+
const LEASE_NAME = "embedding";
|
|
404
|
+
const LEASE_TTL_MS = 60_000;
|
|
405
|
+
const lease = acquireWorkerLease(s, LEASE_NAME, LEASE_TTL_MS);
|
|
406
|
+
if (!lease.acquired || !lease.token) {
|
|
407
|
+
console.log(`${c.yellow}Another embed is already in progress (lease held); skipping.${c.reset}`);
|
|
408
|
+
return;
|
|
391
409
|
}
|
|
410
|
+
const leaseToken = lease.token;
|
|
411
|
+
// Passed into every vector mutation (clear, stale-clean, table-create, insert) so
|
|
412
|
+
// each verifies ownership before mutating — a process that lost the lease mid-await
|
|
413
|
+
// cannot wipe/recreate/write the vector store under the new holder.
|
|
414
|
+
const leaseGuard = { workerName: LEASE_NAME, token: leaseToken };
|
|
415
|
+
let leaseLost = false;
|
|
416
|
+
const heartbeat = setInterval(() => {
|
|
417
|
+
if (!renewWorkerLease(s, LEASE_NAME, leaseToken, LEASE_TTL_MS)) leaseLost = true;
|
|
418
|
+
}, Math.floor(LEASE_TTL_MS / 2));
|
|
392
419
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
420
|
+
try {
|
|
421
|
+
const embedUrl = process.env.CLAWMEM_EMBED_URL;
|
|
422
|
+
if (embedUrl) {
|
|
423
|
+
console.log(`Using remote GPU embedding: ${embedUrl}`);
|
|
424
|
+
} else {
|
|
425
|
+
// Local CPU mode: disable inactivity timeout to prevent context disposal mid-batch
|
|
426
|
+
setDefaultLlamaCpp(new LlamaCpp({ inactivityTimeoutMs: 0 }));
|
|
427
|
+
}
|
|
428
|
+
const llm = getDefaultLlamaCpp();
|
|
398
429
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
430
|
+
// Probe the live model's output dimension (+ model name). Returns null on ANY
|
|
431
|
+
// failure so a down/flaky endpoint can NEVER trigger a destructive clear.
|
|
432
|
+
const probeEmbed = async (): Promise<{ dim: number; model: string } | null> => {
|
|
433
|
+
try {
|
|
434
|
+
const r = await llm.embed("clawmem dimension probe");
|
|
435
|
+
return r && r.embedding && r.embedding.length > 0
|
|
436
|
+
? { dim: r.embedding.length, model: r.model ?? "" }
|
|
437
|
+
: null;
|
|
438
|
+
} catch { return null; }
|
|
439
|
+
};
|
|
405
440
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
console.log(`Embedding ${hashes.length} documents (${totalFragEstimate} fragments total)...`);
|
|
420
|
-
|
|
421
|
-
const embedUrl = process.env.CLAWMEM_EMBED_URL;
|
|
422
|
-
if (embedUrl) {
|
|
423
|
-
console.log(`Using remote GPU embedding: ${embedUrl}`);
|
|
424
|
-
} else {
|
|
425
|
-
// Local CPU mode: disable inactivity timeout to prevent context disposal mid-batch
|
|
426
|
-
setDefaultLlamaCpp(new LlamaCpp({ inactivityTimeoutMs: 0 }));
|
|
427
|
-
}
|
|
428
|
-
const llm = getDefaultLlamaCpp();
|
|
429
|
-
let embedded = 0;
|
|
430
|
-
let totalFragments = 0;
|
|
431
|
-
let failedFragments = 0;
|
|
432
|
-
const batchStart = Date.now();
|
|
433
|
-
|
|
434
|
-
// Cloud API: global batch pacing state (persists across documents)
|
|
435
|
-
// TPM is the binding constraint, not RPM. 50 frags × ~800 tokens ≈ 40K tokens/batch → max ~2.5 batches/min at 100K TPM.
|
|
436
|
-
const isCloudEmbed = !!process.env.CLAWMEM_EMBED_API_KEY;
|
|
437
|
-
const CLOUD_BATCH_SIZE = 50;
|
|
438
|
-
const CLOUD_TPM_LIMIT = parseInt(process.env.CLAWMEM_EMBED_TPM_LIMIT || "100000", 10);
|
|
439
|
-
const CLOUD_TPM_SAFETY = 0.85; // use 85% of limit to leave headroom for retries
|
|
440
|
-
const CHARS_PER_TOKEN = 4;
|
|
441
|
-
let lastBatchSentAt = 0; // global timestamp of last batch send
|
|
442
|
-
|
|
443
|
-
for (let docIdx = 0; docIdx < hashes.length; docIdx++) {
|
|
444
|
-
const { hash, body, path, title: docTitle, collection } = hashes[docIdx]!;
|
|
445
|
-
const title = docTitle || basename(path).replace(/\.(md|txt)$/i, "");
|
|
446
|
-
const canId = canonicalDocId(collection, path);
|
|
447
|
-
|
|
448
|
-
// Parse frontmatter for fragment splitting
|
|
449
|
-
let frontmatter: Record<string, any> | undefined;
|
|
450
|
-
try {
|
|
451
|
-
const parsed = parseDocument(body, path);
|
|
452
|
-
frontmatter = parsed.meta as any;
|
|
453
|
-
} catch {
|
|
454
|
-
// No frontmatter or parsing error — fine, skip it
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const fragments = splitDocument(body, frontmatter);
|
|
458
|
-
const docStart = Date.now();
|
|
459
|
-
const prevTotalFragments = totalFragments;
|
|
460
|
-
const prevFailedFragments = failedFragments;
|
|
461
|
-
let seq0Succeeded = false;
|
|
462
|
-
console.error(` [${docIdx + 1}/${hashes.length}] ${basename(path)} (${fragments.length} frags, ${body.length} chars)`);
|
|
463
|
-
|
|
464
|
-
if (isCloudEmbed) {
|
|
465
|
-
// Batch mode: collect all texts, send in chunks of CLOUD_BATCH_SIZE
|
|
466
|
-
const allTexts: string[] = [];
|
|
467
|
-
for (const frag of fragments) {
|
|
468
|
-
const label = frag.label || title;
|
|
469
|
-
allTexts.push(formatDocForEmbedding(frag.content, label));
|
|
441
|
+
// Bind the whole run to one (dim, model); every fragment is validated before it is
|
|
442
|
+
// stored. On a fresh vault these are set from the first successful fragment.
|
|
443
|
+
let expectedDim: number | null = null;
|
|
444
|
+
let expectedModel: string | null = null;
|
|
445
|
+
|
|
446
|
+
if (values.force) {
|
|
447
|
+
// Probe FIRST — validate the endpoint before destroying anything (so a force
|
|
448
|
+
// re-embed against a dead endpoint cannot clear the vault and then fail).
|
|
449
|
+
const probe = await probeEmbed();
|
|
450
|
+
if (!probe) {
|
|
451
|
+
console.error(`${c.red}Force re-embed aborted: could not reach the embedding endpoint. Nothing was cleared.${c.reset}`);
|
|
452
|
+
return;
|
|
470
453
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const requiredGapMs = Math.max(500, (batchTokens / safeTPM) * 60_000);
|
|
486
|
-
const elapsed = Date.now() - lastBatchSentAt;
|
|
487
|
-
const remainingMs = requiredGapMs - elapsed;
|
|
488
|
-
if (remainingMs > 0) {
|
|
489
|
-
const jittered = Math.floor(remainingMs * (0.85 + Math.random() * 0.3));
|
|
490
|
-
await new Promise(r => setTimeout(r, jittered));
|
|
491
|
-
}
|
|
454
|
+
console.log(`${c.yellow}Force mode: clearing all embeddings (rebuilding at dim ${probe.dim})${c.reset}`);
|
|
455
|
+
expectedDim = probe.dim;
|
|
456
|
+
expectedModel = probe.model || null;
|
|
457
|
+
s.clearAllEmbeddings(leaseGuard);
|
|
458
|
+
} else {
|
|
459
|
+
// Implicit run: NON-DESTRUCTIVE drift check (dimension AND model). Catches
|
|
460
|
+
// drift even when the worklist is empty (query embeddings would already be
|
|
461
|
+
// incompatible with the stored table). Never clears — aborts with instructions.
|
|
462
|
+
const existingDim = s.getVecTableDim(); // throws VecSchemaError on malformed DDL → caught below
|
|
463
|
+
if (existingDim !== null) {
|
|
464
|
+
const probe = await probeEmbed();
|
|
465
|
+
if (probe && probe.dim !== existingDim) {
|
|
466
|
+
console.error(`${c.red}Embedding dimension changed (${existingDim} → ${probe.dim}). Run 'clawmem embed --force' to clear and rebuild the full vault.${c.reset}`);
|
|
467
|
+
return;
|
|
492
468
|
}
|
|
469
|
+
// Same dimension but a DIFFERENT model still mixes the vector space (cosine
|
|
470
|
+
// across two models is meaningless) and the dim check cannot see it. Compare
|
|
471
|
+
// the probe's model against what the vault was built with; abort on mismatch.
|
|
472
|
+
const existingModels = s.getVecModels();
|
|
473
|
+
if (existingModels.length > 1) {
|
|
474
|
+
console.error(`${c.red}Vault already contains mixed embedding models: ${existingModels.join(", ")}. Run 'clawmem embed --force' to rebuild with a single model.${c.reset}`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (probe && probe.model && existingModels.length === 1 && existingModels[0] !== probe.model) {
|
|
478
|
+
console.error(`${c.red}Embedding model changed (${existingModels[0]} → ${probe.model}) at the same dimension. Mixing models in one vector space breaks similarity. Run 'clawmem embed --force' to rebuild with the current model.${c.reset}`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
expectedDim = existingDim;
|
|
482
|
+
if (probe && probe.model) expectedModel = probe.model;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Clean stale embeddings (orphaned hashes from updated/deleted documents)
|
|
487
|
+
const cleaned = s.cleanStaleEmbeddings(leaseGuard);
|
|
488
|
+
if (cleaned > 0) {
|
|
489
|
+
console.log(`${c.yellow}Cleaned ${cleaned} stale embedding(s) from orphaned documents${c.reset}`);
|
|
490
|
+
}
|
|
493
491
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
492
|
+
// Use fragment-based pipeline: split documents into semantic fragments and embed each
|
|
493
|
+
const hashes = s.getHashesNeedingFragments();
|
|
494
|
+
if (hashes.length === 0) {
|
|
495
|
+
console.log(`${c.green}All documents already embedded${c.reset}`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
498
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
499
|
+
// Count total fragments first for ETA
|
|
500
|
+
let totalFragEstimate = 0;
|
|
501
|
+
const docFragCounts: number[] = [];
|
|
502
|
+
for (const { body, path } of hashes) {
|
|
503
|
+
let frontmatter: Record<string, any> | undefined;
|
|
504
|
+
try {
|
|
505
|
+
const parsed = parseDocument(body, path);
|
|
506
|
+
frontmatter = parsed.meta as any;
|
|
507
|
+
} catch { /* skip */ }
|
|
508
|
+
const frags = splitDocument(body, frontmatter);
|
|
509
|
+
docFragCounts.push(frags.length);
|
|
510
|
+
totalFragEstimate += frags.length;
|
|
511
|
+
}
|
|
512
|
+
console.log(`Embedding ${hashes.length} documents (${totalFragEstimate} fragments total)...`);
|
|
513
|
+
|
|
514
|
+
let embedded = 0;
|
|
515
|
+
let totalFragments = 0;
|
|
516
|
+
let failedFragments = 0;
|
|
517
|
+
const batchStart = Date.now();
|
|
518
|
+
|
|
519
|
+
// Cloud API: global batch pacing state (persists across documents)
|
|
520
|
+
// TPM is the binding constraint, not RPM. 50 frags × ~800 tokens ≈ 40K tokens/batch → max ~2.5 batches/min at 100K TPM.
|
|
521
|
+
const isCloudEmbed = !!process.env.CLAWMEM_EMBED_API_KEY;
|
|
522
|
+
const CLOUD_BATCH_SIZE = 50;
|
|
523
|
+
const CLOUD_TPM_LIMIT = parseInt(process.env.CLAWMEM_EMBED_TPM_LIMIT || "100000", 10);
|
|
524
|
+
const CLOUD_TPM_SAFETY = 0.85; // use 85% of limit to leave headroom for retries
|
|
525
|
+
const CHARS_PER_TOKEN = 4;
|
|
526
|
+
let lastBatchSentAt = 0; // global timestamp of last batch send
|
|
527
|
+
|
|
528
|
+
// Bind the run to one (dim, model) and validate every embedding before it is stored.
|
|
529
|
+
// The first successful fragment sets the binding on a fresh vault; any later drift —
|
|
530
|
+
// a dimension change OR a same-dimension model swap from a flapping endpoint — throws
|
|
531
|
+
// a fatal error that aborts the whole run (caught below). This is what dimension
|
|
532
|
+
// checks alone cannot do: catch a different 2560-d model.
|
|
533
|
+
const bindAndValidate = (result: { embedding: number[] | Float32Array; model?: string }) => {
|
|
534
|
+
const dim = result.embedding.length;
|
|
535
|
+
if (expectedDim === null) {
|
|
536
|
+
expectedDim = dim;
|
|
537
|
+
if (!expectedModel && result.model) expectedModel = result.model;
|
|
538
|
+
} else if (dim !== expectedDim) {
|
|
539
|
+
throw new VecDimensionMismatchError(expectedDim, dim);
|
|
540
|
+
}
|
|
541
|
+
if (expectedModel && result.model && result.model !== expectedModel) {
|
|
542
|
+
throw new VecModelMismatchError(expectedModel, result.model);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
for (let docIdx = 0; docIdx < hashes.length; docIdx++) {
|
|
547
|
+
// Abort cleanly if the heartbeat reported the lease was reclaimed.
|
|
548
|
+
if (leaseLost) throw new EmbedLeaseLostError();
|
|
549
|
+
|
|
550
|
+
const { hash, body, path, title: docTitle, collection } = hashes[docIdx]!;
|
|
551
|
+
const title = docTitle || basename(path).replace(/\.(md|txt)$/i, "");
|
|
552
|
+
const canId = canonicalDocId(collection, path);
|
|
553
|
+
|
|
554
|
+
// Parse frontmatter for fragment splitting
|
|
555
|
+
let frontmatter: Record<string, any> | undefined;
|
|
556
|
+
try {
|
|
557
|
+
const parsed = parseDocument(body, path);
|
|
558
|
+
frontmatter = parsed.meta as any;
|
|
559
|
+
} catch {
|
|
560
|
+
// No frontmatter or parsing error — fine, skip it
|
|
561
|
+
}
|
|
503
562
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
563
|
+
const fragments = splitDocument(body, frontmatter);
|
|
564
|
+
const docStart = Date.now();
|
|
565
|
+
const prevFailedFragments = failedFragments;
|
|
566
|
+
let seq0Succeeded = false;
|
|
567
|
+
|
|
568
|
+
// Mark the doc 'pending' and increment embed_attempts ONCE before its first
|
|
569
|
+
// fragment, so a crash mid-document leaves it retryable (re-selected by
|
|
570
|
+
// getHashesNeedingFragments). Completion setters below are state-only.
|
|
571
|
+
s.markEmbedStart(hash);
|
|
572
|
+
console.error(` [${docIdx + 1}/${hashes.length}] ${basename(path)} (${fragments.length} frags, ${body.length} chars)`);
|
|
573
|
+
|
|
574
|
+
if (isCloudEmbed) {
|
|
575
|
+
// Batch mode: collect all texts, send in chunks of CLOUD_BATCH_SIZE
|
|
576
|
+
const allTexts: string[] = [];
|
|
577
|
+
for (const frag of fragments) {
|
|
578
|
+
const label = frag.label || title;
|
|
579
|
+
allTexts.push(formatDocForEmbedding(frag.content, label));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
for (let batchStartIdx = 0; batchStartIdx < allTexts.length; batchStartIdx += CLOUD_BATCH_SIZE) {
|
|
583
|
+
// Abort before each batch if the lease was reclaimed — a large document
|
|
584
|
+
// must not keep writing after another process took the lease (HIGH-3).
|
|
585
|
+
if (leaseLost) throw new EmbedLeaseLostError();
|
|
586
|
+
// Global TPM-aware delay: compute required wait based on last batch's token count,
|
|
587
|
+
// then wait only the remaining time since lastBatchSentAt. Applies to ALL batches
|
|
588
|
+
// including first batch of each document (inter-document pacing).
|
|
589
|
+
if (lastBatchSentAt > 0) {
|
|
590
|
+
// Adaptive TPM-aware delay. Set CLAWMEM_EMBED_TPM_LIMIT to match your tier:
|
|
591
|
+
// Free: 100000 (default), Paid: 2000000, Premium: 50000000
|
|
592
|
+
const batchEnd0 = Math.min(batchStartIdx + CLOUD_BATCH_SIZE, allTexts.length);
|
|
593
|
+
const estimatedTokens = allTexts.slice(batchStartIdx, batchEnd0)
|
|
594
|
+
.reduce((sum, t) => sum + Math.ceil(t.length / CHARS_PER_TOKEN), 0);
|
|
595
|
+
// Use current batch estimate (not previous batch actuals — previous batch may differ in size)
|
|
596
|
+
const batchTokens = estimatedTokens;
|
|
597
|
+
const safeTPM = CLOUD_TPM_LIMIT * CLOUD_TPM_SAFETY;
|
|
598
|
+
const requiredGapMs = Math.max(500, (batchTokens / safeTPM) * 60_000);
|
|
599
|
+
const elapsed = Date.now() - lastBatchSentAt;
|
|
600
|
+
const remainingMs = requiredGapMs - elapsed;
|
|
601
|
+
if (remainingMs > 0) {
|
|
602
|
+
const jittered = Math.floor(remainingMs * (0.85 + Math.random() * 0.3));
|
|
603
|
+
await new Promise(r => setTimeout(r, jittered));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const batchEnd = Math.min(batchStartIdx + CLOUD_BATCH_SIZE, allTexts.length);
|
|
608
|
+
const batchTexts = allTexts.slice(batchStartIdx, batchEnd);
|
|
609
|
+
lastBatchSentAt = Date.now();
|
|
610
|
+
const reqStart = Date.now();
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
const results = await llm.embedBatch(batchTexts);
|
|
614
|
+
const reqMs = Date.now() - reqStart;
|
|
615
|
+
const tokensUsed = llm.lastBatchTokens;
|
|
616
|
+
|
|
617
|
+
for (let i = 0; i < results.length; i++) {
|
|
618
|
+
const seq = batchStartIdx + i;
|
|
619
|
+
const frag = fragments[seq]!;
|
|
620
|
+
const result = results[i];
|
|
621
|
+
if (result) {
|
|
622
|
+
bindAndValidate(result);
|
|
623
|
+
s.ensureVecTable(result.embedding.length, leaseGuard);
|
|
624
|
+
s.insertEmbedding(
|
|
625
|
+
hash, seq, frag.startLine, new Float32Array(result.embedding),
|
|
626
|
+
result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
|
|
627
|
+
leaseGuard
|
|
628
|
+
);
|
|
629
|
+
totalFragments++;
|
|
630
|
+
if (seq === 0) seq0Succeeded = true;
|
|
631
|
+
} else {
|
|
632
|
+
failedFragments++;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
console.error(` batch ${batchStartIdx + 1}-${batchEnd}/${allTexts.length} (${results.filter(r => r).length} ok) ${reqMs}ms${tokensUsed ? ` ${tokensUsed} tok` : ""}`);
|
|
636
|
+
} catch (err) {
|
|
637
|
+
if (err instanceof FatalVectorError) throw err; // dim/model/schema mismatch → abort the whole run
|
|
638
|
+
failedFragments += batchTexts.length;
|
|
639
|
+
console.error(`${c.yellow}Warning: batch embed failed for ${path} frags ${batchStartIdx + 1}-${batchEnd}: ${err}${c.reset}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
// Local mode: embed one at a time (no rate limit concern)
|
|
644
|
+
for (let seq = 0; seq < fragments.length; seq++) {
|
|
645
|
+
// Abort before each fragment if the lease was reclaimed — bounds any
|
|
646
|
+
// post-loss writing to at most one fragment of a large doc (HIGH-3).
|
|
647
|
+
if (leaseLost) throw new EmbedLeaseLostError();
|
|
648
|
+
const frag = fragments[seq]!;
|
|
649
|
+
const label = frag.label || title;
|
|
650
|
+
const text = formatDocForEmbedding(frag.content, label);
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
const fragStart = Date.now();
|
|
654
|
+
const result = await llm.embed(text);
|
|
655
|
+
const fragMs = Date.now() - fragStart;
|
|
508
656
|
if (result) {
|
|
509
|
-
|
|
657
|
+
bindAndValidate(result);
|
|
658
|
+
s.ensureVecTable(result.embedding.length, leaseGuard);
|
|
510
659
|
s.insertEmbedding(
|
|
511
660
|
hash, seq, frag.startLine, new Float32Array(result.embedding),
|
|
512
|
-
result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
|
|
661
|
+
result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
|
|
662
|
+
leaseGuard
|
|
513
663
|
);
|
|
514
664
|
totalFragments++;
|
|
515
665
|
if (seq === 0) seq0Succeeded = true;
|
|
666
|
+
if (seq === 0 || (seq + 1) % 5 === 0 || seq === fragments.length - 1) {
|
|
667
|
+
console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) ${fragMs}ms [${text.length} chars]`);
|
|
668
|
+
}
|
|
516
669
|
} else {
|
|
517
670
|
failedFragments++;
|
|
671
|
+
console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) → null result [${text.length} chars]`);
|
|
518
672
|
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
} catch (err) {
|
|
522
|
-
failedFragments += batchTexts.length;
|
|
523
|
-
console.error(`${c.yellow}Warning: batch embed failed for ${path} frags ${batchStart + 1}-${batchEnd}: ${err}${c.reset}`);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
} else {
|
|
527
|
-
// Local mode: embed one at a time (no rate limit concern)
|
|
528
|
-
for (let seq = 0; seq < fragments.length; seq++) {
|
|
529
|
-
const frag = fragments[seq]!;
|
|
530
|
-
const label = frag.label || title;
|
|
531
|
-
const text = formatDocForEmbedding(frag.content, label);
|
|
532
|
-
|
|
533
|
-
try {
|
|
534
|
-
const fragStart = Date.now();
|
|
535
|
-
const result = await llm.embed(text);
|
|
536
|
-
const fragMs = Date.now() - fragStart;
|
|
537
|
-
if (result) {
|
|
538
|
-
s.ensureVecTable(result.embedding.length);
|
|
539
|
-
s.insertEmbedding(
|
|
540
|
-
hash, seq, frag.startLine, new Float32Array(result.embedding),
|
|
541
|
-
result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
|
|
542
|
-
);
|
|
543
|
-
totalFragments++;
|
|
544
|
-
if (seq === 0) seq0Succeeded = true;
|
|
545
|
-
if (seq === 0 || (seq + 1) % 5 === 0 || seq === fragments.length - 1) {
|
|
546
|
-
console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) ${fragMs}ms [${text.length} chars]`);
|
|
547
|
-
}
|
|
548
|
-
} else {
|
|
673
|
+
} catch (err) {
|
|
674
|
+
if (err instanceof FatalVectorError) throw err; // dim/model/schema mismatch → abort the whole run
|
|
549
675
|
failedFragments++;
|
|
550
|
-
console.error(
|
|
676
|
+
console.error(`${c.yellow}Warning: failed to embed fragment ${seq} (${frag.type}) of ${path}: ${err}${c.reset}`);
|
|
551
677
|
}
|
|
552
|
-
} catch (err) {
|
|
553
|
-
failedFragments++;
|
|
554
|
-
console.error(`${c.yellow}Warning: failed to embed fragment ${seq} (${frag.type}) of ${path}: ${err}${c.reset}`);
|
|
555
678
|
}
|
|
556
679
|
}
|
|
680
|
+
|
|
681
|
+
// Embed-state completion: mark synced ONLY when the WHOLE document succeeded (no
|
|
682
|
+
// failed fragments) — a partial embed must not be silently permanent. Any failure
|
|
683
|
+
// → 'failed' (state-only; attempts already incremented at markEmbedStart) so the
|
|
684
|
+
// worklist retries it, bounded by embed_attempts < 3.
|
|
685
|
+
const docFragsFail = failedFragments - prevFailedFragments;
|
|
686
|
+
if (seq0Succeeded && docFragsFail === 0) {
|
|
687
|
+
s.markEmbedSynced(hash);
|
|
688
|
+
} else if (!seq0Succeeded) {
|
|
689
|
+
s.markEmbedFailed(hash, "primary fragment (seq=0) failed");
|
|
690
|
+
} else {
|
|
691
|
+
s.markEmbedFailed(hash, `${docFragsFail} fragment(s) failed`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
embedded++;
|
|
695
|
+
const docMs = Date.now() - docStart;
|
|
696
|
+
const elapsed = ((Date.now() - batchStart) / 1000).toFixed(0);
|
|
697
|
+
console.error(` → doc done in ${(docMs / 1000).toFixed(1)}s | ${embedded}/${hashes.length} docs, ${totalFragments} frags, ${failedFragments} fails [${elapsed}s elapsed]`);
|
|
557
698
|
}
|
|
558
699
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
700
|
+
const totalSec = ((Date.now() - batchStart) / 1000).toFixed(1);
|
|
701
|
+
console.log();
|
|
702
|
+
console.log(`${c.green}Embedded ${embedded} documents (${totalFragments} fragments, ${failedFragments} failed) in ${totalSec}s${c.reset}`);
|
|
703
|
+
} catch (err) {
|
|
704
|
+
// Fatal aborts must NOT exit 0 — otherwise the embed timer / `update --embed`
|
|
705
|
+
// cannot tell the run was incomplete. Set a nonzero exit code (cleanup still
|
|
706
|
+
// runs in finally). Non-fatal errors propagate unchanged.
|
|
707
|
+
if (err instanceof EmbedLeaseLostError) {
|
|
708
|
+
// Checked before FatalVectorError because EmbedLeaseLostError extends it.
|
|
709
|
+
console.error(`${c.red}Embed aborted: lost the embedding lease (another embed process took over). Re-run 'clawmem embed'.${c.reset}`);
|
|
710
|
+
process.exitCode = 1;
|
|
711
|
+
} else if (err instanceof FatalVectorError) {
|
|
712
|
+
console.error(`${c.red}Embed aborted: ${(err as Error).message}${c.reset}`);
|
|
713
|
+
process.exitCode = 1;
|
|
566
714
|
} else {
|
|
567
|
-
|
|
568
|
-
s.markEmbedFailed(hash, "primary fragment (seq=0) failed");
|
|
715
|
+
throw err;
|
|
569
716
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
console.error(` → doc done in ${(docMs / 1000).toFixed(1)}s | ${embedded}/${hashes.length} docs, ${totalFragments} frags, ${failedFragments} fails [${elapsed}s elapsed]`);
|
|
717
|
+
} finally {
|
|
718
|
+
clearInterval(heartbeat);
|
|
719
|
+
releaseWorkerLease(s, LEASE_NAME, leaseToken);
|
|
720
|
+
await disposeDefaultLlamaCpp();
|
|
575
721
|
}
|
|
576
|
-
|
|
577
|
-
const totalSec = ((Date.now() - batchStart) / 1000).toFixed(1);
|
|
578
|
-
console.log();
|
|
579
|
-
console.log(`${c.green}Embedded ${embedded} documents (${totalFragments} fragments, ${failedFragments} failed) in ${totalSec}s${c.reset}`);
|
|
580
|
-
|
|
581
|
-
await disposeDefaultLlamaCpp();
|
|
582
722
|
}
|
|
583
723
|
|
|
584
724
|
async function cmdStatus() {
|
|
@@ -1891,13 +2031,34 @@ async function cmdDoctor() {
|
|
|
1891
2031
|
const s = getStore();
|
|
1892
2032
|
const needsEmbed = s.getHashesNeedingEmbedding();
|
|
1893
2033
|
const hasVectors = !!s.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'").get();
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
2034
|
+
// ALWAYS run the consistency check — the WORST desync (content_vectors rows but
|
|
2035
|
+
// vectors_vec entirely absent) lives in the no-table case, so it must not be
|
|
2036
|
+
// skipped. set-diff, NOT counts (one missing + one orphan cancel in a count check).
|
|
2037
|
+
// Pending docs are reported separately — they are in neither table, not a desync.
|
|
2038
|
+
const vc = s.getVectorConsistency();
|
|
2039
|
+
if (!hasVectors && vc.cvCount === 0) {
|
|
1897
2040
|
console.log(`${c.yellow}!${c.reset} Vector index: not created yet (run 'clawmem embed')`);
|
|
2041
|
+
} else if (!hasVectors) {
|
|
2042
|
+
console.log(`${c.red}✗${c.reset} Vector index: vectors_vec is MISSING but ${vc.cvCount} content_vectors row(s) exist — full desync. Run 'clawmem embed --force' to rebuild.`);
|
|
2043
|
+
issues++;
|
|
2044
|
+
} else {
|
|
2045
|
+
console.log(`${c.green}✓${c.reset} Vector index: exists (${needsEmbed} need embedding)`);
|
|
2046
|
+
if (vc.cvMissingVv > 0 || vc.vvOrphan > 0) {
|
|
2047
|
+
console.log(`${c.red}✗${c.reset} Vector consistency: ${vc.cvMissingVv} metadata row(s) missing a vector, ${vc.vvOrphan} orphan vector(s) (content_vectors=${vc.cvCount}, vectors_vec=${vc.vvCount}). Run 'clawmem embed --force' to rebuild.`);
|
|
2048
|
+
issues++;
|
|
2049
|
+
} else {
|
|
2050
|
+
console.log(`${c.green}✓${c.reset} Vector consistency: ${vc.vvCount} vectors match ${vc.cvCount} metadata rows (${vc.pending} pending)`);
|
|
2051
|
+
}
|
|
1898
2052
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
2053
|
+
// Mixed embedding models = a heterogeneous vector space (cosine across different
|
|
2054
|
+
// models is meaningless) even when keys/dimensions are consistent. Flag it.
|
|
2055
|
+
const vecModels = s.getVecModels();
|
|
2056
|
+
if (vecModels.length > 1) {
|
|
2057
|
+
console.log(`${c.red}✗${c.reset} Embedding models: vault has MIXED models (${vecModels.join(", ")}) — heterogeneous vector space. Run 'clawmem embed --force' to rebuild with one model.`);
|
|
2058
|
+
issues++;
|
|
2059
|
+
}
|
|
2060
|
+
} catch (err) {
|
|
2061
|
+
console.log(`${c.yellow}!${c.reset} Vector index: could not check (${(err as Error).message})`);
|
|
1901
2062
|
}
|
|
1902
2063
|
|
|
1903
2064
|
// 4. Content types
|
package/src/llm.ts
CHANGED
|
@@ -290,6 +290,7 @@ function normalizeRemoteLlmReasoningEffort(value?: string): string | null {
|
|
|
290
290
|
|
|
291
291
|
function buildRemoteChatCompletionsUrl(remoteLlmUrl: string): string {
|
|
292
292
|
const baseUrl = remoteLlmUrl.replace(/\/+$/, "");
|
|
293
|
+
if (baseUrl.endsWith("/chat/completions")) return baseUrl;
|
|
293
294
|
const endpoint = baseUrl.endsWith("/v1") ? "/chat/completions" : "/v1/chat/completions";
|
|
294
295
|
return `${baseUrl}${endpoint}`;
|
|
295
296
|
}
|
package/src/store.ts
CHANGED
|
@@ -592,6 +592,25 @@ function initializeDatabase(db: Database): void {
|
|
|
592
592
|
}
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
+
// Centralize the embed-lifecycle reset on content change: ANY path that updates
|
|
596
|
+
// documents.hash (updateDocument, reactivateDocument, indexer reactivation,
|
|
597
|
+
// decision/antipattern merges, saveMemory, beads sync, …) gets the doc's embed
|
|
598
|
+
// state reset, so the new content is re-embedded with a fresh retry budget and is
|
|
599
|
+
// never excluded by the OLD content's exhausted embed_attempts. One trigger covers
|
|
600
|
+
// every caller (codex HIGH, INCIDENT-2026-06-22). It fires only on an actual change
|
|
601
|
+
// (`IS NOT` is null-safe) and does not touch hash, so it cannot recurse. Created
|
|
602
|
+
// right after the embed_* column migrations above, so the columns it references
|
|
603
|
+
// exist. NOT wrapped in try/catch: a creation failure means this load-bearing
|
|
604
|
+
// reset is silently absent, so let it surface loudly rather than hide a footgun.
|
|
605
|
+
db.exec(`
|
|
606
|
+
CREATE TRIGGER IF NOT EXISTS reset_embed_on_hash_change
|
|
607
|
+
AFTER UPDATE OF hash ON documents
|
|
608
|
+
FOR EACH ROW WHEN OLD.hash IS NOT NEW.hash
|
|
609
|
+
BEGIN
|
|
610
|
+
UPDATE documents SET embed_state = 'pending', embed_attempts = 0, embed_error = NULL WHERE id = NEW.id;
|
|
611
|
+
END;
|
|
612
|
+
`);
|
|
613
|
+
|
|
595
614
|
// Migration: add A-MEM columns to documents
|
|
596
615
|
const amemMigrations: [string, string][] = [
|
|
597
616
|
["amem_keywords", "ALTER TABLE documents ADD COLUMN amem_keywords TEXT"],
|
|
@@ -923,32 +942,153 @@ function initializeDatabase(db: Database): void {
|
|
|
923
942
|
}
|
|
924
943
|
|
|
925
944
|
|
|
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
945
|
// v0.8.1 Ext 6b: per-database cache for the query_text column presence on
|
|
930
946
|
// context_usage. Set once at migration time so insertUsageFn can pick the
|
|
931
947
|
// correct INSERT shape without running PRAGMA on every write. Falls back
|
|
932
948
|
// to `false` (safe — equivalent to pre-migration behavior) when absent.
|
|
933
949
|
const contextUsageHasQueryTextCache = new WeakMap<Database, boolean>();
|
|
934
950
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
951
|
+
/**
|
|
952
|
+
* Fatal, non-recoverable vector-store errors. These abort the embed run rather
|
|
953
|
+
* than being swallowed as per-fragment failures. A dimension/schema mismatch must
|
|
954
|
+
* NEVER silently drop an existing table — the only path that clears vectors is the
|
|
955
|
+
* explicit clearAllEmbeddings (gated behind `embed --force`). See
|
|
956
|
+
* INCIDENT-2026-06-22 §12 + EMBED-LEASE-RENEWAL-DESIGN.md.
|
|
957
|
+
*/
|
|
958
|
+
export class FatalVectorError extends Error {}
|
|
959
|
+
|
|
960
|
+
export class VecDimensionMismatchError extends FatalVectorError {
|
|
961
|
+
constructor(public readonly existingDim: number, public readonly requestedDim: number) {
|
|
962
|
+
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.`);
|
|
963
|
+
this.name = "VecDimensionMismatchError";
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export class VecSchemaError extends FatalVectorError {
|
|
968
|
+
constructor(message: string) {
|
|
969
|
+
super(message);
|
|
970
|
+
this.name = "VecSchemaError";
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
export class VecModelMismatchError extends FatalVectorError {
|
|
975
|
+
constructor(public readonly expectedModel: string, public readonly actualModel: string) {
|
|
976
|
+
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.`);
|
|
977
|
+
this.name = "VecModelMismatchError";
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// A FatalVectorError so it propagates out of the per-fragment catch and aborts the
|
|
982
|
+
// embed run. Thrown both by cmdEmbed (between fragments) and INSIDE insertEmbedding's
|
|
983
|
+
// write transaction (atomic lease-token fence) when the lease was reclaimed.
|
|
984
|
+
export class EmbedLeaseLostError extends FatalVectorError {
|
|
985
|
+
constructor() {
|
|
986
|
+
super("Embedding lease lost (another embed process took over). Re-run 'clawmem embed'.");
|
|
987
|
+
this.name = "EmbedLeaseLostError";
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** A held embedding-lease identity, passed into vector mutations so each can verify
|
|
992
|
+
* ownership before mutating — a process that lost the lease cannot wipe/recreate/write
|
|
993
|
+
* the vector store under the new holder. */
|
|
994
|
+
export type LeaseGuard = { workerName: string; token: string };
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Throw EmbedLeaseLostError if the caller no longer holds the named lease. Call as the
|
|
998
|
+
* FIRST statement inside a write transaction (before any mutation), so the check and
|
|
999
|
+
* the writes are atomic under SQLite's single-writer serialization — no other holder
|
|
1000
|
+
* can interleave a reclaim between the check and the mutation. No-op when leaseGuard
|
|
1001
|
+
* is omitted (non-embed callers).
|
|
1002
|
+
*/
|
|
1003
|
+
function assertLeaseHeld(db: Database, leaseGuard?: LeaseGuard): void {
|
|
1004
|
+
if (!leaseGuard) return;
|
|
1005
|
+
const row = db.prepare(`SELECT lease_token FROM worker_leases WHERE worker_name = ?`).get(leaseGuard.workerName) as { lease_token: string } | null;
|
|
1006
|
+
if (!row || row.lease_token !== leaseGuard.token) {
|
|
1007
|
+
throw new EmbedLeaseLostError();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Single source of truth for vectors_vec schema validation. Reads the table DDL
|
|
1013
|
+
* and returns:
|
|
1014
|
+
* - null → table ABSENT
|
|
1015
|
+
* - <integer> → table VALID (vec0 schema: hash_seq + float[N] + cosine); the N
|
|
1016
|
+
* - throws VecSchemaError → table PRESENT but its schema is unexpected
|
|
1017
|
+
* (missing float[N], hash_seq, or distance_metric=cosine; malformed/legacy DDL).
|
|
1018
|
+
* No caching: a stale per-process dim cache could bypass this validation when
|
|
1019
|
+
* another process changed the table. The embed lease serializes embeds, and the
|
|
1020
|
+
* old cache fast-path is removed so validation is unconditional on every call.
|
|
1021
|
+
* Case-insensitive, whitespace-tolerant.
|
|
1022
|
+
*/
|
|
1023
|
+
function readVecTableDim(db: Database): number | null {
|
|
1024
|
+
const info = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get() as { sql: string } | null;
|
|
1025
|
+
if (!info) return null;
|
|
1026
|
+
const m = info.sql.match(/float\s*\[\s*(\d+)\s*\]/i);
|
|
1027
|
+
const hasHashSeq = /\bhash_seq\b/i.test(info.sql);
|
|
1028
|
+
const hasCosine = /distance_metric\s*=\s*cosine/i.test(info.sql);
|
|
1029
|
+
if (!m || !hasHashSeq || !hasCosine) {
|
|
1030
|
+
throw new VecSchemaError(`vectors_vec exists but its schema is unexpected (need hash_seq + float[N] + distance_metric=cosine): ${info.sql}`);
|
|
1031
|
+
}
|
|
1032
|
+
return parseInt(m[1]!, 10);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function ensureVecTableInternal(db: Database, dimensions: number, leaseGuard?: LeaseGuard): void {
|
|
1036
|
+
const existing = readVecTableDim(db); // null=absent, N=valid, throws VecSchemaError if malformed
|
|
1037
|
+
if (existing !== null) {
|
|
1038
|
+
// NEVER drop an existing table on a dimension mismatch — that silently destroys
|
|
1039
|
+
// the vault's vectors while the metadata-based worklist skips the now-vectorless
|
|
1040
|
+
// docs (INCIDENT-2026-06-22). Throw; the run aborts. Clear+rebuild happens only
|
|
1041
|
+
// via clearAllEmbeddings (`embed --force`).
|
|
1042
|
+
if (existing !== dimensions) throw new VecDimensionMismatchError(existing, dimensions);
|
|
1043
|
+
return; // exists at the right dim + valid schema → nothing to do
|
|
1044
|
+
}
|
|
1045
|
+
// Table absent → create it ATOMICALLY. The lease assertion, a RE-CHECK of absence,
|
|
1046
|
+
// and the CREATE run in ONE immediate-write-lock transaction, so no other process
|
|
1047
|
+
// can clear/recreate between the check and the CREATE (closes the TOCTOU): a process
|
|
1048
|
+
// that lost its lease cannot create an empty table at the wrong dimension under the
|
|
1049
|
+
// new holder, and two creators cannot race. (vec0 CREATE works inside a transaction.)
|
|
1050
|
+
db.transaction(() => {
|
|
1051
|
+
assertLeaseHeld(db, leaseGuard);
|
|
1052
|
+
const recheck = readVecTableDim(db);
|
|
1053
|
+
if (recheck !== null) {
|
|
1054
|
+
// Another holder created it while we waited for the write lock.
|
|
1055
|
+
if (recheck !== dimensions) throw new VecDimensionMismatchError(recheck, dimensions);
|
|
946
1056
|
return;
|
|
947
1057
|
}
|
|
948
|
-
db.exec(
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
|
|
1058
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[${dimensions}] distance_metric=cosine)`);
|
|
1059
|
+
}).immediate();
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Public accessor for the vectors_vec dimension. Returns null if absent, the
|
|
1064
|
+
* integer dimension if valid, or throws VecSchemaError if the table exists with
|
|
1065
|
+
* a malformed/unexpected schema (see readVecTableDim).
|
|
1066
|
+
*/
|
|
1067
|
+
export function getVecTableDim(db: Database): number | null {
|
|
1068
|
+
return readVecTableDim(db);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* The DISTINCT non-empty embedding models stored in the vault (empty array if no
|
|
1073
|
+
* embeddings exist). Used to detect model drift BETWEEN runs — a different model at
|
|
1074
|
+
* the SAME dimension produces a heterogeneous vector space that dimension checks
|
|
1075
|
+
* cannot catch. Returning ALL distinct models (not just the majority) lets callers
|
|
1076
|
+
* also detect an ALREADY-heterogeneous vault (length > 1) instead of hiding the
|
|
1077
|
+
* minority behind a majority match. The implicit embed path aborts and requires
|
|
1078
|
+
* `embed --force` on any drift.
|
|
1079
|
+
*/
|
|
1080
|
+
export function getVecModels(db: Database): string[] {
|
|
1081
|
+
// Join to ACTIVE documents so stale/orphaned content_vectors rows (an obsolete
|
|
1082
|
+
// model on an inactive hash, not yet cleaned) cannot trigger a permanent
|
|
1083
|
+
// "mixed models" abort that would block the very cleanup that removes them.
|
|
1084
|
+
const rows = db.prepare(
|
|
1085
|
+
`SELECT DISTINCT cv.model
|
|
1086
|
+
FROM content_vectors cv
|
|
1087
|
+
JOIN documents d ON d.hash = cv.hash AND d.active = 1
|
|
1088
|
+
WHERE cv.model IS NOT NULL AND cv.model != ''
|
|
1089
|
+
ORDER BY cv.model`
|
|
1090
|
+
).all() as { model: string }[];
|
|
1091
|
+
return rows.map(r => r.model);
|
|
952
1092
|
}
|
|
953
1093
|
|
|
954
1094
|
// =============================================================================
|
|
@@ -959,7 +1099,9 @@ export type Store = {
|
|
|
959
1099
|
db: Database;
|
|
960
1100
|
dbPath: string;
|
|
961
1101
|
close: () => void;
|
|
962
|
-
ensureVecTable: (dimensions: number) => void;
|
|
1102
|
+
ensureVecTable: (dimensions: number, leaseGuard?: LeaseGuard) => void;
|
|
1103
|
+
getVecTableDim: () => number | null;
|
|
1104
|
+
getVecModels: () => string[];
|
|
963
1105
|
|
|
964
1106
|
// Index health
|
|
965
1107
|
getHashesNeedingEmbedding: () => number;
|
|
@@ -976,7 +1118,6 @@ export type Store = {
|
|
|
976
1118
|
deleteLLMCache: () => number;
|
|
977
1119
|
deleteInactiveDocuments: () => number;
|
|
978
1120
|
cleanupOrphanedContent: () => number;
|
|
979
|
-
cleanupOrphanedVectors: () => number;
|
|
980
1121
|
vacuumDatabase: () => void;
|
|
981
1122
|
|
|
982
1123
|
// Context
|
|
@@ -1025,9 +1166,10 @@ export type Store = {
|
|
|
1025
1166
|
// Vector/embedding operations
|
|
1026
1167
|
getHashesForEmbedding: () => { hash: string; body: string; path: string }[];
|
|
1027
1168
|
getHashesNeedingFragments: () => { hash: string; body: string; path: string; title: string; collection: string }[];
|
|
1028
|
-
clearAllEmbeddings: () => void;
|
|
1029
|
-
|
|
1030
|
-
|
|
1169
|
+
clearAllEmbeddings: (leaseGuard?: LeaseGuard) => void;
|
|
1170
|
+
getVectorConsistency: () => { cvCount: number; vvCount: number; cvMissingVv: number; vvOrphan: number; pending: number };
|
|
1171
|
+
insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string, leaseGuard?: LeaseGuard) => void;
|
|
1172
|
+
cleanStaleEmbeddings: (leaseGuard?: LeaseGuard) => number;
|
|
1031
1173
|
|
|
1032
1174
|
// SAME: Observation metadata
|
|
1033
1175
|
updateObservationFields: (docPath: string, collectionName: string, fields: { observation_type?: string; facts?: string; narrative?: string; concepts?: string; files_read?: string; files_modified?: string }) => void;
|
|
@@ -1052,6 +1194,7 @@ export type Store = {
|
|
|
1052
1194
|
snoozeDocument: (collection: string, path: string, until: string | null) => void;
|
|
1053
1195
|
|
|
1054
1196
|
// Embed state tracking
|
|
1197
|
+
markEmbedStart: (hash: string) => void;
|
|
1055
1198
|
markEmbedSynced: (hash: string) => void;
|
|
1056
1199
|
markEmbedFailed: (hash: string, error: string) => void;
|
|
1057
1200
|
getEmbedStats: () => { pending: number; synced: number; failed: number };
|
|
@@ -1150,7 +1293,9 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1150
1293
|
db,
|
|
1151
1294
|
dbPath: resolvedPath,
|
|
1152
1295
|
close: () => db.close(),
|
|
1153
|
-
ensureVecTable: (dimensions: number) => ensureVecTableInternal(db, dimensions),
|
|
1296
|
+
ensureVecTable: (dimensions: number, leaseGuard?: LeaseGuard) => ensureVecTableInternal(db, dimensions, leaseGuard),
|
|
1297
|
+
getVecTableDim: () => getVecTableDim(db),
|
|
1298
|
+
getVecModels: () => getVecModels(db),
|
|
1154
1299
|
|
|
1155
1300
|
// Index health
|
|
1156
1301
|
getHashesNeedingEmbedding: () => getHashesNeedingEmbedding(db),
|
|
@@ -1167,7 +1312,6 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1167
1312
|
deleteLLMCache: () => deleteLLMCache(db),
|
|
1168
1313
|
deleteInactiveDocuments: () => deleteInactiveDocuments(db),
|
|
1169
1314
|
cleanupOrphanedContent: () => cleanupOrphanedContent(db),
|
|
1170
|
-
cleanupOrphanedVectors: () => cleanupOrphanedVectors(db),
|
|
1171
1315
|
vacuumDatabase: () => vacuumDatabase(db),
|
|
1172
1316
|
|
|
1173
1317
|
// Context
|
|
@@ -1216,9 +1360,10 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1216
1360
|
// Vector/embedding operations
|
|
1217
1361
|
getHashesForEmbedding: () => getHashesForEmbedding(db),
|
|
1218
1362
|
getHashesNeedingFragments: () => getHashesNeedingFragments(db),
|
|
1219
|
-
clearAllEmbeddings: () => clearAllEmbeddings(db),
|
|
1220
|
-
|
|
1221
|
-
|
|
1363
|
+
clearAllEmbeddings: (leaseGuard?: LeaseGuard) => clearAllEmbeddings(db, leaseGuard),
|
|
1364
|
+
getVectorConsistency: () => getVectorConsistency(db),
|
|
1365
|
+
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),
|
|
1366
|
+
cleanStaleEmbeddings: (leaseGuard?: LeaseGuard) => cleanStaleEmbeddings(db, leaseGuard),
|
|
1222
1367
|
|
|
1223
1368
|
// SAME: Observation metadata
|
|
1224
1369
|
updateObservationFields: (docPath: string, collectionName: string, fields) => updateObservationFieldsFn(db, docPath, collectionName, fields),
|
|
@@ -1243,11 +1388,23 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1243
1388
|
snoozeDocument: (collection: string, path: string, until: string | null) => snoozeDocumentFn(db, collection, path, until),
|
|
1244
1389
|
|
|
1245
1390
|
// Embed state tracking
|
|
1391
|
+
markEmbedStart: (hash: string) => {
|
|
1392
|
+
// Increment embed_attempts exactly ONCE per attempt, at the start, and set
|
|
1393
|
+
// 'pending' so a crash mid-document leaves the doc retryable (and selected by
|
|
1394
|
+
// getHashesNeedingFragments). The completion setters below are state-only — no
|
|
1395
|
+
// further increment — so a start + a failure for the same attempt cannot
|
|
1396
|
+
// double-count the retry budget.
|
|
1397
|
+
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);
|
|
1398
|
+
},
|
|
1246
1399
|
markEmbedSynced: (hash: string) => {
|
|
1247
|
-
|
|
1400
|
+
// Success resets the retry budget: embed_attempts counts CONSECUTIVE failures
|
|
1401
|
+
// of the current content, so a successful (re-)embed must clear it — otherwise
|
|
1402
|
+
// a doc re-embedded many times (repeated content edits) accumulates attempts
|
|
1403
|
+
// and is wrongly excluded by the worklist's `embed_attempts < 3` guard.
|
|
1404
|
+
db.prepare(`UPDATE documents SET embed_state = 'synced', embed_attempts = 0, embed_error = NULL WHERE hash = ? AND active = 1`).run(hash);
|
|
1248
1405
|
},
|
|
1249
1406
|
markEmbedFailed: (hash: string, error: string) => {
|
|
1250
|
-
db.prepare(`UPDATE documents SET embed_state = 'failed', embed_error =
|
|
1407
|
+
db.prepare(`UPDATE documents SET embed_state = 'failed', embed_error = ? WHERE hash = ? AND active = 1`).run(error, hash);
|
|
1251
1408
|
},
|
|
1252
1409
|
getEmbedStats: () => {
|
|
1253
1410
|
const stats = db.prepare(`
|
|
@@ -1923,51 +2080,9 @@ export function cleanupOrphanedContent(db: Database): number {
|
|
|
1923
2080
|
return result.changes;
|
|
1924
2081
|
}
|
|
1925
2082
|
|
|
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
|
-
}
|
|
2083
|
+
// (Removed cleanupOrphanedVectors — an exposed, unfenced, non-transactional vector
|
|
2084
|
+
// mutation with no production caller. Use cleanStaleEmbeddings, which is lease-fenced
|
|
2085
|
+
// and atomic. See INCIDENT-2026-06-22 / codex review.)
|
|
1971
2086
|
|
|
1972
2087
|
/**
|
|
1973
2088
|
* Run VACUUM to reclaim unused space in the database.
|
|
@@ -1996,35 +2111,40 @@ export function canonicalDocId(collection: string, path: string): string {
|
|
|
1996
2111
|
* to any active document. Also cleans the corresponding vectors_vec rows.
|
|
1997
2112
|
* Returns the number of stale embeddings removed.
|
|
1998
2113
|
*/
|
|
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
|
-
|
|
2114
|
+
export function cleanStaleEmbeddings(db: Database, leaseGuard?: LeaseGuard): number {
|
|
2115
|
+
// Atomic + lease-fenced: the ownership check and the deletes share one transaction
|
|
2116
|
+
// so a process that lost its lease cannot delete vectors out from under the new holder.
|
|
2117
|
+
return db.transaction(() => {
|
|
2118
|
+
assertLeaseHeld(db, leaseGuard);
|
|
2119
|
+
// Find orphaned hashes in content_vectors that have no active document
|
|
2120
|
+
const staleRows = db.prepare(`
|
|
2121
|
+
SELECT DISTINCT cv.hash
|
|
2122
|
+
FROM content_vectors cv
|
|
2123
|
+
LEFT JOIN documents d ON d.hash = cv.hash AND d.active = 1
|
|
2124
|
+
WHERE d.id IS NULL
|
|
2125
|
+
`).all() as { hash: string }[];
|
|
2126
|
+
|
|
2127
|
+
if (staleRows.length === 0) return 0;
|
|
2128
|
+
|
|
2129
|
+
const staleHashes = staleRows.map(r => r.hash);
|
|
2130
|
+
|
|
2131
|
+
// Get all hash_seq keys for stale rows to clean vectors_vec
|
|
2132
|
+
const placeholders = staleHashes.map(() => '?').join(',');
|
|
2133
|
+
const staleVecKeys = db.prepare(`
|
|
2134
|
+
SELECT hash || '_' || seq as hash_seq FROM content_vectors WHERE hash IN (${placeholders})
|
|
2135
|
+
`).all(...staleHashes) as { hash_seq: string }[];
|
|
2136
|
+
|
|
2137
|
+
// Delete from vectors_vec
|
|
2138
|
+
if (staleVecKeys.length > 0) {
|
|
2139
|
+
const vecPlaceholders = staleVecKeys.map(() => '?').join(',');
|
|
2140
|
+
db.prepare(`DELETE FROM vectors_vec WHERE hash_seq IN (${vecPlaceholders})`).run(...staleVecKeys.map(r => r.hash_seq));
|
|
2141
|
+
}
|
|
2023
2142
|
|
|
2024
|
-
|
|
2025
|
-
|
|
2143
|
+
// Delete from content_vectors
|
|
2144
|
+
db.prepare(`DELETE FROM content_vectors WHERE hash IN (${placeholders})`).run(...staleHashes);
|
|
2026
2145
|
|
|
2027
|
-
|
|
2146
|
+
return staleVecKeys.length;
|
|
2147
|
+
}).immediate(); // immediate write lock: assert ownership under the lock before the deletes
|
|
2028
2148
|
}
|
|
2029
2149
|
|
|
2030
2150
|
// =============================================================================
|
|
@@ -2410,6 +2530,8 @@ export function reactivateDocument(
|
|
|
2410
2530
|
modifiedAt: string
|
|
2411
2531
|
): void {
|
|
2412
2532
|
const safeTitle = (typeof title === "string") ? title : String(title ?? "Untitled");
|
|
2533
|
+
// The reset_embed_on_hash_change trigger resets embed_state/attempts/error iff the
|
|
2534
|
+
// hash actually changes, so re-adding unchanged content preserves its valid vectors.
|
|
2413
2535
|
db.prepare(`UPDATE documents SET active = 1, title = ?, hash = ?, modified_at = ? WHERE id = ?`)
|
|
2414
2536
|
.run(safeTitle, hash, modifiedAt, documentId);
|
|
2415
2537
|
}
|
|
@@ -2439,6 +2561,8 @@ export function updateDocument(
|
|
|
2439
2561
|
modifiedAt: string
|
|
2440
2562
|
): void {
|
|
2441
2563
|
const safeTitle = (typeof title === "string") ? title : String(title ?? "Untitled");
|
|
2564
|
+
// The reset_embed_on_hash_change trigger resets embed_state/attempts/error when the
|
|
2565
|
+
// hash actually changes, so this only needs to set the content fields.
|
|
2442
2566
|
db.prepare(`UPDATE documents SET title = ?, hash = ?, modified_at = ? WHERE id = ?`)
|
|
2443
2567
|
.run(safeTitle, hash, modifiedAt, documentId);
|
|
2444
2568
|
}
|
|
@@ -3305,6 +3429,9 @@ export function getHashesForEmbedding(db: Database): { hash: string; body: strin
|
|
|
3305
3429
|
export function getHashesNeedingFragments(db: Database): { hash: string; body: string; path: string; title: string; collection: string }[] {
|
|
3306
3430
|
// Select docs that either have no fragments at all OR are missing the primary (seq=0) fragment.
|
|
3307
3431
|
// The seq=0 embedding is critical — surprisal scoring, semantic graph, and health checks depend on it.
|
|
3432
|
+
// Also retry docs left 'pending' (crash mid-doc) or 'failed' (partial fragment failure) so partial
|
|
3433
|
+
// embeds are not permanently silent — bounded by embed_attempts < 3. The OR-branch is parenthesized
|
|
3434
|
+
// so embed_attempts < 3 and d.active = 1 always apply to every selected row (SQL precedence).
|
|
3308
3435
|
return db.prepare(`
|
|
3309
3436
|
SELECT d.hash, c.doc as body, MIN(d.path) as path, MIN(d.title) as title, MIN(d.collection) as collection
|
|
3310
3437
|
FROM documents d
|
|
@@ -3312,8 +3439,8 @@ export function getHashesNeedingFragments(db: Database): { hash: string; body: s
|
|
|
3312
3439
|
LEFT JOIN content_vectors v ON d.hash = v.hash AND v.fragment_type IS NOT NULL
|
|
3313
3440
|
LEFT JOIN content_vectors v0 ON d.hash = v0.hash AND v0.seq = 0
|
|
3314
3441
|
WHERE d.active = 1
|
|
3315
|
-
AND (v.hash IS NULL OR v0.hash IS NULL)
|
|
3316
3442
|
AND COALESCE(d.embed_attempts, 0) < 3
|
|
3443
|
+
AND ((v.hash IS NULL OR v0.hash IS NULL) OR d.embed_state IN ('pending', 'failed'))
|
|
3317
3444
|
GROUP BY d.hash
|
|
3318
3445
|
`).all() as { hash: string; body: string; path: string; title: string; collection: string }[];
|
|
3319
3446
|
}
|
|
@@ -3322,12 +3449,53 @@ export function getHashesNeedingFragments(db: Database): { hash: string; body: s
|
|
|
3322
3449
|
* Clear all embeddings from the database (force re-index).
|
|
3323
3450
|
* Deletes all rows from content_vectors and drops the vectors_vec table.
|
|
3324
3451
|
*/
|
|
3325
|
-
export function clearAllEmbeddings(db: Database): void {
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
//
|
|
3329
|
-
|
|
3330
|
-
|
|
3452
|
+
export function clearAllEmbeddings(db: Database, leaseGuard?: LeaseGuard): void {
|
|
3453
|
+
// Atomic: the lease check + DELETE content_vectors + DROP vectors_vec + reset
|
|
3454
|
+
// embed_state all commit together. The in-transaction assertLeaseHeld means a
|
|
3455
|
+
// `--force` process that lost its lease during the endpoint probe cannot wipe the
|
|
3456
|
+
// vault out from under the new holder (the destructive op is the highest-risk
|
|
3457
|
+
// mutation, so it MUST be fenced). A crash/concurrent reader never observes a
|
|
3458
|
+
// half-cleared state.
|
|
3459
|
+
db.transaction(() => {
|
|
3460
|
+
assertLeaseHeld(db, leaseGuard);
|
|
3461
|
+
db.exec(`DELETE FROM content_vectors`);
|
|
3462
|
+
db.exec(`DROP TABLE IF EXISTS vectors_vec`);
|
|
3463
|
+
// Reset embed state so failed docs get retried after force re-embed
|
|
3464
|
+
try { db.exec(`UPDATE documents SET embed_state = 'pending', embed_error = NULL, embed_attempts = 0 WHERE active = 1`); } catch { /* column may not exist yet */ }
|
|
3465
|
+
}).immediate(); // immediate write lock: assert ownership under the lock before this destructive op
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
/**
|
|
3469
|
+
* Vault-wide content_vectors ↔ vectors_vec consistency snapshot for `doctor`.
|
|
3470
|
+
* Computes BOTH key-set differences (not just counts — one missing + one orphan
|
|
3471
|
+
* cancel in a count check) under a single read transaction so both scans see the
|
|
3472
|
+
* same committed snapshot. Any nonzero cvMissingVv / vvOrphan is an invariant
|
|
3473
|
+
* violation (every content_vectors row must have a vectors_vec entry and vice-versa).
|
|
3474
|
+
* `pending` is reported separately (pending docs are in neither table, so they are
|
|
3475
|
+
* not a desync). See INCIDENT-2026-06-22 §12.
|
|
3476
|
+
*/
|
|
3477
|
+
export function getVectorConsistency(db: Database): {
|
|
3478
|
+
cvCount: number; vvCount: number; cvMissingVv: number; vvOrphan: number; pending: number;
|
|
3479
|
+
} {
|
|
3480
|
+
return db.transaction(() => {
|
|
3481
|
+
const cvKeys = new Set<string>(
|
|
3482
|
+
(db.prepare(`SELECT hash || '_' || seq AS k FROM content_vectors`).all() as { k: string }[]).map(r => r.k)
|
|
3483
|
+
);
|
|
3484
|
+
let vvKeys = new Set<string>();
|
|
3485
|
+
try {
|
|
3486
|
+
vvKeys = new Set<string>(
|
|
3487
|
+
(db.prepare(`SELECT hash_seq FROM vectors_vec`).all() as { hash_seq: string }[]).map(r => r.hash_seq)
|
|
3488
|
+
);
|
|
3489
|
+
} catch { /* vectors_vec absent → empty set (cvMissingVv will surface it) */ }
|
|
3490
|
+
let cvMissingVv = 0;
|
|
3491
|
+
for (const k of cvKeys) if (!vvKeys.has(k)) cvMissingVv++;
|
|
3492
|
+
let vvOrphan = 0;
|
|
3493
|
+
for (const k of vvKeys) if (!cvKeys.has(k)) vvOrphan++;
|
|
3494
|
+
const pending = (db.prepare(
|
|
3495
|
+
`SELECT COUNT(*) AS n FROM documents WHERE active = 1 AND (embed_state = 'pending' OR embed_state IS NULL)`
|
|
3496
|
+
).get() as { n: number }).n;
|
|
3497
|
+
return { cvCount: cvKeys.size, vvCount: vvKeys.size, cvMissingVv, vvOrphan, pending };
|
|
3498
|
+
})();
|
|
3331
3499
|
}
|
|
3332
3500
|
|
|
3333
3501
|
/**
|
|
@@ -3344,16 +3512,31 @@ export function insertEmbedding(
|
|
|
3344
3512
|
embeddedAt: string,
|
|
3345
3513
|
fragmentType?: string,
|
|
3346
3514
|
fragmentLabel?: string,
|
|
3347
|
-
canonicalId?: string
|
|
3515
|
+
canonicalId?: string,
|
|
3516
|
+
leaseGuard?: LeaseGuard
|
|
3348
3517
|
): void {
|
|
3349
3518
|
const hashSeq = `${hash}_${seq}`;
|
|
3350
|
-
// vec0
|
|
3351
|
-
//
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3519
|
+
// Atomic vec0 + metadata write: the DELETE (vec0's "upsert" — no INSERT OR
|
|
3520
|
+
// REPLACE on vec0), the vector INSERT, and the content_vectors UPSERT commit
|
|
3521
|
+
// together, so an interruption can never leave a vector without its metadata
|
|
3522
|
+
// row or vice-versa (the cv↔vv invariant the doctor check enforces).
|
|
3523
|
+
// ensureVecTable is always called before this and now throws (never drops)
|
|
3524
|
+
// on a dimension mismatch, so vectors_vec reliably exists here — the old
|
|
3525
|
+
// DELETE error-suppression for the "table missing mid-migration" case is gone.
|
|
3526
|
+
//
|
|
3527
|
+
// leaseGuard (optional): an in-transaction embedding-lease fence. The token
|
|
3528
|
+
// check and the writes share ONE transaction, so a process that lost the lease
|
|
3529
|
+
// while awaiting the model (between its loop-level leaseLost check and this
|
|
3530
|
+
// write) cannot commit a stale vector — SQLite serializes the transaction, so
|
|
3531
|
+
// no other holder can interleave a reclaim between the check and the INSERTs.
|
|
3532
|
+
db.transaction(() => {
|
|
3533
|
+
assertLeaseHeld(db, leaseGuard);
|
|
3534
|
+
db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`).run(hashSeq);
|
|
3535
|
+
db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`).run(hashSeq, embedding);
|
|
3536
|
+
db.prepare(
|
|
3537
|
+
`INSERT OR REPLACE INTO content_vectors (hash, seq, pos, model, embedded_at, fragment_type, fragment_label, canonical_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
3538
|
+
).run(hash, seq, pos, model, embeddedAt, fragmentType ?? null, fragmentLabel ?? null, canonicalId ?? null);
|
|
3539
|
+
}).immediate(); // immediate write lock: the lease assert reads under the lock, so a lost-lease write can't slip through
|
|
3357
3540
|
}
|
|
3358
3541
|
|
|
3359
3542
|
// =============================================================================
|
package/src/worker-lease.ts
CHANGED
|
@@ -114,6 +114,40 @@ export function releaseWorkerLease(
|
|
|
114
114
|
return result.changes > 0;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Extend a lease's expiry if the caller's token still owns it. Returns `true`
|
|
119
|
+
* if THIS holder still owns the lease and the TTL was extended; `false` if the
|
|
120
|
+
* row is gone or was reclaimed by another token (we lost the lease). A long
|
|
121
|
+
* embed run renews on a heartbeat well inside its TTL; on a `false` result the
|
|
122
|
+
* caller MUST abort, because another process now holds the lease and could run
|
|
123
|
+
* concurrently. Token-fenced like releaseWorkerLease (the UPDATE matches only
|
|
124
|
+
* our own (worker_name, lease_token) row); any DB error → `false` (fail-safe:
|
|
125
|
+
* a renewal failure is treated as lease-lost → abort).
|
|
126
|
+
*/
|
|
127
|
+
export function renewWorkerLease(
|
|
128
|
+
store: Store,
|
|
129
|
+
workerName: string,
|
|
130
|
+
token: string,
|
|
131
|
+
ttlMs: number,
|
|
132
|
+
now: Date = new Date(),
|
|
133
|
+
): boolean {
|
|
134
|
+
if (ttlMs <= 0) {
|
|
135
|
+
throw new Error(`renewWorkerLease: ttlMs must be positive, got ${ttlMs}`);
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const result = store.db.prepare(
|
|
139
|
+
`UPDATE worker_leases SET acquired_at = ?, expires_at = ?
|
|
140
|
+
WHERE worker_name = ? AND lease_token = ?`,
|
|
141
|
+
).run(nowIso(now), futureIso(now, ttlMs), workerName, token);
|
|
142
|
+
return result.changes > 0;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error(
|
|
145
|
+
`[worker-lease] renew error for ${workerName}: ${(err as Error).message}`,
|
|
146
|
+
);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
117
151
|
/**
|
|
118
152
|
* Run `fn` under an exclusive lease on `workerName`. If the lease cannot
|
|
119
153
|
* be acquired, returns `{acquired: false}` without invoking `fn`. The
|
|
Binary file
|