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 +2 -2
- package/bin/klypix-worker.mjs +3 -1
- package/package.json +2 -2
- package/src/agent-rules.mjs +2 -2
- package/src/brain-semantic.mjs +32 -4
- package/src/semantic-memory.mjs +117 -18
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
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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`
|
|
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.
|
|
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 &&
|
|
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",
|
package/src/agent-rules.mjs
CHANGED
|
@@ -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
|
package/src/brain-semantic.mjs
CHANGED
|
@@ -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
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
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
|
|
package/src/semantic-memory.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 */ }
|