clawmem 0.10.7 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/clawmem.ts CHANGED
@@ -13,11 +13,16 @@ import {
13
13
  canonicalDocId,
14
14
  type Store,
15
15
  type SearchResult,
16
+ type ExpandedQuery,
16
17
  DEFAULT_EMBED_MODEL,
17
18
  DEFAULT_QUERY_MODEL,
18
19
  DEFAULT_RERANK_MODEL,
19
20
  DEFAULT_GLOB,
20
21
  extractSnippet,
22
+ FatalVectorError,
23
+ VecDimensionMismatchError,
24
+ VecModelMismatchError,
25
+ EmbedLeaseLostError,
21
26
  } from "./store.ts";
22
27
  import {
23
28
  getDefaultLlamaCpp,
@@ -28,6 +33,11 @@ import {
28
33
  LlamaCpp,
29
34
  type Queryable,
30
35
  } from "./llm.ts";
36
+ import {
37
+ acquireWorkerLease,
38
+ releaseWorkerLease,
39
+ renewWorkerLease,
40
+ } from "./worker-lease.ts";
31
41
  import {
32
42
  loadConfig,
33
43
  addCollection as collectionsAdd,
@@ -385,200 +395,331 @@ async function cmdEmbed(args: string[]) {
385
395
 
386
396
  const s = getStore();
387
397
 
388
- if (values.force) {
389
- console.log(`${c.yellow}Force mode: clearing all embeddings${c.reset}`);
390
- s.clearAllEmbeddings();
398
+ // Embedding lease: serialize embed commands (manual / embed timer / update --embed)
399
+ // so two embeds cannot run at once. It is RENEWABLE (token-fenced heartbeat), not a
400
+ // fixed-TTL lease — a full-vault rebuild outlasts any fixed TTL and would be reclaimed
401
+ // mid-run. Without serialization, two embeds using different same-dimension models can
402
+ // silently build a heterogeneous vector space (dimension checks can't catch that).
403
+ // See EMBED-LEASE-RENEWAL-DESIGN.md / INCIDENT-2026-06-22.
404
+ const LEASE_NAME = "embedding";
405
+ const LEASE_TTL_MS = 60_000;
406
+ const lease = acquireWorkerLease(s, LEASE_NAME, LEASE_TTL_MS);
407
+ if (!lease.acquired || !lease.token) {
408
+ console.log(`${c.yellow}Another embed is already in progress (lease held); skipping.${c.reset}`);
409
+ return;
391
410
  }
411
+ const leaseToken = lease.token;
412
+ // Passed into every vector mutation (clear, stale-clean, table-create, insert) so
413
+ // each verifies ownership before mutating — a process that lost the lease mid-await
414
+ // cannot wipe/recreate/write the vector store under the new holder.
415
+ const leaseGuard = { workerName: LEASE_NAME, token: leaseToken };
416
+ let leaseLost = false;
417
+ const heartbeat = setInterval(() => {
418
+ if (!renewWorkerLease(s, LEASE_NAME, leaseToken, LEASE_TTL_MS)) leaseLost = true;
419
+ }, Math.floor(LEASE_TTL_MS / 2));
392
420
 
393
- // Clean stale embeddings (orphaned hashes from updated/deleted documents)
394
- const cleaned = s.cleanStaleEmbeddings();
395
- if (cleaned > 0) {
396
- console.log(`${c.yellow}Cleaned ${cleaned} stale embedding(s) from orphaned documents${c.reset}`);
397
- }
421
+ try {
422
+ const embedUrl = process.env.CLAWMEM_EMBED_URL;
423
+ if (embedUrl) {
424
+ console.log(`Using remote GPU embedding: ${embedUrl}`);
425
+ } else {
426
+ // Local CPU mode: disable inactivity timeout to prevent context disposal mid-batch
427
+ setDefaultLlamaCpp(new LlamaCpp({ inactivityTimeoutMs: 0 }));
428
+ }
429
+ const llm = getDefaultLlamaCpp();
398
430
 
399
- // Use fragment-based pipeline: split documents into semantic fragments and embed each
400
- const hashes = s.getHashesNeedingFragments();
401
- if (hashes.length === 0) {
402
- console.log(`${c.green}All documents already embedded${c.reset}`);
403
- return;
404
- }
431
+ // Probe the live model's output dimension (+ model name). Returns null on ANY
432
+ // failure so a down/flaky endpoint can NEVER trigger a destructive clear.
433
+ const probeEmbed = async (): Promise<{ dim: number; model: string } | null> => {
434
+ try {
435
+ const r = await llm.embed("clawmem dimension probe");
436
+ return r && r.embedding && r.embedding.length > 0
437
+ ? { dim: r.embedding.length, model: r.model ?? "" }
438
+ : null;
439
+ } catch { return null; }
440
+ };
405
441
 
406
- // Count total fragments first for ETA
407
- let totalFragEstimate = 0;
408
- const docFragCounts: number[] = [];
409
- for (const { body, path } of hashes) {
410
- let frontmatter: Record<string, any> | undefined;
411
- try {
412
- const parsed = parseDocument(body, path);
413
- frontmatter = parsed.meta as any;
414
- } catch { /* skip */ }
415
- const frags = splitDocument(body, frontmatter);
416
- docFragCounts.push(frags.length);
417
- totalFragEstimate += frags.length;
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));
442
+ // Bind the whole run to one (dim, model); every fragment is validated before it is
443
+ // stored. On a fresh vault these are set from the first successful fragment.
444
+ let expectedDim: number | null = null;
445
+ let expectedModel: string | null = null;
446
+
447
+ if (values.force) {
448
+ // Probe FIRST — validate the endpoint before destroying anything (so a force
449
+ // re-embed against a dead endpoint cannot clear the vault and then fail).
450
+ const probe = await probeEmbed();
451
+ if (!probe) {
452
+ console.error(`${c.red}Force re-embed aborted: could not reach the embedding endpoint. Nothing was cleared.${c.reset}`);
453
+ return;
470
454
  }
471
-
472
- for (let batchStart = 0; batchStart < allTexts.length; batchStart += CLOUD_BATCH_SIZE) {
473
- // Global TPM-aware delay: compute required wait based on last batch's token count,
474
- // then wait only the remaining time since lastBatchSentAt. Applies to ALL batches
475
- // including first batch of each document (inter-document pacing).
476
- if (lastBatchSentAt > 0) {
477
- // Adaptive TPM-aware delay. Set CLAWMEM_EMBED_TPM_LIMIT to match your tier:
478
- // Free: 100000 (default), Paid: 2000000, Premium: 50000000
479
- const batchEnd0 = Math.min(batchStart + CLOUD_BATCH_SIZE, allTexts.length);
480
- const estimatedTokens = allTexts.slice(batchStart, batchEnd0)
481
- .reduce((sum, t) => sum + Math.ceil(t.length / CHARS_PER_TOKEN), 0);
482
- // Use current batch estimate (not previous batch actuals previous batch may differ in size)
483
- const batchTokens = estimatedTokens;
484
- const safeTPM = CLOUD_TPM_LIMIT * CLOUD_TPM_SAFETY;
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
- }
455
+ console.log(`${c.yellow}Force mode: clearing all embeddings (rebuilding at dim ${probe.dim})${c.reset}`);
456
+ expectedDim = probe.dim;
457
+ expectedModel = probe.model || null;
458
+ s.clearAllEmbeddings(leaseGuard);
459
+ } else {
460
+ // Implicit run: NON-DESTRUCTIVE drift check (dimension AND model). Catches
461
+ // drift even when the worklist is empty (query embeddings would already be
462
+ // incompatible with the stored table). Never clears aborts with instructions.
463
+ const existingDim = s.getVecTableDim(); // throws VecSchemaError on malformed DDL → caught below
464
+ if (existingDim !== null) {
465
+ const probe = await probeEmbed();
466
+ if (probe && probe.dim !== existingDim) {
467
+ console.error(`${c.red}Embedding dimension changed (${existingDim} → ${probe.dim}). Run 'clawmem embed --force' to clear and rebuild the full vault.${c.reset}`);
468
+ return;
492
469
  }
470
+ // Same dimension but a DIFFERENT model still mixes the vector space (cosine
471
+ // across two models is meaningless) and the dim check cannot see it. Compare
472
+ // the probe's model against what the vault was built with; abort on mismatch.
473
+ const existingModels = s.getVecModels();
474
+ if (existingModels.length > 1) {
475
+ console.error(`${c.red}Vault already contains mixed embedding models: ${existingModels.join(", ")}. Run 'clawmem embed --force' to rebuild with a single model.${c.reset}`);
476
+ return;
477
+ }
478
+ if (probe && probe.model && existingModels.length === 1 && existingModels[0] !== probe.model) {
479
+ 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}`);
480
+ return;
481
+ }
482
+ expectedDim = existingDim;
483
+ if (probe && probe.model) expectedModel = probe.model;
484
+ }
485
+ }
486
+
487
+ // Clean stale embeddings (orphaned hashes from updated/deleted documents)
488
+ const cleaned = s.cleanStaleEmbeddings(leaseGuard);
489
+ if (cleaned > 0) {
490
+ console.log(`${c.yellow}Cleaned ${cleaned} stale embedding(s) from orphaned documents${c.reset}`);
491
+ }
492
+
493
+ // Use fragment-based pipeline: split documents into semantic fragments and embed each
494
+ const hashes = s.getHashesNeedingFragments();
495
+ if (hashes.length === 0) {
496
+ console.log(`${c.green}All documents already embedded${c.reset}`);
497
+ return;
498
+ }
499
+
500
+ // Count total fragments first for ETA
501
+ let totalFragEstimate = 0;
502
+ const docFragCounts: number[] = [];
503
+ for (const { body, path } of hashes) {
504
+ let frontmatter: Record<string, any> | undefined;
505
+ try {
506
+ const parsed = parseDocument(body, path);
507
+ frontmatter = parsed.meta as any;
508
+ } catch { /* skip */ }
509
+ const frags = splitDocument(body, frontmatter);
510
+ docFragCounts.push(frags.length);
511
+ totalFragEstimate += frags.length;
512
+ }
513
+ console.log(`Embedding ${hashes.length} documents (${totalFragEstimate} fragments total)...`);
514
+
515
+ let embedded = 0;
516
+ let totalFragments = 0;
517
+ let failedFragments = 0;
518
+ const batchStart = Date.now();
519
+
520
+ // Cloud API: global batch pacing state (persists across documents)
521
+ // TPM is the binding constraint, not RPM. 50 frags × ~800 tokens ≈ 40K tokens/batch → max ~2.5 batches/min at 100K TPM.
522
+ const isCloudEmbed = !!process.env.CLAWMEM_EMBED_API_KEY;
523
+ const CLOUD_BATCH_SIZE = 50;
524
+ const CLOUD_TPM_LIMIT = parseInt(process.env.CLAWMEM_EMBED_TPM_LIMIT || "100000", 10);
525
+ const CLOUD_TPM_SAFETY = 0.85; // use 85% of limit to leave headroom for retries
526
+ const CHARS_PER_TOKEN = 4;
527
+ let lastBatchSentAt = 0; // global timestamp of last batch send
528
+
529
+ // Bind the run to one (dim, model) and validate every embedding before it is stored.
530
+ // The first successful fragment sets the binding on a fresh vault; any later drift —
531
+ // a dimension change OR a same-dimension model swap from a flapping endpoint — throws
532
+ // a fatal error that aborts the whole run (caught below). This is what dimension
533
+ // checks alone cannot do: catch a different 2560-d model.
534
+ const bindAndValidate = (result: { embedding: number[] | Float32Array; model?: string }) => {
535
+ const dim = result.embedding.length;
536
+ if (expectedDim === null) {
537
+ expectedDim = dim;
538
+ if (!expectedModel && result.model) expectedModel = result.model;
539
+ } else if (dim !== expectedDim) {
540
+ throw new VecDimensionMismatchError(expectedDim, dim);
541
+ }
542
+ if (expectedModel && result.model && result.model !== expectedModel) {
543
+ throw new VecModelMismatchError(expectedModel, result.model);
544
+ }
545
+ };
493
546
 
494
- const batchEnd = Math.min(batchStart + CLOUD_BATCH_SIZE, allTexts.length);
495
- const batchTexts = allTexts.slice(batchStart, batchEnd);
496
- lastBatchSentAt = Date.now();
497
- const reqStart = Date.now();
547
+ for (let docIdx = 0; docIdx < hashes.length; docIdx++) {
548
+ // Abort cleanly if the heartbeat reported the lease was reclaimed.
549
+ if (leaseLost) throw new EmbedLeaseLostError();
498
550
 
499
- try {
500
- const results = await llm.embedBatch(batchTexts);
501
- const reqMs = Date.now() - reqStart;
502
- const tokensUsed = llm.lastBatchTokens;
551
+ const { hash, body, path, title: docTitle, collection } = hashes[docIdx]!;
552
+ const title = docTitle || basename(path).replace(/\.(md|txt)$/i, "");
553
+ const canId = canonicalDocId(collection, path);
503
554
 
504
- for (let i = 0; i < results.length; i++) {
505
- const seq = batchStart + i;
506
- const frag = fragments[seq]!;
507
- const result = results[i];
555
+ // Parse frontmatter for fragment splitting
556
+ let frontmatter: Record<string, any> | undefined;
557
+ try {
558
+ const parsed = parseDocument(body, path);
559
+ frontmatter = parsed.meta as any;
560
+ } catch {
561
+ // No frontmatter or parsing error — fine, skip it
562
+ }
563
+
564
+ const fragments = splitDocument(body, frontmatter);
565
+ const docStart = Date.now();
566
+ const prevFailedFragments = failedFragments;
567
+ let seq0Succeeded = false;
568
+
569
+ // Mark the doc 'pending' and increment embed_attempts ONCE before its first
570
+ // fragment, so a crash mid-document leaves it retryable (re-selected by
571
+ // getHashesNeedingFragments). Completion setters below are state-only.
572
+ s.markEmbedStart(hash);
573
+ console.error(` [${docIdx + 1}/${hashes.length}] ${basename(path)} (${fragments.length} frags, ${body.length} chars)`);
574
+
575
+ if (isCloudEmbed) {
576
+ // Batch mode: collect all texts, send in chunks of CLOUD_BATCH_SIZE
577
+ const allTexts: string[] = [];
578
+ for (const frag of fragments) {
579
+ const label = frag.label || title;
580
+ allTexts.push(formatDocForEmbedding(frag.content, label));
581
+ }
582
+
583
+ for (let batchStartIdx = 0; batchStartIdx < allTexts.length; batchStartIdx += CLOUD_BATCH_SIZE) {
584
+ // Abort before each batch if the lease was reclaimed — a large document
585
+ // must not keep writing after another process took the lease (HIGH-3).
586
+ if (leaseLost) throw new EmbedLeaseLostError();
587
+ // Global TPM-aware delay: compute required wait based on last batch's token count,
588
+ // then wait only the remaining time since lastBatchSentAt. Applies to ALL batches
589
+ // including first batch of each document (inter-document pacing).
590
+ if (lastBatchSentAt > 0) {
591
+ // Adaptive TPM-aware delay. Set CLAWMEM_EMBED_TPM_LIMIT to match your tier:
592
+ // Free: 100000 (default), Paid: 2000000, Premium: 50000000
593
+ const batchEnd0 = Math.min(batchStartIdx + CLOUD_BATCH_SIZE, allTexts.length);
594
+ const estimatedTokens = allTexts.slice(batchStartIdx, batchEnd0)
595
+ .reduce((sum, t) => sum + Math.ceil(t.length / CHARS_PER_TOKEN), 0);
596
+ // Use current batch estimate (not previous batch actuals — previous batch may differ in size)
597
+ const batchTokens = estimatedTokens;
598
+ const safeTPM = CLOUD_TPM_LIMIT * CLOUD_TPM_SAFETY;
599
+ const requiredGapMs = Math.max(500, (batchTokens / safeTPM) * 60_000);
600
+ const elapsed = Date.now() - lastBatchSentAt;
601
+ const remainingMs = requiredGapMs - elapsed;
602
+ if (remainingMs > 0) {
603
+ const jittered = Math.floor(remainingMs * (0.85 + Math.random() * 0.3));
604
+ await new Promise(r => setTimeout(r, jittered));
605
+ }
606
+ }
607
+
608
+ const batchEnd = Math.min(batchStartIdx + CLOUD_BATCH_SIZE, allTexts.length);
609
+ const batchTexts = allTexts.slice(batchStartIdx, batchEnd);
610
+ lastBatchSentAt = Date.now();
611
+ const reqStart = Date.now();
612
+
613
+ try {
614
+ const results = await llm.embedBatch(batchTexts);
615
+ const reqMs = Date.now() - reqStart;
616
+ const tokensUsed = llm.lastBatchTokens;
617
+
618
+ for (let i = 0; i < results.length; i++) {
619
+ const seq = batchStartIdx + i;
620
+ const frag = fragments[seq]!;
621
+ const result = results[i];
622
+ if (result) {
623
+ bindAndValidate(result);
624
+ s.ensureVecTable(result.embedding.length, leaseGuard);
625
+ s.insertEmbedding(
626
+ hash, seq, frag.startLine, new Float32Array(result.embedding),
627
+ result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
628
+ leaseGuard
629
+ );
630
+ totalFragments++;
631
+ if (seq === 0) seq0Succeeded = true;
632
+ } else {
633
+ failedFragments++;
634
+ }
635
+ }
636
+ console.error(` batch ${batchStartIdx + 1}-${batchEnd}/${allTexts.length} (${results.filter(r => r).length} ok) ${reqMs}ms${tokensUsed ? ` ${tokensUsed} tok` : ""}`);
637
+ } catch (err) {
638
+ if (err instanceof FatalVectorError) throw err; // dim/model/schema mismatch → abort the whole run
639
+ failedFragments += batchTexts.length;
640
+ console.error(`${c.yellow}Warning: batch embed failed for ${path} frags ${batchStartIdx + 1}-${batchEnd}: ${err}${c.reset}`);
641
+ }
642
+ }
643
+ } else {
644
+ // Local mode: embed one at a time (no rate limit concern)
645
+ for (let seq = 0; seq < fragments.length; seq++) {
646
+ // Abort before each fragment if the lease was reclaimed — bounds any
647
+ // post-loss writing to at most one fragment of a large doc (HIGH-3).
648
+ if (leaseLost) throw new EmbedLeaseLostError();
649
+ const frag = fragments[seq]!;
650
+ const label = frag.label || title;
651
+ const text = formatDocForEmbedding(frag.content, label);
652
+
653
+ try {
654
+ const fragStart = Date.now();
655
+ const result = await llm.embed(text);
656
+ const fragMs = Date.now() - fragStart;
508
657
  if (result) {
509
- s.ensureVecTable(result.embedding.length);
658
+ bindAndValidate(result);
659
+ s.ensureVecTable(result.embedding.length, leaseGuard);
510
660
  s.insertEmbedding(
511
661
  hash, seq, frag.startLine, new Float32Array(result.embedding),
512
- result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
662
+ result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
663
+ leaseGuard
513
664
  );
514
665
  totalFragments++;
515
666
  if (seq === 0) seq0Succeeded = true;
667
+ if (seq === 0 || (seq + 1) % 5 === 0 || seq === fragments.length - 1) {
668
+ console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) ${fragMs}ms [${text.length} chars]`);
669
+ }
516
670
  } else {
517
671
  failedFragments++;
672
+ console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) → null result [${text.length} chars]`);
518
673
  }
519
- }
520
- console.error(` batch ${batchStart + 1}-${batchEnd}/${allTexts.length} (${results.filter(r => r).length} ok) ${reqMs}ms${tokensUsed ? ` ${tokensUsed} tok` : ""}`);
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 {
674
+ } catch (err) {
675
+ if (err instanceof FatalVectorError) throw err; // dim/model/schema mismatch abort the whole run
549
676
  failedFragments++;
550
- console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) null result [${text.length} chars]`);
677
+ console.error(`${c.yellow}Warning: failed to embed fragment ${seq} (${frag.type}) of ${path}: ${err}${c.reset}`);
551
678
  }
552
- } catch (err) {
553
- failedFragments++;
554
- console.error(`${c.yellow}Warning: failed to embed fragment ${seq} (${frag.type}) of ${path}: ${err}${c.reset}`);
555
679
  }
556
680
  }
