klypix-mcp 1.96.0 → 1.98.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/README.md CHANGED
@@ -235,13 +235,23 @@ not a filing convention.
235
235
  session's brief. That is brief priority, not a retrieval-ranking boost.
236
236
  - **You can ask what the project believed then.** `brain_ask` with `as_of: 2026-03-01` reweights
237
237
  ranking by card lifecycle dates, so corrections made later do not leak backwards.
238
- - **Retrieval is local.** Lexical by default. If the optional on-device model is installed,
239
- `brain_ask` and `search_all_brains` use BGE semantic ranking with lexical help for exact
240
- identifiers, paths, and versions — still entirely on your machine. The previous cross-encoder
241
- is available for experiments with `KLYPIX_RERANK=1`, but is off by default because it reduced
242
- precision and added latency on the frozen human-paraphrase evaluation. Without the embedding
243
- model, retrieval degrades cleanly to lexical. `npx klypix-mcp install` deliberately does not
244
- install that model, so a fresh install is lexical.
238
+ - **Retrieval is local.** With the on-device model ("search by meaning"), `brain_ask` and
239
+ `search_all_brains` use BGE semantic ranking with lexical help for exact identifiers, paths, and
240
+ versions — still entirely on your machine. Without it, retrieval is keyword-only. The previous
241
+ cross-encoder is available for experiments with `KLYPIX_RERANK=1`, but is off by default because
242
+ it reduced precision and added latency on the frozen human-paraphrase evaluation.
243
+ - **Search by meaning is installed for you (1.97).** `npx klypix-mcp install` run by a person in a
244
+ terminal installs it: about 150 MB to download once, about 110 MB on disk after the files Node
245
+ never loads are removed. Skip it with `--no-semantic` (or `KLYPIX_SEMANTIC=0`). Installs with no
246
+ terminal — CI, scripts, an AI agent running the install — get it only with `--semantic`. The
247
+ Claude plugin never downloads it. Measured on the frozen 102-question set: the right card in the
248
+ top 5 for 54 questions with it, 19 without.
249
+ - **Preparing a brain never makes a question wait.** Each card is turned into a meaning vector once
250
+ (about 45 ms a card on a laptop CPU, one card at a time, ~305 MB peak), newest cards first, by a
251
+ low-priority background job that saves as it goes. A question on a brain that is not ready yet
252
+ answers right away from what is ready plus keywords, and says how far along the preparation is
253
+ ("Getting this brain ready for search by meaning: 900 of 3,670 cards so far, about 5 min left").
254
+ After that, only new or edited cards are prepared.
245
255
 
246
256
  ### Bounded semantic-memory runtime
247
257
 
@@ -518,12 +528,22 @@ Future hosts get baseline support merely by connecting the MCP server. A deeper
518
528
 
519
529
  ## Overlap warnings
520
530
 
521
- When two sessions declare overlapping expected files, `brain_sync` surfaces it: the peer, its
522
- declared task, and the exact paths in common. A one-time alert is queued to whichever session got
523
- there first, so a late arrival is not the only one who knows.
531
+ `brain_sync` reports three kinds of overlap with other live sessions on the same brain:
524
532
 
525
- This warns. It does not prevent. Nothing blocks an edit, matching is exact-path, and both sides
526
- have to have declared their files for the overlap to be visible at all.
533
+ - **Same file.** Both sessions declared, or were observed editing, the same path. You see the peer,
534
+ its task and the paths in common, and a one-time alert is queued to whichever session got there
535
+ first, so a late arrival is not the only one who knows.
536
+ - **Would conflict at merge.** The two sessions are on different branches, and a test merge of the
537
+ two branch tips (`git merge-tree`, git 2.38 or newer) conflicts in named files, even though no
538
+ declared file overlaps. This sees committed work only: an uncommitted edit is invisible to git.
539
+ It also gets a one-time alert. Files the brain tooling regenerates on every branch (AGENTS.md,
540
+ rule files, `.mcp.json`, `brain.klypix`) are not counted.
541
+ - **Same folder (advisory).** Both sessions work in one folder with no file in common yet. No alert
542
+ is sent. A folder that at least three sessions, and more than half of them, touch is treated as
543
+ noise and not reported.
544
+
545
+ This warns. It does not prevent. Nothing blocks an edit. A session that never declared its files
546
+ and was never observed editing is invisible to the file and folder checks.
527
547
 
528
548
  ## Handoffs and messages
529
549
 
@@ -747,6 +767,15 @@ lost 0 of 46. If the control ever loses nothing, the run reports **inconclusive*
747
767
 
748
768
  Latest results, with hardware and date: [BENCHMARKS.md](BENCHMARKS.md).
749
769
 
770
+ **Does it change what agents do?** That needs real agents, so it lives in the repository rather than
771
+ the package: [`bench/coordination/`](bench/coordination/README.md) runs Claude Code or Codex on four
772
+ small repos where another session knows something the agent needs (a decision changed today, work
773
+ overlapping on another branch, a note from a session that has ended, a root cause another vendor's
774
+ session found). Each run is graded by a test written after the agent finishes, against no shared
775
+ channel, a human copy-paste, `AGENTS.md` kept tidy or appended, and KLYPIX over MCP only. Every number
776
+ is printed with its sample size and interval; tools it has not measured are listed as not measured.
777
+ `--dry-run` costs nothing and proves the graders.
778
+
750
779
  ## Git and concurrency
