klypix-mcp 1.86.2 → 1.86.3

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/README.md CHANGED
@@ -290,7 +290,7 @@ For a reproducible map artifact on every pull request and main-branch push, inst
290
290
  read-only workflow into a Git checkout:
291
291
 
292
292
  ```bash
293
- npx klypix-project-map setup-github /path/to/project
293
+ npx -y -p klypix-mcp klypix-project-map setup-github /path/to/project
294
294
  ```
295
295
 
296
296
  The command refuses to overwrite an existing workflow unless `--force` is explicit. The installed
@@ -314,7 +314,7 @@ behaviour is unverified.
314
314
  | **VS Code (Copilot / Continue)** | MCP config + instructions file | `link` | Model must call `brain_sync` | Model must call `brain_note` | For the MCP connection |
315
315
  | **Gemini CLI / Antigravity** | MCP config + always-on rules file | `link` | Model must call `brain_sync` | Model must call `brain_note` | For the MCP connection |
316
316
  | **Windsurf** | Rules file only | `link` | Reaches the tools through Windsurf's own global MCP config | Model must call `brain_note` | Via its own MCP config |
317
- | **Aider** | Rules file only (no MCP) | `link` | CLI path: `npx klypix-read` | CLI path: `npx klypix-append` | — |
317
+ | **Aider** | Rules file only (no MCP) | `link` | CLI path: `npx -y -p klypix-mcp klypix-read` | CLI path: `npx -y -p klypix-mcp klypix-append` | — |
318
318
  | **Claude Desktop** | One-time manual config edit | you | Model must call `brain_sync` | Model must call `brain_note` | For the MCP connection |
319
319
 
320
320
  `install` and `link` are different things and are not interchangeable: `install` sets up the
