clawmem 0.20.2 → 0.21.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/src/clawmem.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  import { parseArgs } from "util";
7
7
  import { existsSync, mkdirSync, readFileSync } from "fs";
8
8
  import { resolve as pathResolve, basename } from "path";
9
+ import { createHash } from "crypto";
10
+ import { runCanaryBattery, canaryProbeInputs, cosineSim, CANARY_DRIFT_FLOOR, runSampledVectorValidation, canaryGate, persistCanaryBaselineIfFirst, type CanaryCheckResult } from "./canary.ts";
11
+ import { retryOnBusyAsync, isSqliteBusyError } from "./busy-retry.ts";
9
12
  import {
10
13
  createStore,
11
14
  prewarmVectors,
@@ -390,14 +393,37 @@ async function cmdMine(args: string[]) {
390
393
  }
391
394
  }
392
395
 
396
+ // SQLITE_BUSY retry helper lives in busy-retry.ts (testable — importing THIS module runs
397
+ // the CLI). Console reporting is injected here so the helper stays I/O-free.
398
+ const retryOnBusy = <T,>(fn: () => T, label: string, isLeaseLost: () => boolean): Promise<T> =>
399
+ retryOnBusyAsync(fn, label, isLeaseLost, {
400
+ onRetry: (l, attempt, delayMs) =>
401
+ console.error(`${c.yellow} ${l}: database busy — retrying in ${delayMs / 1000}s (${attempt}/3)${c.reset}`),
402
+ });
403
+
393
404
  async function cmdEmbed(args: string[]) {
394
405
  const { values } = parseArgs({
395
406
  args,
396
- options: { force: { type: "boolean", short: "f", default: false } },
407
+ options: {
408
+ force: { type: "boolean", short: "f", default: false },
409
+ // Escape hatch for the geometry-canary preflight ((d).1): a failing battery
410
+ // aborts the run BEFORE any destructive step unless this is passed.
411
+ "force-geometry": { type: "boolean", default: false },
412
+ // Explicit baseline replacement (T8-M3): baselines are first-healthy calibrations
413
+ // and never roll on their own — this is the intentional recalibration operation.
414
+ "recalibrate-canary": { type: "boolean", default: false },
415
+ },
397
416
  allowPositionals: false,
398
417
  });
399
418
 
400
419
  const s = getStore();
420
+ // Embed runs race live hook/watcher writers. Set the operational busy timeout on the
421
+ // ACTIVE connection — `update --embed` constructs and caches the store before invoking
422
+ // this command, so a getStore()-time option could not cover it ((f).3 / T2-M1). Kept at
423
+ // 10s: a synchronous busy wait blocks the event loop, and the lease heartbeat below
424
+ // fires every 30s against a 60s TTL — waits must stay well under the renewal margin
425
+ // ((f).1 / T2-H4). Recovery beyond 10s is the ASYNC bounded retry, not a longer block.
426
+ s.db.exec(`PRAGMA busy_timeout = 10000`);
401
427
 
402
428
  // Embedding lease: serialize embed commands (manual / embed timer / update --embed)
403
429
  // so two embeds cannot run at once. It is RENEWABLE (token-fenced heartbeat), not a
@@ -422,6 +448,17 @@ async function cmdEmbed(args: string[]) {
422
448
  if (!renewWorkerLease(s, LEASE_NAME, leaseToken, LEASE_TTL_MS)) leaseLost = true;
423
449
  }, Math.floor(LEASE_TTL_MS / 2));
424
450
 