751
780
 
752
781
  One file in your repo, committed with your code — versioned, branchable, portable. So two
@@ -1095,9 +1124,10 @@ Read this section before you build on any of it.
1095
1124
  loop prevention, stable message IDs and per-recipient-machine acknowledgement primitives), but no
1096
1125
  transport: carrying frames between machines is the desktop app's job. With `klypix-mcp` alone,
1097
1126
  coordination is machine-local.
1098
- - **Overlap matching is exact-path, and both sides must declare.** A session that never declares
1099
- its expected files is invisible to overlap detection, and `src/auth/token.ts` does not match a
1100
- rename or a parent directory.
1127
+ - **File overlap is exact-path.** A session needs declared files or observed edits to be checked;
1128
+ `src/auth/token.ts` does not match a rename. Folder overlap is advisory. The merge test sees
1129
+ committed work only, needs git 2.38 or newer, and runs only for sessions on different local
1130
+ branches of the same repository.
1101
1131
  - **Overlap warnings are advisory.** Nothing is blocked. One severity string in the payload reads
1102
1132
  `blocking`; the mechanism is not.
1103
1133
  - **Codex has no automatic capture**, with or without `--codex-hooks`. The Codex hook never writes
@@ -12,6 +12,10 @@
12
12
  // npx klypix-mcp install --codex-hooks # optional prompt/file awareness; Codex asks for trust
13
13
  // npx klypix-mcp install --allow-untagged # acknowledge deploying an UNTAGGED source checkout
14
14
  // # (dev deploy; also KLYPIX_MCP_ALLOW_UNTAGGED=1)
15
+ // npx klypix-mcp install --semantic # also install search by meaning (~150 MB download, once)
16
+ // npx klypix-mcp install --no-semantic # never (also KLYPIX_SEMANTIC=0)
17
+ // A person running install in a terminal gets search by meaning by default;
18
+ // scripted, CI and agent-run installs (no terminal) only with --semantic.
15
19
  //
16
20
  // Never-throws-silently: it's an explicit CLI, so it reports what it did and exits
17
21
  // non-zero on a real failure. Never wires a broken settings.json (refuse + restore).
@@ -35,6 +39,7 @@ import { brainInstallDecision, deploySourceDecision } from '../src/install-versi
35
39
  import { acquireInstallLockSync, releaseInstallLockSync } from '../src/install-lock.mjs';
36
40
  import { collectRepoState } from '../src/repo-state.mjs';
37
41
  import { runSetup, renderBrief } from '../src/setup.mjs';
42
+ import { SEMANTIC_RUNTIME_MANIFEST, dirBytes, pruneSemanticRuntime, semanticInstallDecision } from '../src/semantic-install.mjs';
38
43
 
39
44
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
45
  const PKG_ROOT = path.resolve(__dirname, '..');
@@ -60,6 +65,10 @@ const SETUP_JSON = process.argv.includes('--json');
60
65
  // this is source authority (knowingly deploy an untagged working tree).
61
66
  const ALLOW_UNTAGGED = process.argv.includes('--allow-untagged')
62
67
  || process.env.KLYPIX_MCP_ALLOW_UNTAGGED === '1';
68
+ // Search by meaning (1.97.0). Measured 2026-10-11 on the real 102-question
69
+ // set: the right card in the top 5 for 54 questions with it, 19 without.
70
+ const SEMANTIC_FLAG = process.argv.includes('--semantic');
71
+ const NO_SEMANTIC = process.argv.includes('--no-semantic') || process.env.KLYPIX_SEMANTIC === '0';
63
72
 
64
73
  const HOME = os.homedir();
65
74
  const CLAUDE_DIR = path.join(HOME, '.claude');
@@ -143,6 +152,52 @@ function wireCodex() {
143
152
  };
144
153
  }
145
154
 