@@ -94,7 +94,9 @@ const runVerb = async (verb, moduleId) => {
94
94
  // into ~/.claude/project-brain and wire the Claude Code hooks. This is the single
95
95
  // agent-neutral installer, so a brain release reaches every machine via one npm
96
96
  // publish + this command (the global brain serves every project). Runs before any
97
- // server setup; delegates to the dedicated bin so `npx klypix-install` also works.
97
+ // server setup; delegates to the dedicated bin so `npx -p klypix-mcp klypix-install`
98
+ // also works (the bin name on its own is not an npm package we own — see
99
+ // test/npx-owned-names.mjs).
98
100
  await runVerb('install', './klypix-install.mjs');
99
101
 
100
102
  // `npx klypix-mcp link` — make THIS project's brain automatic for EVERY agent tool,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.86.2",
3
+ "version": "1.86.3",
4
4
  "mcpName": "io.github.dahshanlabs/klypix-mcp",
5
5
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "bench": "node bin/klypix-mcp.mjs bench",
85
85
  "test:bench": "node test/bench.mjs",
86
86
  "pretest": "node test/publish-workflow.mjs",
87
- "test": "node test/publish-verdict.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/enrichment.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs",
87
+ "test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs &&node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs",
88
88
  "test:memory": "node test/memory-runtime.mjs",
89
89
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
90
90
  "runtime": "node bin/klypix-runtime.mjs",
@@ -444,12 +444,12 @@ survives across sessions, agents, and context resets.
444
444
  - do NOT automatically read the full \`.claude/brain-brief.md\`; open it only when \`brain_sync\`
445
445
  says its compact context is insufficient or the task asks for broad project history/status.
446
446
  - with the \`klypix-canvas\` MCP server: to **answer a question** from the brain ("what did we decide about X?", "where did Y land?"), call \`brain_ask\` — it ranks the whole brain, includes superseded history, and surfaces the current truth for any corrected card. Use \`search_canvases\` for a raw keyword lookup, \`read_canvas\` (canvas: \`"brain"\`) for the whole thing, or \`brain_insights\` for the load-bearing cards.
447
- - or via CLI: \`npx klypix-read brain.klypix\`
447
+ - or via CLI: \`npx -y -p klypix-mcp klypix-read brain.klypix\`
448
448
 
449
449
  **When you make a real decision, finding, or milestone — capture it HERE** so it persists for the next session/agent:
450
450
  - with MCP: call \`brain_note\` with a one-line decision, or
451
451
  - emit a marker line in your output: \`🧠 BRAIN [Area]: <one-line decision>\`, or
452
- - via CLI: \`echo "🧠 BRAIN [Area]: <decision>" | npx klypix-append brain.klypix\`
452
+ - via CLI: \`echo "🧠 BRAIN [Area]: <decision>" | npx -y -p klypix-mcp klypix-append brain.klypix\`
453
453
 
454
454
  Capture **sparingly** — real decisions / milestones / open questions / reusable gotchas, not routine
455
455
  steps — and capture it **at the moment you decide** (a one-line marker inline), not batched or left in
@@ -118,6 +118,21 @@ async function embedQueries(pipe, texts) {
118
118
  // Vectors are unit-normalized, so dot == cosine.
119
119
  export const dot = (a, b) => { let s = 0; const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) s += a[i] * b[i]; return s; };
120
120
 
121
+ // Copy of vectorEntryMatchesText in semantic-memory.mjs, where the rule is
122
+ // explained (this one-shot module cannot import the long-lived runtime). It
123
+ // takes precomputed hashes and references nothing outside itself. Keep the two
124
+ // identical: test/semantic-hash-parity.mjs compares them.
125
+ export function vectorEntryMatchesText(entry, fullHash, truncatedHash) {
126
+ try {
127
+ if (!entry || !entry.v || typeof fullHash !== 'string' || !fullHash) return false;
128
+ if (typeof entry.t === 'string' && entry.t === fullHash) return true;
129
+ if (typeof entry.h !== 'string' || !entry.h) return false;
130
+ if (entry.h === fullHash) return true;
131
+ const truncated = typeof truncatedHash === 'function' ? truncatedHash() : null;
132
+ return typeof truncated === 'string' && truncated !== '' && entry.h === truncated;
133
+ } catch { return false; }
134
+ }
135
+
121
136
  // READ-ONLY card-vector cache — NEVER embeds or writes (embedding all cards in the
122
137
  // per-prompt process is the multi-second stall we forbid). Reuses the SAME warm
123
138
  // cache the MCP host fills. Current workers canonicalize absolute Windows paths
@@ -141,10 +156,23 @@ function readCachedVecs(brainPath, cards) {
141
156
  } catch { /* try next variant */ }
142
157
  }
143
158
  const map = new Map();
144
- // A vector is accepted only for the text it was embedded from (the cache
145
- // stores sha1(text) as `h`) — an edited card must fall back to lexical, never
146
- // pair on a stale embedding (parity with semantic-memory.cachedVectorsForBrain).
147
- if (cache && cache.cards) for (const c of cards) { const e = cache.cards[c.id]; if (e && e.v && (!e.h || e.h === sha1(String(c.text)))) map.set(c.id, e.v); }
159
+ // A vector is accepted only when its entry proves it was embedded from the
160
+ // card's current text: the full-text fingerprint `t`, or for an entry written
161
+ // before `t` existed its `h` against the full or the truncated text (`h` is
162
+ // the hash of the EMBED INPUT: the first 1,500 characters plus enrichment).
163
+ // An edited card must fall back to lexical, never pair on a stale embedding.
164
+ // Same card filter and same acceptance rule as
165
+ // semantic-memory.cachedVectorsForBrain, on ONE cache file: this reader
166
+ // stops at the first variant carrying the current modelKey, the server
167
+ // reader merges every alias file, so the two can differ until a writer run
168
+ // folds the aliases into the canonical file. The entry lookup comes first
169
+ // so a card with no cached vector costs no hash.
170
+ if (cache && cache.cards) for (const c of cards) {
171
+ if (!c || c.type === 'container' || typeof c.text !== 'string' || !c.text.trim()) continue;
172
+ const e = cache.cards[c.id];
173
+ if (!e || !e.v) continue;
174
+ if (vectorEntryMatchesText(e, sha1(c.text), () => (c.text.length > 1500 ? sha1(c.text.slice(0, 1500)) : null))) map.set(c.id, e.v);
175
+ }
148
176
  return map;
149
177
  }
150
178
 
@@ -51,6 +51,7 @@ const counters = {
51
51
  maxQueued: 0,
52
52
  cacheFilesMerged: 0,
53
53
  cacheWrites: 0,
54
+ cacheWriteFailures: 0,
54
55
  cacheLockWaits: 0,
55
56
  cacheLockTimeouts: 0,
56
57
  modelDisposals: 0,
@@ -518,17 +519,77 @@ async function withCrossProcessCacheLock(brainPath, fn) {
518
519
  }
519
520
  }
520
521
 
521
- function writeCacheAtomic(file, cache) {
522
+ // Windows refuses a rename over a destination another process holds open
523
+ // (EPERM, EBUSY or EACCES by configuration), and this file is read by every
524
+ // one-shot hook and every server on the machine. A bounded backoff (five
525
+ // attempts, 300 ms nominal in total; wall time can be longer because of timer
526
+ // granularity) outlasts a reader passing through; any other error, and a
527
+ // holder that stays, still throws. The retry applies in bounded mode only,
528
+ // because only there the lock serialises writers: in legacy mode a delayed
529
+ // rename would let an older snapshot commit after a newer one, so legacy mode
530
+ // makes a single attempt and throws.
531
+ const CACHE_RENAME_RETRYABLE_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
532
+ const CACHE_RENAME_BACKOFF_MS = [20, 40, 80, 160];
533
+
534
+ async function writeCacheAtomic(file, cache) {
522
535
  const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
523
536
  try {
524
537
  fs.writeFileSync(tmp, JSON.stringify(cache));
525
- fs.renameSync(tmp, file);
538
+ for (let attempt = 0; ; attempt++) {
539
+ try { fs.renameSync(tmp, file); break; }
540
+ catch (error) {
541
+ if (!BOUNDED || attempt >= CACHE_RENAME_BACKOFF_MS.length || !CACHE_RENAME_RETRYABLE_CODES.has(error?.code)) throw error;
542
+ await wait(CACHE_RENAME_BACKOFF_MS[attempt]);
543
+ }
544
+ }
526
545
  } finally {
527
546
  try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* cache is best-effort */ }
528
547
  }
529
548
  }
530
549
 
531
- function readCache(brainPath, desiredHashes) {
550
+ // The disk cache is best-effort: a failed write never fails the caller, whose
551
+ // in-memory result is already correct. It is counted, because a write that
552
+ // keeps failing means readers keep seeing the previous file.
553
+ async function persistCache(file, cache) {
554
+ try {
555
+ fs.mkdirSync(EMB_DIR, { recursive: true });
556
+ await writeCacheAtomic(file, cache);
557
+ counters.cacheWrites++;
558
+ } catch { counters.cacheWriteFailures++; }
559
+ }
560
+
561
+ // A cache entry carries two fingerprints: `h` of the EMBED INPUT (the first
562
+ // 1,500 characters plus question enrichment; the writer's re-embed trigger)
563
+ // and `t` of the FULL card text, which is what a reader can recompute. A
564
+ // reader accepts a vector when either one proves it was embedded from the
565
+ // card's current text: `t` matches, or `h` matches the full or the truncated
566
+ // text (entries written before `t` existed, and entries an older writer kept
567
+ // across an edit beyond the cap). Enrichment may have changed since; the
568
+ // vector still pictures the card and the writer refreshes it on its next run.
569
+ // An entry with neither fingerprint cannot be tied to any text and is refused.
570
+ // Takes precomputed hashes and references nothing outside itself because
571
+ // brain-semantic.mjs holds a second copy (the one-shot hook cannot import this
572
+ // module). Keep the two identical: test/semantic-hash-parity.mjs compares them.
573
+ export function vectorEntryMatchesText(entry, fullHash, truncatedHash) {
574
+ try {
575
+ if (!entry || !entry.v || typeof fullHash !== 'string' || !fullHash) return false;
576
+ if (typeof entry.t === 'string' && entry.t === fullHash) return true;
577
+ if (typeof entry.h !== 'string' || !entry.h) return false;
578
+ if (entry.h === fullHash) return true;
579
+ const truncated = typeof truncatedHash === 'function' ? truncatedHash() : null;
580
+ return typeof truncated === 'string' && truncated !== '' && entry.h === truncated;
581
+ } catch { return false; }
582
+ }
583
+
584
+ // `accept(id, entry)` decides which stored entries survive the bounded merge:
585
+ // the writer keeps an entry whose embed input is unchanged, the read-only path
586
+ // applies vectorEntryMatchesText. Entries are merged by reference, so `t`
587
+ // rides along from alias files. `held` is what the merge refused, first copy
588
+ // per id, which is the canonical file's copy when it has one, by design (the
589
+ // canonical file is read first): the writer puts it back when it has to
590
+ // persist without having embedded, so a failed run never removes a vector
591
+ // from disk.
592
+ function readCache(brainPath, accept) {
532
593
  if (!BOUNDED) {
533
594
  const key = String(brainPath).replace(/\\/g, '/');
534
595
  const file = path.join(EMB_DIR, sha1(key) + '.json');
@@ -541,6 +602,7 @@ function readCache(brainPath, desiredHashes) {
541
602
  const candidates = cacheCandidates(brainPath);
542
603
  const file = candidates[0].file;
543
604
  const cache = { v: 2, modelKey: EMBEDDING_CACHE_KEY, cards: {} };
605
+ const held = {};
544
606
  let found = 0;
545
607
  let canonicalValid = false;
546
608
  let canonicalStale = false;
@@ -558,7 +620,11 @@ function readCache(brainPath, desiredHashes) {
558
620
  if (canonical) canonicalValid = true;
559
621
  found++;
560
622
  for (const [id, entry] of Object.entries(parsed.cards)) {
561
- if (!entry?.v || entry.h !== desiredHashes.get(id)) continue;
623
+ if (!entry?.v) continue;
624
+ if (!accept(id, entry)) {
625
+ if (!held[id]) held[id] = entry;
626
+ continue;
627
+ }
562
628
  if (!cache.cards[id]) {
563
629
  cache.cards[id] = entry;
564
630
  if (!canonical) mergedFromAlias = true;
@@ -569,6 +635,7 @@ function readCache(brainPath, desiredHashes) {
569
635
  return {
570
636
  file,
571
637
  cache,
638
+ held,
572
639
  // A stale legacy alias is harmless once the canonical BGE cache is valid.
573
640
  // Rewrite only when the canonical file itself is stale/missing or an alias
574
641
  // contributes a card the canonical cache did not already contain.
@@ -594,14 +661,39 @@ async function vectorsForBrainUnlocked(pipe, brainPath, cards) {
594
661
  ${extra}` : base;
595
662
  };