681
+
682
+ // Embed-state completion: mark synced ONLY when the WHOLE document succeeded (no
683
+ // failed fragments) — a partial embed must not be silently permanent. Any failure
684
+ // → 'failed' (state-only; attempts already incremented at markEmbedStart) so the
685
+ // worklist retries it, bounded by embed_attempts < 3.
686
+ const docFragsFail = failedFragments - prevFailedFragments;
687
+ if (seq0Succeeded && docFragsFail === 0) {
688
+ s.markEmbedSynced(hash);
689
+ } else if (!seq0Succeeded) {
690
+ s.markEmbedFailed(hash, "primary fragment (seq=0) failed");
691
+ } else {
692
+ s.markEmbedFailed(hash, `${docFragsFail} fragment(s) failed`);
693
+ }
694
+
695
+ embedded++;
696
+ const docMs = Date.now() - docStart;
697
+ const elapsed = ((Date.now() - batchStart) / 1000).toFixed(0);
698
+ console.error(` → doc done in ${(docMs / 1000).toFixed(1)}s | ${embedded}/${hashes.length} docs, ${totalFragments} frags, ${failedFragments} fails [${elapsed}s elapsed]`);
557
699
  }
558
700
 
559
- // Track embed state per document — seq=0 (primary) must succeed for synced status
560
- const docFragsOk = totalFragments - prevTotalFragments;
561
- const docFragsFail = failedFragments - prevFailedFragments;
562
- if (seq0Succeeded) {
563
- s.markEmbedSynced(hash);
564
- } else if (docFragsOk === 0 && docFragsFail > 0) {
565
- s.markEmbedFailed(hash, "all fragments failed");
701
+ const totalSec = ((Date.now() - batchStart) / 1000).toFixed(1);
702
+ console.log();
703
+ console.log(`${c.green}Embedded ${embedded} documents (${totalFragments} fragments, ${failedFragments} failed) in ${totalSec}s${c.reset}`);
704
+ } catch (err) {
705
+ // Fatal aborts must NOT exit 0 — otherwise the embed timer / `update --embed`
706
+ // cannot tell the run was incomplete. Set a nonzero exit code (cleanup still
707
+ // runs in finally). Non-fatal errors propagate unchanged.
708
+ if (err instanceof EmbedLeaseLostError) {
709
+ // Checked before FatalVectorError because EmbedLeaseLostError extends it.
710
+ console.error(`${c.red}Embed aborted: lost the embedding lease (another embed process took over). Re-run 'clawmem embed'.${c.reset}`);
711
+ process.exitCode = 1;
712
+ } else if (err instanceof FatalVectorError) {
713
+ console.error(`${c.red}Embed aborted: ${(err as Error).message}${c.reset}`);
714
+ process.exitCode = 1;
566
715
  } else {
567
- // seq=0 failed but some later fragments succeeded — mark failed so seq=0 gets retried
568
- s.markEmbedFailed(hash, "primary fragment (seq=0) failed");
716
+ throw err;
569
717
  }
570
-
571
- embedded++;
572
- const docMs = Date.now() - docStart;
573
- const elapsed = ((Date.now() - batchStart) / 1000).toFixed(0);
574
- console.error(` → doc done in ${(docMs / 1000).toFixed(1)}s | ${embedded}/${hashes.length} docs, ${totalFragments} frags, ${failedFragments} fails [${elapsed}s elapsed]`);
718
+ } finally {
719
+ clearInterval(heartbeat);
720
+ releaseWorkerLease(s, LEASE_NAME, leaseToken);
721
+ await disposeDefaultLlamaCpp();
575
722
  }
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
723
  }