451
+ // Run-level state-marker wrapper ((f).4 / T9-M4): busy-retried, lease-loss aborts, and
452
+ // a marker that STILL fails logs-and-continues — bookkeeping must never kill the run.
453
+ const markSafeGlobal = async (label: string, fn: () => void) => {
454
+ try { await retryOnBusy(fn, label, () => leaseLost); }
455
+ catch (err) {
456
+ if (err instanceof FatalVectorError) throw err; // incl. EmbedLeaseLostError
457
+ if (!isSqliteBusyError(err)) throw err;
458
+ console.error(`${c.yellow}Warning: ${label} still busy after retries — continuing${c.reset}`);
459
+ }
460
+ };
461
+
425
462
  try {
426
463
  const embedUrl = process.env.CLAWMEM_EMBED_URL;
427
464
  if (embedUrl) {
@@ -432,6 +469,85 @@ async function cmdEmbed(args: string[]) {
432
469
  }
433
470
  const llm = getDefaultLlamaCpp();
434
471
 
472
+ // Geometry-canary PREFLIGHT ((d).1 / T3-H1): gate BEFORE any destructive step — a
473
+ // broken-geometry server must fail the run before clearAllEmbeddings, not after 60k
474
+ // writes. Runs on EVERY embed entry, including runs that turn out to be no-work
475
+ // (a healthy-looking idle run still validates the server). FAIL-CLOSED for --force
476
+ // (T8-H1): a destructive clear must never proceed on an UNVALIDATED endpoint — the
477
+ // dimension probe alone can pass a flaky server far enough to destroy the old index.
478
+ // Recalibration (T9-M3) evaluates INTRINSIC sanity only — the old baseline is exactly
479
+ // what a recalibration replaces — and requires --force: the baseline must describe the
480
+ // geometry the WHOLE vault was embedded with, which only a full rebuild guarantees.
481
+ const recalibrate = !!values["recalibrate-canary"];
482
+ if (recalibrate && !values.force) {
483
+ console.error(`${c.red}--recalibrate-canary requires --force: the new baseline must correspond to a full rebuild under the new geometry.${c.reset}`);
484
+ process.exitCode = 1;
485
+ return;
486
+ }
487
+ let canaryState: CanaryCheckResult | null = null;
488
+ {
489
+ const outcome = await runCanaryBattery(t => llm.embed(t), key => s.getCanaryBaseline(key), { ignoreBaseline: recalibrate });
490
+ if (!("unavailable" in outcome)) canaryState = outcome;
491
+ const gate = canaryGate(outcome, { force: !!values.force, forceGeometry: !!values["force-geometry"] });
492
+ if (gate.action === "abort") {
493
+ console.error(`${c.red}Embed aborted: ${gate.reason}${c.reset}`);
494
+ if (canaryState) {
495
+ for (const f of canaryState.failures) console.error(` ${c.red}${f}${c.reset}`);
496
+ console.error(`${c.red}The server is producing non-discriminating or drifted vectors (pooling / EOS-anchor / quant misconfiguration?). Nothing was cleared or written. Fix the serving stack (see docs/troubleshooting.md → "Vector search returns weak or irrelevant results"), or override with --force-geometry.${c.reset}`);
497
+ }
498
+ process.exitCode = 1;
499
+ return;
500
+ }
501
+ if (gate.action === "warn") {
502
+ console.error(`${c.yellow}Geometry canary: ${gate.reason}${c.reset}`);
503
+ if (canaryState) for (const f of canaryState.failures) console.error(` ${c.yellow}${f}${c.reset}`);
504
+ }
505
+ }
506
+
507
+ // Shared end-of-run finalization (T9-H1 + T10-M1): EVERY exit path that reaches a
508
+ // completed run — including the no-work early return — verifies the end state.
509
+ // Absent preflight vectors (canary unavailable, run proceeded) → persistent
510
+ // unverified taint + nonzero; baseline persist/recalibration happens ONLY after a
511
+ // successful same-dimension end probe. "Not verified" is never success (T8-M2).
512
+ const finalizeCanary = async (failedFragmentsCount: number) => {
513
+ const setTaint = (reason: string) =>
514
+ markSafeGlobal("setVaultFlag(taint)", () => s.setVaultFlag("embed_geometry_taint", reason, leaseGuard));
515
+ if (!canaryState) {
516
+ console.error(`${c.red}WARNING: this run had NO validated preflight geometry (canary unavailable). The vault state is UNVERIFIED — run 'clawmem embed --force' against a validated server to clear.${c.reset}`);
517
+ await setTaint(`no preflight validation at ${new Date().toISOString()}`);
518
+ process.exitCode = 1;
519
+ return;
520
+ }
521
+ let endDrift: number | null = null;
522
+ try {
523
+ const fresh = await llm.embed(canaryProbeInputs().get("rel_a")!);
524
+ const pre = canaryState.vectors.get("rel_a")!;
525
+ if (fresh && fresh.embedding.length === pre.length) {
526
+ endDrift = cosineSim(pre, fresh.embedding instanceof Float32Array ? fresh.embedding : new Float32Array(fresh.embedding));
527
+ }
528
+ } catch { /* endpoint gone at the very end — endDrift stays null → unverified */ }
529
+ if (endDrift === null) {
530
+ console.error(`${c.red}WARNING: end-of-run geometry verification FAILED (endpoint unreachable or dimension changed). This run is UNVERIFIED — treat the vault state as suspect. Re-run 'clawmem embed' once the server is stable.${c.reset}`);
531
+ await setTaint(`unverified end-of-run at ${new Date().toISOString()}`);
532
+ process.exitCode = 1;
533
+ } else if (endDrift < CANARY_DRIFT_FLOOR) {
534
+ console.error(`${c.red}WARNING: embedding-server geometry DRIFTED mid-run (probe self-sim ${endDrift.toFixed(4)} < ${CANARY_DRIFT_FLOOR}). This rebuild is TAINTED — the vault mixes two geometries. Stabilize the server, then run 'clawmem embed --force'.${c.reset}`);
535
+ await setTaint(`mid-run drift ${endDrift.toFixed(4)} at ${new Date().toISOString()}`);
536
+ process.exitCode = 1;
537
+ } else {
538
+ // Verified end. A FULL verified rebuild (--force) clears any standing taint —
539
+ // the mixed-geometry state the flag records has been rebuilt away (T8-M1).
540
+ // leaseLost is re-checked first: a reclaimed holder must not clear a
541
+ // successor's taint (T9-M4).
542
+ if (values.force && failedFragmentsCount === 0 && !leaseLost) {
543
+ await markSafeGlobal("clearVaultFlag(taint)", () => s.clearVaultFlag("embed_geometry_taint", leaseGuard));
544
+ }
545
+ if (canaryState.pass) {
546
+ persistCanaryBaselineIfFirst(s, canaryState, { recalibrate, leaseGuard });
547
+ }
548
+ }
549
+ };
550
+
435
551
  // Probe the live model's output dimension (+ model name). Returns null on ANY
436
552
  // failure so a down/flaky endpoint can NEVER trigger a destructive clear.
437
553
  const probeEmbed = async (): Promise<{ dim: number; model: string } | null> => {
@@ -488,16 +604,33 @@ async function cmdEmbed(args: string[]) {
488
604
  }
489
605
  }
490
606
 
491
- // Clean stale embeddings (orphaned hashes from updated/deleted documents)
492
- const cleaned = s.cleanStaleEmbeddings(leaseGuard);
493
- if (cleaned > 0) {
494
- console.log(`${c.yellow}Cleaned ${cleaned} stale embedding(s) from orphaned documents${c.reset}`);
607
+ // Clean stale embeddings (orphaned hashes from updated/deleted documents).
608
+ // SKIPPED under --force ((f).7 / T2-H3): clearAllEmbeddings just emptied
609
+ // content_vectors, so no orphaned hashes can exist — running it only risks a
610
+ // SQLITE_BUSY dying AFTER the clear with the index freshly emptied.
611
+ if (!values.force) {
612
+ try {
613
+ const cleaned = await retryOnBusy(() => s.cleanStaleEmbeddings(leaseGuard), "cleanStaleEmbeddings", () => leaseLost);
614
+ if (cleaned > 0) {
615
+ console.log(`${c.yellow}Cleaned ${cleaned} stale embedding(s) from orphaned documents${c.reset}`);
616
+ }
617
+ } catch (err) {
618
+ if (err instanceof FatalVectorError) throw err; // incl. EmbedLeaseLostError
619
+ if (!isSqliteBusyError(err)) throw err;
620
+ // Busy after all retries: stale rows are inert (hydration JOINs active docs only) —
621
+ // log and continue; the next sweep retries.
622
+ console.error(`${c.yellow}Warning: stale-embedding cleanup still busy after retries — continuing${c.reset}`);
623
+ }
495
624
  }
496
625
 
497
626
  // Use fragment-based pipeline: split documents into semantic fragments and embed each
498
627
  const hashes = s.getHashesNeedingFragments();
499
628
  if (hashes.length === 0) {
629
+ // No-work run: routes through the SAME finalization as a working run (T10-M1) —
630
+ // it must not skip end verification, silently exit zero on an unvalidated
631
+ // endpoint, or persist/recalibrate a baseline without a verified end.
500
632
  console.log(`${c.green}All documents already embedded${c.reset}`);
633
+ await finalizeCanary(0);
501
634
  return;
502
635
  }
503
636
 
@@ -573,7 +706,9 @@ async function cmdEmbed(args: string[]) {
573
706
  // Mark the doc 'pending' and increment embed_attempts ONCE before its first
574
707
  // fragment, so a crash mid-document leaves it retryable (re-selected by
575
708
  // getHashesNeedingFragments). Completion setters below are state-only.
576
- s.markEmbedStart(hash);
709
+ // A state MARKER must never kill the run ((f).4) — markSafeGlobal above.
710
+ const markSafe = markSafeGlobal;
711
+ await markSafe("markEmbedStart", () => s.markEmbedStart(hash, leaseGuard));
577
712
  console.error(` [${docIdx + 1}/${hashes.length}] ${basename(path)} (${fragments.length} frags, ${body.length} chars)`);
578
713
 
579
714
  if (isCloudEmbed) {
@@ -625,12 +760,19 @@ async function cmdEmbed(args: string[]) {
625
760
  const result = results[i];
626
761
  if (result) {
627
762
  bindAndValidate(result);
628
- s.ensureVecTable(result.embedding.length, leaseGuard);
629
- s.insertEmbedding(
630
- hash, seq, frag.startLine, new Float32Array(result.embedding),
631
- result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
632
- leaseGuard
633
- );
763
+ // Embed-input fingerprint ((d).4 / T5-L1): SHA-256 over the UTF-8 bytes of
764
+ // the exact formatted embed input, written in the same atomic transaction.
765
+ const embedInputFp = createHash("sha256").update(allTexts[seq]!, "utf8").digest("hex");
766
+ // SQLITE_BUSY-only async retry ((f).2/6): busy exhaustion throws to the
767
+ // batch catch (fragment failure); FatalVectorError passes through untouched.
768
+ await retryOnBusy(() => {
769
+ s.ensureVecTable(result.embedding.length, leaseGuard);
770
+ s.insertEmbedding(
771
+ hash, seq, frag.startLine, new Float32Array(result.embedding),
772
+ result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
773
+ leaseGuard, embedInputFp
774
+ );
775
+ }, "insertEmbedding", () => leaseLost);
634
776
  totalFragments++;
635
777
  if (seq === 0) seq0Succeeded = true;
636
778
  } else {
@@ -660,12 +802,19 @@ async function cmdEmbed(args: string[]) {
660
802
  const fragMs = Date.now() - fragStart;
661
803
  if (result) {
662
804
  bindAndValidate(result);
663
- s.ensureVecTable(result.embedding.length, leaseGuard);
664
- s.insertEmbedding(
665
- hash, seq, frag.startLine, new Float32Array(result.embedding),
666
- result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
667
- leaseGuard
668
- );
805
+ // Embed-input fingerprint ((d).4 / T5-L1): SHA-256 over the UTF-8 bytes of
806
+ // the exact formatted embed input, written in the same atomic transaction.
807
+ const embedInputFp = createHash("sha256").update(text, "utf8").digest("hex");
808
+ // SQLITE_BUSY-only async retry ((f).2/6): busy exhaustion throws to the
809
+ // per-fragment catch (fragment failure); FatalVectorError passes through.
810
+ await retryOnBusy(() => {
811
+ s.ensureVecTable(result.embedding.length, leaseGuard);
812
+ s.insertEmbedding(
813
+ hash, seq, frag.startLine, new Float32Array(result.embedding),
814
+ result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
815
+ leaseGuard, embedInputFp
816
+ );
817
+ }, "insertEmbedding", () => leaseLost);
669
818
  totalFragments++;
670
819
  if (seq === 0) seq0Succeeded = true;
671
820
  if (seq === 0 || (seq + 1) % 5 === 0 || seq === fragments.length - 1) {
@@ -686,14 +835,17 @@ async function cmdEmbed(args: string[]) {
686
835
  // Embed-state completion: mark synced ONLY when the WHOLE document succeeded (no
687
836
  // failed fragments) — a partial embed must not be silently permanent. Any failure
688
837
  // → 'failed' (state-only; attempts already incremented at markEmbedStart) so the
689
- // worklist retries it, bounded by embed_attempts < 3.
838
+ // worklist retries it, bounded by embed_attempts < 3. Markers are lease-fenced and
839
+ // busy-retried; a marker that STILL fails logs-and-continues — it must never kill
840
+ // the run (the 2026-07-10 incident: markEmbedFailed's own SQLITE_BUSY crashed a
841
+ // force rebuild at doc 344/4,995 with the index already cleared).
690
842
  const docFragsFail = failedFragments - prevFailedFragments;
691
843
  if (seq0Succeeded && docFragsFail === 0) {
692
- s.markEmbedSynced(hash);
844
+ await markSafe("markEmbedSynced", () => s.markEmbedSynced(hash, leaseGuard));
693
845
  } else if (!seq0Succeeded) {
694
- s.markEmbedFailed(hash, "primary fragment (seq=0) failed");
846
+ await markSafe("markEmbedFailed", () => s.markEmbedFailed(hash, "primary fragment (seq=0) failed", leaseGuard));
695
847
  } else {
696
- s.markEmbedFailed(hash, `${docFragsFail} fragment(s) failed`);
848
+ await markSafe("markEmbedFailed", () => s.markEmbedFailed(hash, `${docFragsFail} fragment(s) failed`, leaseGuard));
697
849
  }
698
850
 
699
851
  embedded++;
@@ -705,6 +857,9 @@ async function cmdEmbed(args: string[]) {
705
857
  const totalSec = ((Date.now() - batchStart) / 1000).toFixed(1);
706
858
  console.log();
707
859
  console.log(`${c.green}Embedded ${embedded} documents (${totalFragments} fragments, ${failedFragments} failed) in ${totalSec}s${c.reset}`);
860
+
861
+ // End-of-run verification — shared finalization (T9-H1 + T10-M1); see finalizeCanary.
862
+ await finalizeCanary(failedFragments);
708
863
  } catch (err) {
709
864
  // Fatal aborts must NOT exit 0 — otherwise the embed timer / `update --embed`
710
865
  // cannot tell the run was incomplete. Set a nonzero exit code (cleanup still
@@ -2248,6 +2403,96 @@ async function cmdDoctor() {
2248
2403
  console.log(`${c.yellow}!${c.reset} Reranker: could not probe (${(err as Error).message})`);
2249
2404
  }
2250
2405
 
2406
+ // 10. Embedding-geometry canary (VSEARCH-TRUST-HARDENING (d)): pair-separation sanity
2407
+ // (catches WRONG-but-stable geometry — the 2026-07-10 class, invisible to
2408
+ // self-similarity) + drift vs the stored baseline (catches a changed serving stack —
2409
+ // the 2026-06-22 class — behind an unchanged model name). For a vault WITH vectors
2410
+ // this is a REQUIRED check: unavailability is incomplete/nonzero, not green (T8-M6).
2411
+ // A persisted taint flag from a bad rebuild stays red until a verified full rebuild
2412
+ // clears it (T8-M1).
2413
+ {
2414
+ const s = getStore();
2415
+ const vaultHasVectors = !!s.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get()
2416
+ && ((s.db.prepare(`SELECT count(*) as cnt FROM vectors_vec`).get() as { cnt: number })?.cnt ?? 0) > 0;
2417
+ const taint = s.getVaultFlag("embed_geometry_taint");
2418
+ if (taint) {
2419
+ console.log(`${c.red}✗${c.reset} Geometry taint: a prior embed run was tainted/unverified (${taint}) — the vault may mix two geometries. Run 'clawmem embed --force' against a stable server to clear.`);
2420
+ issues++;
2421
+ process.exitCode = 1;
2422
+ }
2423
+ try {
2424
+ const llm = getDefaultLlamaCpp();
2425
+ const outcome = await runCanaryBattery(t => llm.embed(t), key => s.getCanaryBaseline(key));
2426
+ if ("unavailable" in outcome) {
2427
+ if (vaultHasVectors) {
2428
+ console.log(`${c.red}✗${c.reset} Geometry canary: INCOMPLETE — required check could not run (${outcome.reason}). A vector vault without a validated server is unverified, not healthy.`);
2429
+ issues++;
2430
+ process.exitCode = 1;
2431
+ } else {
2432
+ console.log(`${c.yellow}!${c.reset} Geometry canary: skipped (${outcome.reason}; vault has no vectors)`);
2433
+ }
2434
+ } else if (outcome.pass) {
2435
+ const m = outcome.margins;
2436
+ console.log(`${c.green}✓${c.reset} Geometry canary: separation healthy (rel ${m.m_rel!.toFixed(2)}, echo ${m.m_echo!.toFixed(2)}, term ${m.m_term!.toFixed(2)}, trunc ${m.m_trunc!.toFixed(2)})${outcome.driftChecked ? " · drift vs baseline OK" : " · no baseline yet (absolute floors)"}`);
2437
+ } else {
2438
+ console.log(`${c.red}✗${c.reset} Geometry canary: FAILED — embedding server produces non-discriminating or drifted vectors`);
2439
+ for (const f of outcome.failures.slice(0, 6)) console.log(` ${c.dim}${f}${c.reset}`);
2440
+ console.log(` ${c.dim}Pooling / EOS-anchor / quant misconfiguration, or the server changed since the last embed. See docs/troubleshooting.md → "Vector search returns weak or irrelevant results". A geometry change requires 'clawmem embed --force'.${c.reset}`);
2441
+ issues++;
2442
+ process.exitCode = 1;
2443
+ }
2444
+ } catch (err) {
2445
+ if (vaultHasVectors) {
2446
+ console.log(`${c.red}✗${c.reset} Geometry canary: INCOMPLETE — required check errored (${(err as Error).message})`);
2447
+ issues++;
2448
+ process.exitCode = 1;
2449
+ } else {
2450
+ console.log(`${c.yellow}!${c.reset} Geometry canary: could not run (${(err as Error).message})`);
2451
+ }
2452
+ }
2453
+ }
2454
+
2455
+ // 11. Sampled vector validation ((d).4): persisted-vs-fresh on REAL vectors_vec rows,
2456
+ // reconstructed through the production pipeline from the CANONICAL document
2457
+ // (T8-H3). Definitive fingerprint failures return immediately and are nonzero
2458
+ // regardless of coverage (T6-M2/T8-H2); attempts are hard-capped. Required check
2459
+ // for a vector vault — exceptions are incomplete/nonzero, not green (T8-M6).
2460
+ try {
2461
+ const s = getStore();
2462
+ const llm = getDefaultLlamaCpp();
2463
+ const summary = await runSampledVectorValidation(s, (t: string) => llm.embed(t));
2464
+ if (summary.eligible === 0) {
2465
+ console.log(`${c.yellow}!${c.reset} Sampled vectors: no eligible rows (no synced embedded documents)`);
2466
+ } else if (summary.definitiveFailures.length > 0) {
2467
+ console.log(`${c.red}✗${c.reset} Sampled vectors: DEFINITIVE failure after ${summary.attempts} attempt(s) (${summary.validated}/${summary.target} validated before stopping, ${summary.eligible} eligible)`);
2468
+ for (const f of summary.definitiveFailures.slice(0, 4)) console.log(` ${c.dim}${f}${c.reset}`);
2469
+ console.log(` ${c.dim}Stale-input rows need a re-embed; corruption/drift at matching fingerprints means the stored vector no longer matches its exact input.${c.reset}`);
2470
+ issues++;
2471
+ process.exitCode = 1;
2472
+ } else if (summary.validated < summary.nMin || summary.validatedSeq0 < summary.seq0Target) {
2473
+ const seq0Part = summary.validatedSeq0 < summary.seq0Target ? `; seq-0 quota UNMET (${summary.validatedSeq0}/${summary.seq0Target} validated — primary fragments are the surprisal/graph/health anchors)` : "";
2474
+ console.log(`${c.red}✗${c.reset} Sampled vectors: DEGRADED — validation could not complete (${summary.validated}/${summary.target} validated, min ${summary.nMin}${seq0Part}; ${summary.unreconstructable} unreconstructable, ${summary.inconclusiveLegacy} legacy-inconclusive; ${summary.attempts} attempts over ${summary.eligible} eligible)`);
2475
+ console.log(` ${c.dim}Splitter/metadata drift or legacy rows below threshold — re-embed or investigate.${c.reset}`);
2476
+ issues++;
2477
+ process.exitCode = 1;
2478
+ } else {
2479
+ const legacyNote = summary.legacyTier > 0 ? ` (${summary.legacyTier} legacy-tier: structural only — title provenance unavailable until re-embed)` : "";
2480
+ const seq0Note = summary.validatedSeq0 > 0 ? `, ${summary.validatedSeq0} seq-0` : "";
2481
+ console.log(`${c.green}✓${c.reset} Sampled vectors: ${summary.validated}/${summary.target} validated (cos ≥ 0.98${seq0Note})${legacyNote}`);
2482
+ }
2483
+ } catch (err) {
2484
+ const s = getStore();
2485
+ const vaultHasVectors = !!s.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get()
2486
+ && ((s.db.prepare(`SELECT count(*) as cnt FROM vectors_vec`).get() as { cnt: number })?.cnt ?? 0) > 0;
2487
+ if (vaultHasVectors) {
2488
+ console.log(`${c.red}✗${c.reset} Sampled vectors: INCOMPLETE — required check errored (${(err as Error).message})`);
2489
+ issues++;
2490
+ process.exitCode = 1;
2491
+ } else {
2492
+ console.log(`${c.yellow}!${c.reset} Sampled vectors: could not run (${(err as Error).message})`);
2493
+ }
2494
+ }
2495
+
2251
2496
  console.log();
2252
2497
  if (issues > 0) {
2253
2498
  console.log(`${c.yellow}${issues} issue(s) found.${c.reset}`);
@@ -2256,6 +2501,7 @@ async function cmdDoctor() {
2256
2501
  }
2257
2502
  }
2258
2503
 
2504
+
2259
2505
  // =============================================================================
2260
2506
  // Reranker health (scheduled-check CLI)
2261
2507
  // =============================================================================
@@ -2291,6 +2537,7 @@ async function cmdRerankHealth(args: string[]) {
2291
2537
  process.exitCode = health.ok ? 0 : 1;
2292
2538
  }
2293
2539
 
2540
+
2294
2541
  // =============================================================================
2295
2542
  // Bootstrap
2296
2543
  // =============================================================================
@@ -3326,6 +3573,8 @@ ${c.bold}Options:${c.reset}
3326
3573
  --json JSON output
3327
3574
  --min-score <N> Minimum score threshold
3328
3575
  -f, --force Force re-embed/reindex all
3576
+ --force-geometry Override a failing embed geometry-canary preflight
3577
+ --recalibrate-canary Replace the stored canary baseline (first-healthy otherwise)
3329
3578
  --pull Run update commands before indexing
3330
3579
  `);
3331
3580
  }
package/src/config.ts CHANGED
@@ -52,11 +52,24 @@ export interface LifecyclePolicy {
52
52
  dry_run: boolean;
53
53
  }
54
54
 
55
+ export interface RetrievalConfig {
56
+ /**
57
+ * Option-B knob (VSEARCH-TRUST-HARDENING (a)): when true, the MCP direct tools
58
+ * (search / vsearch / memory_retrieve) score non-recency queries with the
59
+ * retrieval-tuned QUERY_WEIGHTS instead of DEFAULT_WEIGHTS. Default FALSE —
60
+ * the flip is gated on a direct-pipeline eval (the n=199 evidence covered only
61
+ * the hybrid `query` pipeline).
62
+ */
63
+ mcp_direct_tuned_weights: boolean;
64
+ }
65
+
55
66
  export interface ClawMemConfig {
56
67
  /** Named vault paths (empty = single-vault mode) */
57
68
  vaults: VaultConfig;
58
69
  /** Lifecycle management policy */
59
70
  lifecycle?: LifecyclePolicy;
71
+ /** Retrieval behavior knobs */
72
+ retrieval?: RetrievalConfig;
60
73
  }
61
74
 
62
75
  // ---------------------------------------------------------------------------
@@ -173,7 +186,15 @@ export function loadVaultConfig(): ClawMemConfig {
173
186
  };
174
187
  }
175
188
 
176
- _cachedConfig = { vaults, lifecycle };
189
+ // 4. Retrieval knobs (optional). Env override for tests / staged rollout.
190
+ let retrieval: RetrievalConfig | undefined;
191
+ const envTuned = process.env.CLAWMEM_MCP_DIRECT_TUNED_WEIGHTS;
192
+ const yamlTuned = parsedYaml?.retrieval && typeof parsedYaml.retrieval === "object"
193
+ ? parsedYaml.retrieval.mcp_direct_tuned_weights === true
194
+ : false;
195
+ retrieval = { mcp_direct_tuned_weights: envTuned !== undefined ? envTuned === "true" : yamlTuned };
196
+
197
+ _cachedConfig = { vaults, lifecycle, retrieval };
177
198
  return _cachedConfig;
178
199
  }
179
200
 
@@ -19,6 +19,12 @@ export interface TraversalOptions {
19
19
  budget: number; // Max total nodes (20-50)
20
20
  intent: IntentType;
21
21
  queryEmbedding: number[];
22
+ // Visibility exclusion (VSEARCH-TRUST-HARDENING (b).3): excluded-collection neighbors are
23
+ // pruned in the neighbor-selection query itself — BEFORE beam selection and BEFORE merged-
24
+ // score normalization — so internal nodes can neither crowd out nor demote user nodes.
25
+ // Callers with retrieval visibility policy (memory_retrieve causal mode, query_plan graph
26
+ // clause) pass this; substrate-by-design callers (intent_search) do not.
27
+ excludeCollections?: string[];
22
28
  }
23
29
 
24
30
  export interface TraversalNode {
@@ -120,11 +126,31 @@ export function adaptiveTraversal(
120
126
  anchorNodes.push({ docId, score: anchor.score });
121
127
  }
122
128
  }
123
- const { maxDepth, beamWidth, budget, intent, queryEmbedding } = options;
129
+ const { maxDepth, beamWidth, budget, intent, queryEmbedding, excludeCollections } = options;
124
130
 
125
131
  // Intent-specific weights for structural alignment
126
132
  const weights = getIntentWeights(intent);
127
133
 
134
+ // Neighbor-selection query — with the visibility guard IN the SQL when exclusion is active,
135
+ // so excluded nodes never enter `candidates` (no beam consumption, no normalization skew).
136
+ const hasExclusion = !!(excludeCollections && excludeCollections.length > 0);
137
+ const exPlaceholders = hasExclusion ? excludeCollections!.map(() => '?').join(',') : '';
138
+ const neighborStmt = hasExclusion
139
+ ? db.prepare(`
140
+ SELECT r.target_id as docId, r.relation_type, r.weight
141
+ FROM memory_relations r
142
+ JOIN documents d ON d.id = r.target_id
143
+ WHERE r.source_id = ? AND d.collection NOT IN (${exPlaceholders})
144
+
145
+ UNION
146
+
147
+ SELECT r.source_id as docId, r.relation_type, r.weight
148
+ FROM memory_relations r
149
+ JOIN documents d ON d.id = r.source_id
150
+ WHERE r.target_id = ? AND r.relation_type IN ('semantic', 'entity') AND d.collection NOT IN (${exPlaceholders})
151
+ `)
152
+ : null;
153
+
128
154
  const visited = new Map<number, TraversalNode>();
129
155
  let currentFrontier: TraversalNode[] = anchorNodes.map(a => ({
130
156
  docId: a.docId,
@@ -143,8 +169,10 @@ export function adaptiveTraversal(
143
169
  const candidates: TraversalNode[] = [];
144
170
 
145
171
  for (const u of currentFrontier) {
146
- // Get all neighbors via any relation type
147
- const neighbors = db.prepare(`
172
+ // Get all neighbors via any relation type (visibility-guarded variant when exclusion is active)
173
+ const neighbors = (hasExclusion
174
+ ? neighborStmt!.all(u.docId, ...excludeCollections!, u.docId, ...excludeCollections!)
175
+ : db.prepare(`
148
176
  SELECT target_id as docId, relation_type, weight
149
177
  FROM memory_relations
150
178
  WHERE source_id = ?
@@ -154,7 +182,7 @@ export function adaptiveTraversal(
154
182
  SELECT source_id as docId, relation_type, weight
155
183
  FROM memory_relations
156
184
  WHERE target_id = ? AND relation_type IN ('semantic', 'entity')
157
- `).all(u.docId, u.docId) as { docId: number; relation_type: string; weight: number }[];
185
+ `).all(u.docId, u.docId)) as { docId: number; relation_type: string; weight: number }[];
158
186
 
159
187
  for (const neighbor of neighbors) {
160
188
  if (visited.has(neighbor.docId)) continue;