596
663
  const desiredHashes = new Map(want.map((card) => [card.id, sha1(embedInputFor(card))]));
597
- const loaded = readCache(brainPath, desiredHashes);
664
+ const textHashes = new Map(want.map((card) => [card.id, sha1(card.text)]));
665
+ const loaded = readCache(brainPath, (id, entry) => entry.h === desiredHashes.get(id));
598
666
  const { file, cache } = loaded;
599
667
  let dirty = loaded.dirty;
668
+ // Kept entries written before `t` existed, or kept across an edit beyond the
669
+ // embed cap, get their full-text fingerprint here. Hashing only, never an
670
+ // embed, and only when it differs, so a settled cache is not rewritten. Only
671
+ // an entry whose embed input is unchanged qualifies: the unbounded read keeps
672
+ // stale entries in `cache`, and stamping one would vouch for an old vector.
673
+ let repaired = 0;
674
+ for (const card of want) {
675
+ const entry = cache.cards[card.id];
676
+ if (!entry || entry.h !== desiredHashes.get(card.id)) continue;
677
+ if (entry.t !== textHashes.get(card.id)) { entry.t = textHashes.get(card.id); repaired++; }
678
+ }
679
+ if (repaired) dirty = true;
600
680
  const missing = want.filter((card) => cache.cards[card.id]?.h !== desiredHashes.get(card.id));