583
724
 
584
725
  async function cmdStatus() {
@@ -799,19 +940,13 @@ async function cmdQuery(args: string[]) {
799
940
  const secondScore = ftsResults[1]?.score ?? 0;
800
941
  const strongSignal = topScore >= 0.85 && (topScore - secondScore) >= 0.15;
801
942
 
802
- // Step 2: Query expansion (skip if strong BM25 signal)
803
- let expandedQueries: { type: string; text: string }[] = [];
943
+ // Step 2: Query expansion (skip if strong BM25 signal). expandQuery now returns
944
+ // typed ExpandedQuery[] (lex/vec/hyde) no more brittle string re-parsing, and
945
+ // the original query is no longer echoed back as a phantom "vec" expansion.
946
+ let expandedQueries: ExpandedQuery[] = [];
804
947
  if (!strongSignal) {
805
948
  try {
806
- const expanded = await s.expandQuery(query, DEFAULT_QUERY_MODEL);
807
- expandedQueries = expanded.map(text => {
808
- // Parse "type: text" format from expansion
809
- const colonIdx = text.indexOf(": ");
810
- if (colonIdx > 0 && colonIdx < 5) {
811
- return { type: text.slice(0, colonIdx), text: text.slice(colonIdx + 2) };
812
- }
813
- return { type: "vec", text };
814
- });
949
+ expandedQueries = await s.expandQuery(query, DEFAULT_QUERY_MODEL);
815
950
  } catch {
816
951
  // Fallback: no expansion
817
952
  }
@@ -819,20 +954,27 @@ async function cmdQuery(args: string[]) {
819
954
 
820
955
  // Step 3: Parallel searches
821
956
  const allRanked: { results: RankedResult[]; weight: number }[] = [];
957
+ // Retain the raw SearchResult from every leg (original + typed expansions) so a
958
+ // candidate found ONLY via an expansion leg survives Step 8's resultMap lookup.
959
+ const candidateResults: SearchResult[] = [];
822
960
 
823
961
  // Original query BM25 + vec (weight 2x)
824
962
  allRanked.push({ results: ftsResults.map(toRanked), weight: 2 });
963
+ candidateResults.push(...ftsResults);
825
964
  const vecResults = await s.searchVec(query, DEFAULT_EMBED_MODEL, 20);
826
965
  allRanked.push({ results: vecResults.map(toRanked), weight: 2 });
966
+ candidateResults.push(...vecResults);
827
967
 
828
- // Expanded queries (weight 1x)
968
+ // Expanded queries (weight 1x): lex → FTS, vec/hyde → vector
829
969
  for (const eq of expandedQueries) {
830
970
  if (eq.type === "lex") {
831
- const r = s.searchFTS(eq.text, 20);
971
+ const r = s.searchFTS(eq.query, 20);
832
972
  allRanked.push({ results: r.map(toRanked), weight: 1 });
973
+ candidateResults.push(...r);
833
974
  } else {
834
- const r = await s.searchVec(eq.text, DEFAULT_EMBED_MODEL, 20);
975
+ const r = await s.searchVec(eq.query, DEFAULT_EMBED_MODEL, 20);
835
976
  allRanked.push({ results: r.map(toRanked), weight: 1 });
977
+ candidateResults.push(...r);
836
978
  }
837
979
  }
838
980
 
@@ -869,9 +1011,10 @@ async function cmdQuery(args: string[]) {
869
1011
  });
870
1012
  blended.sort((a, b) => b.score - a.score);
871
1013
 
872
- // Step 8: Map back to full results and apply composite scoring
1014
+ // Step 8: Map back to full results and apply composite scoring. Build the map from
1015
+ // ALL legs (incl. typed expansions) so expansion-only candidates aren't dropped.
873
1016
  const resultMap = new Map(
874
- [...ftsResults, ...vecResults].map(r => [r.filepath, r])
1017
+ candidateResults.map(r => [r.filepath, r])
875
1018
  );
876
1019
  const fullResults = blended
877
1020
  .map(b => resultMap.get(b.file))
@@ -1891,13 +2034,34 @@ async function cmdDoctor() {
1891
2034
  const s = getStore();
1892
2035
  const needsEmbed = s.getHashesNeedingEmbedding();
1893
2036
  const hasVectors = !!s.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'").get();
1894
- if (hasVectors) {
1895
- console.log(`${c.green}✓${c.reset} Vector index: exists (${needsEmbed} need embedding)`);
1896
- } else {
2037
+ // ALWAYS run the consistency check — the WORST desync (content_vectors rows but
2038
+ // vectors_vec entirely absent) lives in the no-table case, so it must not be
2039
+ // skipped. set-diff, NOT counts (one missing + one orphan cancel in a count check).
2040
+ // Pending docs are reported separately — they are in neither table, not a desync.
2041
+ const vc = s.getVectorConsistency();
2042
+ if (!hasVectors && vc.cvCount === 0) {
1897
2043
  console.log(`${c.yellow}!${c.reset} Vector index: not created yet (run 'clawmem embed')`);
2044
+ } else if (!hasVectors) {
2045
+ 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.`);
2046
+ issues++;
2047
+ } else {
2048
+ console.log(`${c.green}✓${c.reset} Vector index: exists (${needsEmbed} need embedding)`);
2049
+ if (vc.cvMissingVv > 0 || vc.vvOrphan > 0) {
2050
+ 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.`);
2051
+ issues++;
2052
+ } else {
2053
+ console.log(`${c.green}✓${c.reset} Vector consistency: ${vc.vvCount} vectors match ${vc.cvCount} metadata rows (${vc.pending} pending)`);
2054
+ }
1898
2055
  }
1899
- } catch {
1900
- console.log(`${c.yellow}!${c.reset} Vector index: could not check`);
2056
+ // Mixed embedding models = a heterogeneous vector space (cosine across different
2057
+ // models is meaningless) even when keys/dimensions are consistent. Flag it.
2058
+ const vecModels = s.getVecModels();
2059
+ if (vecModels.length > 1) {
2060
+ 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.`);
2061
+ issues++;
2062
+ }
2063
+ } catch (err) {
2064
+ console.log(`${c.yellow}!${c.reset} Vector index: could not check (${(err as Error).message})`);
1901
2065
  }
1902
2066
 
1903
2067
  // 4. Content types
@@ -264,8 +264,14 @@ export async function contextSurfacing(
264
264
  const seen = new Set(results.map(r => r.filepath));
265
265
  for (const eq of expanded.slice(0, 3)) {
266
266
  if (Date.now() - startTime > 6000) break; // hard stop at 6s
267
- const ftsExp = store.searchFTS(eq, 5);
268
- for (const r of ftsExp) {
267
+ // Typed routing: lex → FTS; vec/hyde → vector (deep profile + time budget only).
268
+ let hits: SearchResult[] = [];
269
+ if (eq.type === 'lex') {
270
+ hits = store.searchFTS(eq.query, 5);
271
+ } else if (profile.useVector) {
272
+ try { hits = await store.searchVec(eq.query, DEFAULT_EMBED_MODEL, 5); } catch { /* vector leg non-fatal */ }
273
+ }
274
+ for (const r of hits) {
269
275
  if (!seen.has(r.filepath)) {
270
276
  seen.add(r.filepath);
271
277
  results.push(r);