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/AGENTS.md +1 -1
- package/README.md +3 -0
- package/SKILL.md +1 -1
- package/docs/concepts/composite-scoring.md +3 -1
- package/docs/guides/inference-services.md +11 -2
- package/docs/guides/upgrading.md +21 -3
- package/docs/quickstart.md +1 -1
- package/docs/reference/cli.md +5 -3
- package/docs/reference/configuration.md +1 -0
- package/docs/reference/mcp-tools.md +10 -0
- package/docs/troubleshooting.md +7 -4
- package/package.json +1 -1
- package/src/busy-retry.ts +34 -0
- package/src/canary.ts +364 -0
- package/src/clawmem.ts +271 -22
- package/src/config.ts +22 -1
- package/src/graph-traversal.ts +32 -4
- package/src/mcp.ts +190 -64
- package/src/memory.ts +1 -0
- package/src/store.ts +366 -32
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: {
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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
|
|
package/src/graph-traversal.ts
CHANGED
|
@@ -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 =
|
|
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;
|