601
681
  if (missing.length) {
602
- const vectors = await embedTexts(pipe, missing.map((card) => embedInputFor(card)));
682
+ let vectors;
683
+ try { vectors = await embedTexts(pipe, missing.map((card) => embedInputFor(card))); }
684
+ catch (error) {
685
+ // The repair above needs no model, so it lands even when the embed does
686
+ // not. Everything the merge refused goes back beside it: those vectors
687
+ // are still on disk and a reader may still accept them through `t`.
688
+ // The persisted file is a superset snapshot: it can contain refused
689
+ // entries and entries for deleted cards. Readers reject them through
690
+ // the acceptance rule, and the next write that is dirty for another
691
+ // reason trims them.
692
+ if (repaired) await persistCache(file, { ...cache, cards: { ...loaded.held, ...cache.cards } });
693
+ throw error;
694
+ }
603
695
  missing.forEach((card, index) => {
604
- cache.cards[card.id] = { h: desiredHashes.get(card.id), v: vectors[index] };
696
+ cache.cards[card.id] = { h: desiredHashes.get(card.id), t: textHashes.get(card.id), v: vectors[index] };
605
697
  });
606
698
  dirty = true;
607
699
  }
@@ -609,13 +701,7 @@ ${extra}` : base;
609
701
  for (const id of Object.keys(cache.cards)) {
610
702
  if (!live.has(id)) { delete cache.cards[id]; dirty = true; }
611
703
  }
612
- if (dirty) {
613
- try {
614
- fs.mkdirSync(EMB_DIR, { recursive: true });
615
- writeCacheAtomic(file, cache);
616
- counters.cacheWrites++;
617
- } catch { /* disk cache is best-effort; in-memory result remains correct */ }
618
- }
704
+ if (dirty) await persistCache(file, cache);
619
705
  const map = new Map();
620
706
  for (const card of want) {
621
707
  const entry = cache.cards[card.id];
@@ -628,7 +714,9 @@ ${extra}` : base;
628
714
  // embeds a missing card, never writes. For fast paths (brain_sync task context)