155
+ // ── Search by meaning: the optional on-device runtime (src/semantic-install.mjs) ──
156
+ function installSemanticRuntimeIfWanted() {
157
+ const semanticDir = path.join(BRAIN_DIR, 'semantic');
158
+ const transformers = path.join(semanticDir, 'node_modules', '@huggingface', 'transformers');
159
+ const decision = semanticInstallDecision({
160
+ optOut: NO_SEMANTIC,
161
+ flag: SEMANTIC_FLAG,
162
+ runtimeOnly: RUNTIME_ONLY,
163
+ plugin: String(process.env.KLYPIX_PLUGIN ?? '').trim() === '1',
164
+ present: exists(transformers),
165
+ ci: Boolean(process.env.CI),
166
+ tty: Boolean(process.stdout.isTTY),
167
+ });
168
+ if (decision !== 'install') return { decision };
169
+ console.log('• Installing search by meaning (about 150 MB to download, once; skip with --no-semantic)…');
170
+ const started = Date.now();
171
+ try {
172
+ fs.mkdirSync(semanticDir, { recursive: true });
173
+ fs.writeFileSync(path.join(semanticDir, 'package.json'), `${JSON.stringify(SEMANTIC_RUNTIME_MANIFEST, null, 2)}\n`);
174
+ const npmArgs = ['install', '--prefix', semanticDir, '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'];
175
+ if (process.platform === 'win32') execFileSync('cmd.exe', ['/d', '/s', '/c', 'npm.cmd', ...npmArgs], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 600_000, windowsHide: true });
176
+ else execFileSync('npm', npmArgs, { stdio: ['ignore', 'ignore', 'pipe'], timeout: 600_000 });
177
+ if (!exists(transformers)) throw new Error('npm finished but the runtime is missing');
178
+ let freed = 0;
179
+ try { freed = pruneSemanticRuntime(semanticDir); } catch { /* pruning is an optimisation */ }
180
+ return { decision, ok: true, seconds: Math.round((Date.now() - started) / 1000), sizeMb: Math.round(dirBytes(path.join(semanticDir, 'node_modules')) / 1048576), freedMb: Math.round(freed / 1048576) };
181
+ } catch (error) {
182
+ const why = String(error?.stderr || error?.message || error).split('\n').map(s => s.trim()).filter(Boolean).slice(-1)[0] || 'npm failed';
183
+ return { decision, ok: false, error: why.slice(0, 200) };
184
+ }
185
+ }
186
+
187
+ function reportSemantic(step) {
188
+ const turnOn = 'npx klypix-mcp install --semantic';
189
+ if (!step) return;
190
+ if (step.decision === 'install' && step.ok) {
191
+ console.log(`✓ search by meaning installed in ${step.seconds}s (${step.sizeMb} MB on disk). Your project brains are being prepared in the background, newest cards first: questions work right away and get better as it finishes.`);
192
+ } else if (step.decision === 'install') {
193
+ console.error(`⚠ search by meaning could not be installed (${step.error}). Keyword search still works. Try again with: ${turnOn}`);
194
+ } else if (step.decision === 'present') {
195
+ console.log('✓ search by meaning: on');
196
+ } else if (step.decision === 'skip-noninteractive' || step.decision === 'skip-ci') {
197
+ console.log(`• search by meaning is off. Turn it on with: ${turnOn} (about 150 MB, once; agents then find the right card about 3x as often)`);
198
+ }
199
+ }
200
+
146
201
  function reportCodex(result) {
147
202
  if (result.ok) {
148
203
  const mcp = result.mcp.action === 'skipped-no-project-brain'
@@ -594,6 +649,9 @@ try {
594
649
  const notWired = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse', 'PreToolUse'].filter(e => !wiredFor(e));
595
650
 
596
651
  releaseInstallLockSync(installLock);
652
+ // Search by meaning: installed AFTER the brain itself is committed, so a
653
+ // slow or failed download never touches a working keyword-only install.
654
+ const semanticStep = installSemanticRuntimeIfWanted();
597
655
  // Users who already enabled the optional local semantic runtime should not
598
656
  // pay a multi-minute first question after a model/cache contract upgrade.
599
657
  // Migrate registered brains once in a detached process after the atomic
@@ -617,6 +675,7 @@ try {
617
675
  }
618
676
  if (!RUNTIME_ONLY) reportCodex(codex);
619
677
  console.log(`✓ installed klypix brain v${VERSION} → ${BRAIN_DIR} (${n} scripts, ${deps} dep packages)`);
678
+ reportSemantic(semanticStep);
620
679
  if (RUNTIME_ONLY) console.log('✓ runtime-only update: host settings and project files were preserved');
621
680
  else if (!notWired.length) console.log('✓ wired 5 hooks: SessionStart · UserPromptSubmit (--prompt) · Stop (--capture) · PostToolUse (--live) · PreToolUse (--guard) → settings.json');
622
681
  else console.error(`⚠ readiness: ${notWired.length} hook(s) did NOT take (${notWired.join(', ')}) — the brain will read but not capture/sync. Re-run \`npx klypix-mcp install --force\` or check ${SETTINGS}.`);
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // One detached, best-effort cache migration after a compatible core update.
3
- // It runs only when the optional semantic runtime already exists, processes
4
- // registered brains sequentially, and never changes brain content.
2
+ // One detached, best-effort preparation of brains for search by meaning: after
3
+ // an install or a compatible core update (every registered brain), or started
4
+ // by a question on a brain that is not ready yet (--brain <file> --only: that
5
+ // brain alone). It runs at below-normal priority, reports progress to the
6
+ // status file while it works (brainPreparationStatus reads it), refreshes its
7
+ // lock so a long run is never taken for a dead one, and never changes brain
8
+ // content.
5
9
  import fs from 'fs';
6
10
  import os from 'os';
7
11
  import path from 'path';
@@ -9,6 +13,7 @@ import { parseKlypix } from '../src/klypix-format.mjs';
9
13
  import { readRegisteredProjectBrains } from '../src/mcp-auto-update.mjs';
10
14
  import {
11
15
  EMBEDDING_CACHE_KEY,
16
+ cachedVectorsForBrain,
12
17
  disposeSemanticModels,
13
18
  getEmbedderForUse,
14
19
  vectorsForBrain,
@@ -22,6 +27,7 @@ const brainDir = path.resolve(arg('brain-dir', path.join(os.homedir(), '.claude'
22
27
  const lockFile = path.join(brainDir, '.semantic-update-warm.lock');
23
28
  const statusFile = path.join(brainDir, '.semantic-update-warm.json');
24
29
  const explicitBrain = arg('brain');
30
+ const onlyExplicit = process.argv.includes('--only');
25
31
  const startedAt = Date.now();
26
32
  let lock = null;
27
33
 
@@ -31,6 +37,9 @@ const atomicJson = (file, value) => {
31
37
  fs.renameSync(tmp, file);
32
38
  };
33
39
 
40
+ // A background job: never compete with the person's own work for the CPU.
41
+ try { os.setPriority(os.constants.priority.PRIORITY_BELOW_NORMAL); } catch { /* best-effort */ }
42
+
34
43
  try {
35
44
  fs.mkdirSync(brainDir, { recursive: true });
36
45
  try {
@@ -45,13 +54,17 @@ try {
45
54
  }
46
55
  fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, startedAt, modelKey: EMBEDDING_CACHE_KEY }));
47
56
 
48
- const registered = readRegisteredProjectBrains(brainDir).map(item => item.brainPath);
57
+ const registered = onlyExplicit && explicitBrain ? [] : readRegisteredProjectBrains(brainDir).map(item => item.brainPath);
49
58
  const candidates = [...new Set([explicitBrain ? path.resolve(explicitBrain) : null, ...registered].filter(Boolean))]
50
59
  .filter(file => {
51
60
  try { return fs.statSync(file).isFile(); } catch { return false; }
52
61
  });
53
62
  if (!candidates.length) {
54
- atomicJson(statusFile, { protocol: 1, result: 'no-registered-brains', modelKey: EMBEDDING_CACHE_KEY, completedAt: new Date().toISOString() });
63
+ // Nothing to prepare yet, but fetch the model now (~33 MB, once) so the
64
+ // first question on a new brain does not pay for the download.
65
+ let model = 'unavailable';
66
+ try { model = (await getEmbedderForUse(() => {}, 5 * 60_000)) ? 'downloaded' : 'unavailable'; } catch { /* the first question fetches it */ }
67
+ atomicJson(statusFile, { protocol: 1, result: 'no-registered-brains', model, modelKey: EMBEDDING_CACHE_KEY, completedAt: new Date().toISOString() });
55
68
  } else {
56
69
  const pipe = await getEmbedderForUse(() => {}, 5 * 60_000);
57
70
  if (!pipe) {
@@ -61,7 +74,18 @@ try {
61
74
  for (const file of candidates) {
62
75
  try {
63
76
  const { struct } = await parseKlypix(fs.readFileSync(file));
64
- const vectors = await vectorsForBrain(pipe, file, struct.cards);
77
+ const brainStarted = new Date().toISOString();
78
+ let lastReport = 0;
79
+ const report = ({ done, total }, force = false) => {
80
+ if (!force && Date.now() - lastReport < 3_000) return;
81
+ lastReport = Date.now();
82
+ try { atomicJson(statusFile, { protocol: 1, result: 'preparing', modelKey: EMBEDDING_CACHE_KEY, brain: file, done, total, startedAt: brainStarted, updatedAt: new Date().toISOString() }); } catch { /* advisory */ }
83
+ try { const now = new Date(); fs.utimesSync(lockFile, now, now); } catch { /* advisory */ }
84
+ };
85
+ let alreadyReady = 0;
86
+ try { alreadyReady = cachedVectorsForBrain(file, struct.cards).size; } catch { /* starts from zero */ }
87
+ report({ done: alreadyReady, total: struct.cards.filter(c => c.type !== 'container' && String(c.text || '').trim()).length }, true);
88
+ const vectors = await vectorsForBrain(pipe, file, struct.cards, { maxEmbed: Infinity, onProgress: report });
65
89
  warmed++;
66
90
  cards += vectors.size;
67
91
  } catch { failed++; }
@@ -508,7 +508,7 @@ server.registerTool('search_all_brains', {
508
508
 
509
509
  server.registerTool('brain_ask', {
510
510
  title: 'Ask the project brain a question (whole-brain, correction-aware answer)',
511
- description: 'Answer a natural-language question from the WHOLE project brain — "what did we decide about X?", "where did the auth work land?", "why did we drop Y?". Ranks every card lexically, blended with on-device semantic similarity ONLY when the optional local model is installed (a fresh install is lexical; it degrades cleanly). INCLUDES superseded/archived history (flagged, so you can see how a decision changed), and attaches each stale card\'s live CORRECTION so the answer reflects the current truth, not an outdated card. Returns a synthesis-ready context (full cards + provenance + lifecycle) for you to turn into a direct, cited answer — it does not itself write prose. Prefer this over search_canvases when the user asks a QUESTION (not a keyword lookup). Optional as_of (YYYY-MM-DD) answers "what was true then". Cards are often worded differently from the question, so end the question with one more line, `Also: <8–14 words a card answering it would likely use: synonyms, feature, file or tool names>`; they widen the search while the question still leads (measured: right card in the top 5 for 60 of 102 test questions instead of 55; 24 instead of 19 when no local model is installed). Defaults to the project brain ("brain").',
511
+ description: 'Answer a natural-language question from the WHOLE project brain — "what did we decide about X?", "where did the auth work land?", "why did we drop Y?". Ranks every card by meaning with the on-device model (installed by `npx klypix-mcp install` run in a terminal, or with `--semantic`), with keywords for exact names; without the model it is keyword-only, and while a brain is still being prepared the answer says how far along it is. INCLUDES superseded/archived history (flagged, so you can see how a decision changed), and attaches each stale card\'s live CORRECTION so the answer reflects the current truth, not an outdated card. Returns a synthesis-ready context (full cards + provenance + lifecycle) for you to turn into a direct, cited answer — it does not itself write prose. Prefer this over search_canvases when the user asks a QUESTION (not a keyword lookup). Optional as_of (YYYY-MM-DD) answers "what was true then". Cards are often worded differently from the question, so end the question with one more line, `Also: <8–14 words a card answering it would likely use: synonyms, feature, file or tool names>`; they widen the search while the question still leads (measured: right card in the top 5 for 60 of 102 test questions instead of 55; 24 instead of 19 when no local model is installed). Defaults to the project brain ("brain").',
512
512
  inputSchema: {
513
513
  question: z.string().describe('The natural-language question to answer from the brain.'),
514
514
  canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
@@ -1066,7 +1066,7 @@ server.registerTool('brain_reopen', {
1066
1066
 
1067
1067
  server.registerTool('brain_sync', {
1068
1068
  title: 'KLYPIX Context Gateway — synchronize task, peers, conflicts, and relevant memory',
1069
- description: 'APPROVAL-FREE task gateway over the authorized MCP connection. Call FIRST with a concise intent and expected files, again when scope changes, and with phase:"complete" before the final response. One bounded response returns compact task-relevant brain context, active TASK peers (idle connections hidden), one-time messages, structured exact-file conflicts, and late-arrival overlap alerts. A completion that supplies machine-checkable result manifests is fail-closed: invalid, conflicting, or incomparable evidence returns needs-reconciliation and retains task scope. Works on any MCP host — it needs only the authorized MCP connection, so native lifecycle hooks are optional. LIMITS: conflict matching is EXACT-PATH and both sessions must have declared their files, coordination/result reconciliation is machine-local and OS-user-local (a teammate on another machine is invisible), and file overlap remains ADVISORY.',
1069
+ description: 'APPROVAL-FREE task gateway over the authorized MCP connection. Call FIRST with a concise intent and expected files, again when scope changes, and with phase:"complete" before the final response. One bounded response returns compact task-relevant brain context, active TASK peers (idle connections hidden), one-time messages, structured exact-file conflicts, and late-arrival overlap alerts. A completion that supplies machine-checkable result manifests is fail-closed: invalid, conflicting, or incomparable evidence returns needs-reconciliation and retains task scope. `results` is ONLY for such manifests, never a summary of the work: with nothing machine-checkable to claim, complete without it. Works on any MCP host — it needs only the authorized MCP connection, so native lifecycle hooks are optional. LIMITS: blocking overlap is EXACT-PATH over the declared files of each session plus the edits its host observed; sessions on different branches are also test-merged with git merge-tree (committed work only); a same-folder overlap is an advisory notice; coordination/result reconciliation is machine-local and OS-user-local (a teammate on another machine is invisible), and file overlap remains ADVISORY.',
1070
1070
  annotations: {
1071
1071
  destructiveHint: false,
1072
1072
  idempotentHint: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.96.0",
3
+ "version": "1.98.0",
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",
@@ -86,7 +86,7 @@
86
86
  "bench": "node bin/klypix-mcp.mjs bench",
87
87
  "test:bench": "node test/bench.mjs",
88
88
  "pretest": "node test/publish-workflow.mjs",
89
- "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/session-mailbox.mjs && node test/session-reopen.mjs && node test/lane-write-retry.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/brain-ask-words.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/arrange-receipts.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/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-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/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.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/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
89
+ "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/session-mailbox.mjs && node test/session-reopen.mjs && node test/lane-write-retry.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/brain-ask-words.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/arrange-receipts.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-preparation.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-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/overlap-tiers.mjs && node test/coordination-bench.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/results-not-manifests.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.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/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/current-first.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
90
90
  "test:memory": "node test/memory-runtime.mjs",
91
91
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
92
92
  "runtime": "node bin/klypix-runtime.mjs",
@@ -1462,6 +1462,22 @@ function peerFooter(sid) {
1462
1462
  const lines = ['', `## ⚠️ Other live session(s) in this project — ${peers.length} total, as of ${asOf}${hiddenIdle ? ` (+${hiddenIdle} inactive not shown)` : ''} — coordinate (the brain is shared, NOT live-merged)`];
1463
1463
  const releaseLine = releaseLeaseFooterLine(list, now);
1464
1464
  if (releaseLine) lines.push(releaseLine);
1465
+ // Same branch, said ONCE (1.98.0). Two sessions can only share a branch
1466
+ // name by sharing a checkout (git refuses one branch in two worktrees), so
1467
+ // the risk is concrete: a broad `git add` sweeps the other session's edits
1468
+ // into your commit. Per-peer, this line repeated on every row of a busy
1469
+ // lane and stopped being read.
1470
+ const sameBranchCount = myBranch ? peers.filter(p => p.branch === myBranch).length : 0;
1471
+ if (sameBranchCount) lines.push(`- ⚠️ ${sameBranchCount} of these session(s) share your branch \`${myBranch}\` (one checkout): stage only your own files — never \`git add -u\` / \`-A\` — and coordinate before you commit.`);
1472
+ // Same folder, no shared file yet (twin of mcp-presence findNearbyWork, no
1473
+ // git): with 3+ scoped sessions, a folder most of them touch is noise.
1474
+ const dirOf = (k) => { const at = k.lastIndexOf('/'); return at > 0 ? k.slice(0, at) : ''; };
1475
+ const dirsOfScope = (s) => new Set(scopeOf(s).map(fileKey).map(dirOf).filter(Boolean));
1476
+ const myDirs = dirsOfScope(me);
1477
+ const scopedRows = [me, ...peers].filter(s => s && scopeOf(s).length);
1478
+ const dirCount = new Map();
1479
+ for (const s of scopedRows) for (const d of dirsOfScope(s)) dirCount.set(d, (dirCount.get(d) || 0) + 1);
1480
+ const dirIsNoise = (d) => { const n = dirCount.get(d) || 0; return n >= 3 && n > scopedRows.length / 2; };
1465
1481
  for (const p of peers.slice(0, 4)) {
1466
1482
  const bits = [];
1467
1483
  if (p.branch) bits.push(`branch \`${p.branch}\``);
@@ -1496,10 +1512,12 @@ function peerFooter(sid) {
1496
1512
  warn = ` · ⚠️ both edited: ${marked.join(', ')} — expect a conflict, KEEP BOTH${legend}`;
1497
1513
  }
1498
1514
  // State the fact, never the git operation. This runs in a latency-sensitive
1499
- // hook and must not spawn git, so it cannot know whether the branch is merely
1500
- // behind or has DIVERGED — and on a diverged branch "pull/rebase" is advice
1501
- // the real state contradicts. Naming the collision is what this footer is for.
1502
- else if (p.branch && myBranch && p.branch === myBranch) warn = ' · ⚠️ same branch — your commits will interleave; coordinate before you commit';
1515
+ // hook and must not spawn git. The same-branch fact is stated once above
1516
+ // the list; per peer, only a shared folder is added (advisory).
1517
+ else {
1518
+ const near = [...dirsOfScope(p)].filter(d => myDirs.has(d) && !dirIsNoise(d)).sort().slice(0, 2);
1519
+ if (near.length) warn = ` · 📁 same folder: ${near.map(d => `${d}/`).join(', ')} — no shared file yet`;
1520
+ }
1503
1521
  // Unique over the full live list (not just shown peers) — the MSG router
1504
1522
  // resolves a prefix against every row, so uniqueness must match that set.
1505
1523
  lines.push(`- session ${shortestUniquePeerPrefix(list, p.id)} · ${bits.join(' · ')}${warn}`);
@@ -34,7 +34,7 @@ import {
34
34
  challengeBrain, challengeContextToMarkdown, buildRenderSpec, structToBrief,
35
35
  brainLensData, lensToMarkdown, deathDateOfCard,
36
36
  statusContextToMarkdown, findFulfillmentCandidates, findStaleOpenCards, releaseFulfilledOpens, openStatusSummary, perAreaTableToMarkdown,
37
- splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor, currentGuidanceFor, currentGuidancePrefix,
37
+ splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor, supersededByNote, currentGuidanceFor, currentGuidancePrefix,
38
38
  isFastDecayCard, isUnresolvedOpenCard, isSkillCard, validateGuard, guardSidecarPathFor, ensureGuardSidecar, DECAY_STALE_MS, formatDecayAge,
39
39
  isPlanCard, planFulfillmentFor, PLAN_PAIR_SIM_BRAIN, isAgconfTwinId,
40
40
  readPendingShips, clearPendingShips, pendingShipCards, formatCaptureReceipts, parseVerifySuffix, amendmentFirst,
@@ -56,7 +56,7 @@ import {
56
56
  dot, embedTexts, getEmbedder, getEmbedderForUse, withRerankerForUse,
57
57
  rerankHits, semanticFallbackNotice, semanticMemorySnapshot,
58
58
  semanticRuntimeInstalled, shouldPrewarmSemantic, vectorsForBrain,
59
- cachedVectorsForBrain,
59
+ cachedVectorsForBrain, startBrainPreparation, preparationNotice,
60
60
  } from './semantic-memory.mjs';
61
61
 
62
62
  // The MCP and A2A faces prewarm through this protocol-neutral module. Bounded
@@ -435,6 +435,9 @@ export async function opSearchCanvases({ vault, query }) {
435
435
  const isArchived = (c) => /^archive$/i.test(c.area || '');
436
436
  const n = struct.counts;
437
437
  const head = `## ${rel} — "${struct.title}" · ${n.live ?? n.cards} live cards${n.archived ? `, ${n.archived} archived` : ''}, ${n.connections} connections${saved ? ` · saved ${saved}` : ''}${nameMatch && !matched.length ? ' (name/title match)' : ''}`;
438
+ // A stale hit names its replacement (1.98.0) — never a bare "archived".
439
+ let successors = new Map();
440
+ try { successors = correctionOverlaysFor(struct, matched.slice(0, 8)); } catch { successors = new Map(); }
438
441
  const body = matched.slice(0, 8).map(c => {
439
442
  const pos = (c.pos && c.pos.x != null) ? ` @(${Math.round(c.pos.x)},${Math.round(c.pos.y)})` : '';
440
443
  const allTags = [...new Set([...(c.tags || []), ...itemTags(c)])];
@@ -449,7 +452,10 @@ export async function opSearchCanvases({ vault, query }) {
449
452
  const fromReading = reading && !hit(c.title) && !hit(c.text) && hit(reading.text)
450
453
  ? `\n in KLYPIX's saved reading: ${escapeInstructionLines(reading.text.replace(/\s+/g, ' ').slice(0, 240))}`
451
454
  : '';
452
- return `- [${c.type}] "${c.title || '(card)'}" (${c.id})${pos}${date ? ` · ${date}` : ''}${tags}${arch}\n ${escapeInstructionLines(ownText)}${fromReading}`;
455
+ const replaced = successors.has(c.id)
456
+ ? `\n ↪ ${supersededByNote(successors.get(c.id))} — read that card for the current state`
457
+ : '';
458
+ return `- [${c.type}] "${c.title || '(card)'}" (${c.id})${pos}${date ? ` · ${date}` : ''}${tags}${arch}\n ${escapeInstructionLines(ownText)}${fromReading}${replaced}`;
453
459
  }).join('\n');
454
460
  hits.push(matched.length ? `${head}\n${body}` : head);
455
461
  }
@@ -503,7 +509,13 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
503
509
  let struct;
504
510
  try { ({ struct } = await parseKlypix(fs.readFileSync(b.path))); } catch { continue; }
505
511
  let vecs = null;
506
- if (qv) { try { vecs = await vectorsForBrain(pipe, b.path, struct.cards); } catch { /* lexical for this brain */ } }
512
+ if (qv) { try { const r = await questionVectors(pipe, b.path, struct.cards); vecs = r.usable ? r.vecs : null; } catch { /* lexical for this brain */ } }
513
+ // Replacement per stale card (1.98.0), only when this brain HAS lifecycle
514
+ // edges; skipped under as_of (a correction is a present-day fact).
515
+ let successors = new Map();
516
+ if (asOfTs == null && (struct.connections || []).some(cn => cn.label === 'superseded by' || cn.label === 'closed by')) {
517
+ try { successors = correctionOverlaysFor(struct, struct.cards); } catch { successors = new Map(); }
518
+ }
507
519
  for (const c of struct.cards) {
508
520
  if (c.type === 'container') continue;
509
521
  const t = String(c.text || '').toLowerCase();
@@ -529,7 +541,7 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
529
541
  if (isArchived) score -= 1;
530
542
  if (isCur) score += sem != null ? 1.5 : 1; // current-project locality prior
531
543
  }
532
- scored.push({ score, sem, cur: isCur, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
544
+ scored.push({ score, sem, cur: isCur, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c, successor: successors.get(c.id) || null });
533
545
  }
534
546
  }
535
547
  if (!scored.length) return { blocks: [text(`No matches for "${query}" across ${brains.length} registered brain(s).`)] };
@@ -537,7 +549,7 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
537
549
  const top = scored.slice(0, 20);
538
550
  const lines = top.map(h => {
539
551
  const when = h.c.createdAt ? new Date(h.c.createdAt).toISOString().slice(0, 10) : '';
540
- return `- ${h.cur ? '★ ' : ''}[${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
552
+ return `- ${h.cur ? '★ ' : ''}[${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}${h.successor ? `\n ↪ ${supersededByNote(h.successor)}` : ''}`;
541
553
  });
542
554
  // Never say "warming — retry" to someone who simply has not installed the
543
555
  // runtime: for them that advice is wrong forever, and their results are the
@@ -765,6 +777,21 @@ export async function opBrainTaskContext({
765
777
  };
766
778
  }
767
779
 
780
+ // Card meanings for a question, never waiting on a preparation (1.97.0): at
781
+ // most a few seconds of embedding inline; whatever is still missing is handed
782
+ // to the background preparation (startBrainPreparation) and the answer says
783
+ // so (preparationNotice). Meaning ranking is used once at least
784
+ // MEANING_READY_SHARE of the cards are ready — below that the keyword path
785
+ // covers every card, which a mostly-unprepared meaning ranking would not.
786
+ const MEANING_READY_SHARE = 0.5;
787
+ async function questionVectors(pipe, file, cards) {
788
+ const stats = {};
789
+ const vecs = await vectorsForBrain(pipe, file, cards, { stats });
790
+ if (stats.pending > 0) startBrainPreparation(file);
791
+ const usable = Boolean(vecs && vecs.size) && (!stats.wanted || stats.ready / stats.wanted >= MEANING_READY_SHARE);
792
+ return { vecs, stats, usable };
793
+ }
794
+
768
795
  export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log = () => {} }) {
769
796
  // A last line `Also: <words>` is the caller's guess at the card's wording; it
770
797
  // joins the meaning query only (splitAskWords in klypix-format.mjs).
@@ -781,13 +808,14 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
781
808
  // Semantic blend (best-effort, time-bounded): embed the question + the brain's
782
809
  // cards on-device, hand rankForQuestion a Map<cardId, cosine>. A missing/warming
783
810
  // model degrades cleanly to pure lexical.
784
- let semantic = null, mode = 'lexical', cardVecs = null;
811
+ let semantic = null, mode = 'lexical', cardVecs = null, prep = null;
785
812
  try {
786
813
  const pipe = await getEmbedderForUse(log, 20_000);
787
814
  if (pipe) {
788
815
  const [qv] = await embedTexts(pipe, [words ? `${q}\n${words}` : q], { kind: 'query' });
789
- const vecs = await vectorsForBrain(pipe, t.file, struct.cards);
790
- if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); cardVecs = vecs; mode = `semantic+lexical (on-device)${words ? ' + your Also words' : ''}`; }
816
+ const { vecs, stats, usable } = await questionVectors(pipe, t.file, struct.cards);
817
+ prep = stats;
818
+ if (qv && usable) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); cardVecs = vecs; mode = `semantic+lexical (on-device)${words ? ' + your Also words' : ''}`; }
791
819
  }
792
820
  } catch { semantic = null; cardVecs = null; }
793
821
  // Parity with search_all_brains (8445e9c): never say "warming — retry" to a
@@ -801,6 +829,7 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
801
829
  if (!semantic) {
802
830
  fallbackNotice = semanticFallbackNotice(false);
803
831
  if (fallbackNotice) mode = fallbackNotice.startsWith('LEXICAL ONLY') ? 'lexical-only (no semantic runtime)' : 'lexical (semantic warming)';
832
+ if (prep?.pending > 0) mode = 'lexical (preparing search by meaning)';
804
833
  if (words) mode += ' + your Also words';
805
834
  }
806
835
  const kk = Math.max(1, Math.min(20, k || 10));
@@ -849,7 +878,11 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
849
878
  mode += ' + status-mode';
850
879
  } catch { statusMd = ''; }
851
880
  }
852
- const noticeMd = fallbackNotice ? `> ${fallbackNotice}\n\n` : '';
881
+ // While the brain is still being prepared, that is the one thing to say: the
882
+ // runtime IS installed, so the "warming — retry" fallback line would only
883
+ // repeat it less precisely.
884
+ const prepNotice = preparationNotice(t.file, prep, { usedMeaning: Boolean(semantic) });
885
+ const noticeMd = prepNotice ? `> ${prepNotice}\n\n` : (fallbackNotice ? `> ${fallbackNotice}\n\n` : '');
853
886
  const evidenceCache = new Map();
854
887
  const evidenceForCard = (card) => formatCardEvidence(inspectCardEvidence(card, { projectRoot: path.dirname(t.file), cache: evidenceCache }));
855
888
  return { blocks: [text(stamp + noticeMd + statusMd + questionContextToMarkdown(q, result, { mode, as_of: timeTravel ? as_of : null, evidenceForCard }))] };
@@ -874,8 +907,8 @@ export async function opBrainChallenge({ vault, canvas, claim, k = 8, via, log =
874
907
  const pipe = await getEmbedderForUse(log, 20_000);
875
908
  if (pipe) {
876
909
  const [qv] = await embedTexts(pipe, [q], { kind: 'query' });
877
- const vecs = await vectorsForBrain(pipe, t.file, struct.cards);
878
- if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); mode = 'semantic+lexical (on-device)'; }
910
+ const { vecs, usable } = await questionVectors(pipe, t.file, struct.cards);
911
+ if (qv && usable) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); mode = 'semantic+lexical (on-device)'; }
879
912
  }
880
913
  } catch { semantic = null; }
881
914
  const result = challengeBrain(struct, q, { semantic, k: Math.max(1, Math.min(20, k || 8)) });
@@ -1163,7 +1196,11 @@ export async function opBrainReconcile({ vault, canvas, root, mode = 'all', ref
1163
1196
  if (mode === 'plans' && cold && installed) {
1164
1197
  try {
1165
1198
  const pipe = await getEmbedderForUse(log, 20_000);
1166
- if (pipe) vecs = await vectorsForBrain(pipe, file, struct.cards);
1199
+ if (pipe) {
1200
+ const stats = {};
1201
+ vecs = await vectorsForBrain(pipe, file, struct.cards, { stats });
1202
+ if (stats.pending > 0) startBrainPreparation(file);
1203
+ }
1167
1204
  } catch (e) { embedErr = e; }
1168
1205
  }
1169
1206
  let pairSim = null, how;
@@ -1699,7 +1736,9 @@ export async function opBrainConnect({ vault, canvas, apply = false, max = 24, t
1699
1736
  try { pipe = await getEmbedderForUse(log, 20_000); } catch { /* saturated/unavailable → structural */ }
1700
1737
  if (pipe) {
1701
1738
  try {
1702
- const vecs = await vectorsForBrain(pipe, file, struct.cards);
1739
+ const stats = {};
1740
+ const vecs = await vectorsForBrain(pipe, file, struct.cards, { stats });
1741
+ if (stats.pending > 0) startBrainPreparation(file);
1703
1742
  const items = live.filter(c => vecs.get(c.id));
1704
1743
  const sources = normalizedScope === 'orphans' ? items.filter(c => orphanIds.has(c.id)) : items;
1705
1744
  for (const a of sources) {