629
715
  // that may USE card↔card similarity when it is already paid for — plan ↔ 🏁
630
716
  // pairing — and must degrade to lexical bars when it is not. Cards whose text
631
- // changed since they were embedded are simply absent from the result.
717
+ // changed since they were embedded are simply absent from the result: a vector
718
+ // is accepted by vectorEntryMatchesText, never by `h` equality, because `h`
719
+ // fingerprints the truncated, enriched embed input a reader cannot recompute.
632
720
  // Single-entry memo for the fast path (review 2026-08-23: brain_sync re-parsed
633
721
  // a ~36 MB cache on every plan-shaped hit). Keyed by the canonical cache file
634
722
  // + mtime + size + the card-hash digest, so a changed card or a rewritten
@@ -639,7 +727,9 @@ let _cachedVecMemo = null;
639
727
  export function cachedVectorsForBrain(brainPath, cards) {
640
728
  const map = new Map();
641
729
  try {
642
- const want = (cards || []).filter((card) => card && card.type !== 'container' && (card.text || '').trim());
730
+ // A card whose text is not a string is skipped, as the hook reader skips
731
+ // it: `.trim()` on a number used to throw and empty the Map for the brain.
732
+ const want = (cards || []).filter((card) => card && card.type !== 'container' && typeof card.text === 'string' && card.text.trim());
643
733
  if (!want.length) return map;
644
734
  const desiredHashes = new Map(want.map((card) => [card.id, sha1(String(card.text))]));
645
735
  const file = cacheCandidates(brainPath)[0].file;
@@ -647,10 +737,19 @@ export function cachedVectorsForBrain(brainPath, cards) {
647
737
  try { const st = fs.statSync(file); stamp = `${st.mtimeMs}|${st.size}`; } catch { stamp = null; }
648
738
  const digest = sha1([...desiredHashes.entries()].map(([id, h]) => `${id}:${h}`).sort().join('\n'));
649
739
  if (stamp && _cachedVecMemo && _cachedVecMemo.file === file && _cachedVecMemo.stamp === stamp && _cachedVecMemo.digest === digest) return _cachedVecMemo.map;
650
- const loaded = readCache(brainPath, desiredHashes);
740
+ const texts = new Map(want.map((card) => [card.id, String(card.text)]));
741
+ const accept = (id, entry) => {
742
+ const text = texts.get(id);
743
+ return text !== undefined && vectorEntryMatchesText(
744
+ entry,
745
+ desiredHashes.get(id),
746
+ () => (text.length > 1500 ? sha1(text.slice(0, 1500)) : null),
747
+ );
748
+ };
749
+ const loaded = readCache(brainPath, accept);
651
750
  for (const card of want) {
652
751
  const entry = loaded?.cache?.cards?.[card.id];
653
- if (entry?.v && entry.h === desiredHashes.get(card.id)) map.set(card.id, entry.v);
752
+ if (accept(card.id, entry)) map.set(card.id, entry.v);
654
753
  }
655
754
  if (stamp) _cachedVecMemo = { file, stamp, digest, map };
656
755
  } catch { /* cache is best-effort — lexical stays the floor */ }