klypix-mcp 1.51.1 → 1.52.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
@@ -162,18 +162,21 @@ not a filing convention.
162
162
  - **You can ask what the project believed then.** `brain_ask` with `as_of: 2026-03-01` reweights
163
163
  ranking by card lifecycle dates, so corrections made later do not leak backwards.
164
164
  - **Retrieval is local.** Lexical by default. If the optional on-device model is installed,
165
- `brain_ask` and `search_all_brains` blend semantic similarity with lexical scoring and rerank
166
- with a cross-encoder — still entirely on your machine. Without it, retrieval degrades cleanly to
167
- lexical. `npx klypix-mcp install` deliberately does not install that model, so a fresh install
168
- is lexical.
165
+ `brain_ask` and `search_all_brains` use BGE semantic ranking with lexical help for exact
166
+ identifiers, paths, and versions — still entirely on your machine. The previous cross-encoder
167
+ is available for experiments with `KLYPIX_RERANK=1`, but is off by default because it reduced
168
+ precision and added latency on the frozen human-paraphrase evaluation. Without the embedding
169
+ model, retrieval degrades cleanly to lexical. `npx klypix-mcp install` deliberately does not
170
+ install that model, so a fresh install is lexical.
169
171
 
170
172
  ### Bounded semantic-memory runtime
171
173
 
172
174
  Long-lived MCP and A2A workers use the bounded semantic-memory runtime by default. Models load only
173
175
  when semantic work is requested, native inference is serialized per process, embedding work is
174
- split into small batches, cross-encoder candidates are scored in bounded batches before one global
175
- sort, and temporary tensors are released after use. These controls change the resource lifecycle
176
- only; brain cards, ranking rules, project coordination, and the on-disk brain format are unchanged.
176
+ split into small batches, and temporary tensors are released after use. Loaded models retire after
177
+ an idle interval and transparently reload on the next semantic request, so warm queries stay fast
178
+ without permanently pinning native model memory. These controls change the resource lifecycle only;
179
+ brain cards, project coordination, and the on-disk brain format are unchanged.
177
180
 
178
181
  The previous runtime remains available as an emergency rollback. Set
179
182
  `KLYPIX_SEMANTIC_MEMORY_MODE=legacy` in the MCP server environment and reconnect or restart the
@@ -184,6 +187,12 @@ Run the deterministic lifecycle tests with `npm run test:memory`. For an opt-in
184
187
  against a disposable or backed-up brain, set `KLYPIX_MEMORY_SOAK_BRAIN` to its path and run
185
188
  `npm run test:memory:soak`.
186
189
 
190
+ For process-level attribution, run `npx klypix-mcp runtime` (or add `--json`; `--watch 30` samples
191
+ every 30 seconds). It reports KLYPIX workers, supervisors, and legacy launcher overhead separately,
192
+ excludes the owning IDE/chat application's RAM, redacts command-line secrets, and never opens a
193
+ brain or terminates a process. Multiple processes under one host are reported as parallel sessions,
194
+ not called duplicates without an authoritative logical-session receipt.
195
+
187
196
  ---
188
197
 
189
198
  ## Supported hosts and their integration level
@@ -365,6 +374,7 @@ The MCP verbs below are what agents call. These are what **you** call:
365
374
  | `npx klypix-mcp install` | Install the engine + Claude Code hooks on this machine (see Quick start) |
366
375
  | `npx klypix-mcp link` | Wire this project for Cursor, Cline, Windsurf, Copilot, Gemini CLI, Aider (`--check` audits) |
367
376
  | `npx klypix-mcp doctor` | One verdict: version, hosts, live sessions, tool count, drift. Exits non-zero — usable as a CI gate |
377
+ | `npx klypix-mcp runtime` | Passive per-connection process/RAM attribution (`--json`, optional `--watch seconds`); never kills or deduplicates |
368
378
  | `npx klypix-mcp conformance` | Launch two real MCP clients against this build and verify coordination behaviour |
369
379
  | `npx klypix-mcp git-driver` | Register the lossless `.klypix` merge driver for a repo (`status` to check) |
370
380
  | `npx klypix-mcp diff [ref]` | Card-level brain diff against a git ref, as markdown |
@@ -488,6 +498,12 @@ settings and project files. The check is detached and fail-open, developer-owned
488
498
  protected, concurrent sessions collapse behind one lock, and `KLYPIX_AUTO_UPDATE=0` opts out
489
499
  entirely.
490
500
 
501
+ When the optional semantic runtime is already enabled, an update also schedules one detached,
502
+ single-writer cache migration across registered brains. That removes the multi-minute first-query
503
+ re-index after a model/cache upgrade; cache writes are model-keyed and atomic across concurrent
504
+ agent sessions. Lexical-only installs download nothing. Set `KLYPIX_SEMANTIC_WARM_ON_UPDATE=0` to
505
+ keep lazy first-use indexing instead.
506
+
491
507
  ## Security and permissions
492
508
 
493
509
  - **Apache-2.0, source public** at [github.com/dahshanlabs/klypix-mcp](https://github.com/dahshanlabs/klypix-mcp).
@@ -495,7 +511,8 @@ entirely.
495
511
  deterministic and local; the only LLM anywhere is *your* agent. The one exception in this package
496
512
  is the supervisor's once-per-24h npm version check described above — turn it off with
497
513
  `KLYPIX_AUTO_UPDATE=0`.
498
- - **The optional semantic model runs on device.** No cloud inference on any retrieval path.
514
+ - **The optional semantic model runs on device.** Enabling it (or upgrading its model) can fetch
515
+ model weights from Hugging Face; retrieval inference and brain data stay local.
499
516
  - **Coordination state is local files.** The brain is a file in your repo; the presence lane is a
500
517
  file under your home directory. Nothing is uploaded.
501
518
  - **`install` writes to your home directory:** `~/.claude/project-brain` (engine + runtime),
@@ -146,8 +146,8 @@ function agentCard(publicUrl) {
146
146
  }] : []),
147
147
  {
148
148
  id: 'brain_connect',
149
- name: 'Densify the brain graph (connect related cards)',
150
- description: 'Find genuinely related but unlinked cards and propose (or, with apply, draw) connections — semantic when the on-device model is present, else shared tags + [[mentions]]. Additive; never deletes.',
149
+ name: 'Repair orphaned brain cards (connect related cards)',
150
+ description: 'Propose related links for orphaned decision/milestone cards by default, with a before→projected receipt; apply draws only additive, removable connections and never archives or rewrites cards. Pass scope:"all" for deliberate whole-graph densification.',
151
151
  tags: ['brain', 'graph', 'connect', 'memory'],
152
152
  examples: ['Connect the related cards in my project brain'],
153
153
  inputModes: ['text/plain', 'application/json'],
@@ -256,8 +256,11 @@ async function runSkill(skill, args, text, via) {
256
256
  const target = confinedCanvas(args.canvas);
257
257
  if (!target.ok) return refusedCanvas(args.canvas);
258
258
  const max = Math.max(1, Math.min(200, Math.floor(finiteNumber(args.max, 24))));
259
- const threshold = Math.max(0.3, Math.min(1, finiteNumber(args.threshold, 0.45)));
260
- return await opBrainConnect({ vault: VAULT, canvas: target.canvas, apply: args.apply === true, max, threshold, log });
259
+ const threshold = args.threshold == null
260
+ ? null
261
+ : Math.max(0.3, Math.min(1, finiteNumber(args.threshold, 0.55)));
262
+ const scope = args.scope === 'all' ? 'all' : 'orphans';
263
+ return await opBrainConnect({ vault: VAULT, canvas: target.canvas, apply: args.apply === true, max, threshold, scope, log });
261
264
  }
262
265
  case 'make_board':
263
266
  {
@@ -17,6 +17,7 @@ import fs from 'fs';
17
17
  import os from 'os';
18
18
  import path from 'path';
19
19
  import crypto from 'crypto';
20
+ import { spawn } from 'child_process';
20
21
  import { fileURLToPath } from 'url';
21
22
  import {
22
23
  connectCodexMcpServer,
@@ -225,7 +226,7 @@ const flatten = (code) => code
225
226
  .replace(/\.\.\/src\/klypix-(core|format)\.mjs/g, './klypix-$1.mjs')
226
227
  // brain-doctor + agent-rules (the server's lazy `import('../src/brain-doctor.mjs')`
227
228
  // for the brain_doctor tool) → flat sibling refs in the runtime layout.
228
- .replace(/\.\.\/src\/(brain-doctor|agent-rules|mcp-presence|mcp-supervisor|mcp-auto-update)\.mjs/g, './$1.mjs')
229
+ .replace(/\.\.\/src\/(brain-doctor|agent-rules|mcp-presence|mcp-supervisor|mcp-auto-update|semantic-memory|runtime-inspector)\.mjs/g, './$1.mjs')
229
230
  .replace(/klypix-worker\.mjs/g, 'klypix-mcp-worker.mjs')
230
231
  .replace(/const PKG_VERSION = \(\(\) => \{[\s\S]*?\}\)\(\);/, `const PKG_VERSION = '${VERSION}'; // baked at install (flat layout has no package.json)`);
231
232
 
@@ -292,7 +293,7 @@ try {
292
293
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
293
294
  // file must never get a JS-comment banner) beside the flat server, which
294
295
  // resolves it via its ./canvas-view-app.html candidate path.
295
- for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
296
+ for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
296
297
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
297
298
  }
298
299
  for (const [src, dst] of [
@@ -300,6 +301,8 @@ try {
300
301
  ['klypix-worker.mjs', 'klypix-mcp-worker.mjs'],
301
302
  ['klypix-a2a.mjs', 'klypix-a2a-server.mjs'],
302
303
  ['klypix-conformance.mjs', 'klypix-conformance.mjs'],
304
+ ['klypix-runtime.mjs', 'klypix-runtime.mjs'],
305
+ ['klypix-semantic-warm.mjs', 'klypix-semantic-warm.mjs'],
303
306
  ]) {
304
307
  const s = path.join(BIN, src); if (exists(s)) staged.push({ dst, content: flatten(fs.readFileSync(s, 'utf8')) });
305
308
  }
@@ -381,6 +384,27 @@ try {
381
384
  const notWired = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse'].filter(e => !wiredFor(e));
382
385
 
383
386
  if (gotLock) releaseLock();
387
+ // Users who already enabled the optional local semantic runtime should not
388
+ // pay a multi-minute first question after a model/cache contract upgrade.
389
+ // Migrate registered brains once in a detached process after the atomic
390
+ // runtime commit. Fresh lexical-only installs download nothing.
391
+ let semanticWarm = 'not-enabled';
392
+ const semanticRuntime = path.join(BRAIN_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers');
393
+ if (process.env.KLYPIX_SEMANTIC_WARM_ON_UPDATE !== '0' && exists(semanticRuntime)) {
394
+ try {
395
+ const warmArgs = [path.join(BRAIN_DIR, 'klypix-semantic-warm.mjs'), '--brain-dir', BRAIN_DIR];
396
+ const currentBrain = ['brain.klypix', 'brain.any'].map(name => path.join(process.cwd(), name)).find(exists);
397
+ if (currentBrain) warmArgs.push('--brain', currentBrain);
398
+ const warm = spawn(process.execPath, warmArgs, {
399
+ detached: true,
400
+ stdio: 'ignore',
401
+ windowsHide: true,
402
+ env: { ...process.env, KLYPIX_SEMANTIC_WARM_ON_UPDATE: '1' },
403
+ });
404
+ warm.unref();
405
+ semanticWarm = 'scheduled';
406
+ } catch { semanticWarm = 'deferred'; }
407
+ }
384
408
  if (!RUNTIME_ONLY) reportCodex(codex);
385
409
  console.log(`✓ installed klypix brain v${VERSION} → ${BRAIN_DIR} (${n} scripts, ${deps} dep packages)`);
386
410
  if (RUNTIME_ONLY) console.log('✓ runtime-only update: host settings and project files were preserved');
@@ -19,7 +19,7 @@ const PKG_VERSION = (() => {
19
19
  }
20
20
  })();
21
21
 
22
- const DIRECT = new Set(['install', 'link', 'doctor', 'conformance', 'garden-code', 'init', 'git-driver', 'diff', 'pr-brief', 'uninstall']);
22
+ const DIRECT = new Set(['install', 'link', 'doctor', 'runtime', 'conformance', 'garden-code', 'init', 'git-driver', 'diff', 'pr-brief', 'uninstall']);
23
23
 
24
24
  const USAGE = [
25
25
  `klypix-mcp ${PKG_VERSION} — shared project brain + MCP coordination server.`,
@@ -28,6 +28,7 @@ const USAGE = [
28
28
  ' install [--force] [--codex-hooks] install/update this machine\'s brain engine + Claude Code hooks',
29
29
  ' link [dir] [--check] project this project\'s 14 managed agent config files (--check audits, writes nothing, exits 1 on drift)',
30
30
  ' doctor [--npm] [--all] [--json] read-only self-check; exits 1 on drift',
31
+ ' runtime [--json] [--watch seconds] passive MCP process/RAM attribution; never terminates a process',
31
32
  ' conformance [--json] launch two real MCP clients against this build',
32
33
  ' init seed a starter ./brain.klypix + print an MCP config',
33
34
  ' garden-code [brain] print the human approval code for brain_garden',
@@ -54,7 +55,10 @@ if (verb && !verb.startsWith('-') && !DIRECT.has(verb)) {
54
55
  process.exit(2);
55
56
  }
56
57
 
57
- if (DIRECT.has(verb)) {
58
+ if (verb === 'runtime') {
59
+ process.argv.splice(2, 1);
60
+ await import('./klypix-runtime.mjs');
61
+ } else if (DIRECT.has(verb)) {
58
62
  await import('./klypix-worker.mjs');
59
63
  } else {
60
64
  const { runMcpSupervisor } = await import('../src/mcp-supervisor.mjs');
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { formatRuntimeReport, inspectKlypixRuntime } from '../src/runtime-inspector.mjs';
5
+
6
+ const args = process.argv.slice(2);
7
+ const valueAfter = (flag) => {
8
+ const index = args.indexOf(flag);
9
+ return index >= 0 ? args[index + 1] : null;
10
+ };
11
+ const json = args.includes('--json');
12
+ const brainDir = valueAfter('--brain-dir') || path.join(os.homedir(), '.claude', 'project-brain');
13
+ const watchSeconds = Math.max(0, Number(valueAfter('--watch') || 0));
14
+
15
+ const sample = () => {
16
+ try {
17
+ const report = inspectKlypixRuntime({ brainDir });
18
+ process.stdout.write((json ? JSON.stringify(report) : formatRuntimeReport(report)) + '\n');
19
+ } catch (error) {
20
+ const message = error?.message || String(error);
21
+ if (json) process.stdout.write(JSON.stringify({ schemaVersion: 1, passive: true, mutated: false, error: message }) + '\n');
22
+ else process.stderr.write(`KLYPIX runtime inspection failed: ${message}\n`);
23
+ process.exitCode = 1;
24
+ }
25
+ };
26
+
27
+ sample();
28
+ if (watchSeconds > 0) {
29
+ const timer = setInterval(sample, Math.max(5, watchSeconds) * 1_000);
30
+ process.once('SIGINT', () => { clearInterval(timer); process.exit(); });
31
+ process.once('SIGTERM', () => { clearInterval(timer); process.exit(); });
32
+ }
33
+
@@ -0,0 +1,93 @@
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.
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ import path from 'path';
8
+ import { parseKlypix } from '../src/klypix-format.mjs';
9
+ import { readRegisteredProjectBrains } from '../src/mcp-auto-update.mjs';
10
+ import {
11
+ EMBEDDING_CACHE_KEY,
12
+ disposeSemanticModels,
13
+ getEmbedderForUse,
14
+ vectorsForBrain,
15
+ } from '../src/semantic-memory.mjs';
16
+
17
+ const arg = (name, fallback = null) => {
18
+ const index = process.argv.indexOf(`--${name}`);
19
+ return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
20
+ };
21
+ const brainDir = path.resolve(arg('brain-dir', path.join(os.homedir(), '.claude', 'project-brain')));
22
+ const lockFile = path.join(brainDir, '.semantic-update-warm.lock');
23
+ const statusFile = path.join(brainDir, '.semantic-update-warm.json');
24
+ const explicitBrain = arg('brain');
25
+ const startedAt = Date.now();
26
+ let lock = null;
27
+
28
+ const atomicJson = (file, value) => {
29
+ const tmp = `${file}.${process.pid}.tmp`;
30
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
31
+ fs.renameSync(tmp, file);
32
+ };
33
+
34
+ try {
35
+ fs.mkdirSync(brainDir, { recursive: true });
36
+ try {
37
+ lock = fs.openSync(lockFile, 'wx');
38
+ } catch (error) {
39
+ if (error?.code !== 'EEXIST') throw error;
40
+ let stale = false;
41
+ try { stale = Date.now() - fs.statSync(lockFile).mtimeMs > 30 * 60_000; } catch { stale = true; }
42
+ if (!stale) process.exit(0);
43
+ try { fs.unlinkSync(lockFile); } catch { process.exit(0); }
44
+ lock = fs.openSync(lockFile, 'wx');
45
+ }
46
+ fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, startedAt, modelKey: EMBEDDING_CACHE_KEY }));
47
+
48
+ const registered = readRegisteredProjectBrains(brainDir).map(item => item.brainPath);
49
+ const candidates = [...new Set([explicitBrain ? path.resolve(explicitBrain) : null, ...registered].filter(Boolean))]
50
+ .filter(file => {
51
+ try { return fs.statSync(file).isFile(); } catch { return false; }
52
+ });
53
+ if (!candidates.length) {
54
+ atomicJson(statusFile, { protocol: 1, result: 'no-registered-brains', modelKey: EMBEDDING_CACHE_KEY, completedAt: new Date().toISOString() });
55
+ } else {
56
+ const pipe = await getEmbedderForUse(() => {}, 5 * 60_000);
57
+ if (!pipe) {
58
+ atomicJson(statusFile, { protocol: 1, result: 'semantic-unavailable', modelKey: EMBEDDING_CACHE_KEY, completedAt: new Date().toISOString() });
59
+ } else {
60
+ let warmed = 0, failed = 0, cards = 0;
61
+ for (const file of candidates) {
62
+ try {
63
+ const { struct } = await parseKlypix(fs.readFileSync(file));
64
+ const vectors = await vectorsForBrain(pipe, file, struct.cards);
65
+ warmed++;
66
+ cards += vectors.size;
67
+ } catch { failed++; }
68
+ }
69
+ atomicJson(statusFile, {
70
+ protocol: 1,
71
+ result: failed ? (warmed ? 'partial' : 'failed') : 'ready',
72
+ modelKey: EMBEDDING_CACHE_KEY,
73
+ brains: candidates.length,
74
+ warmed,
75
+ failed,
76
+ cards,
77
+ durationMs: Date.now() - startedAt,
78
+ completedAt: new Date().toISOString(),
79
+ });
80
+ }
81
+ }
82
+ } catch {
83
+ // Background migration is fail-open: a later brain_ask can still warm lazily.
84
+ } finally {
85
+ try { await disposeSemanticModels(); } catch { /* */ }
86
+ try { if (lock != null) fs.closeSync(lock); } catch { /* */ }
87
+ try { fs.unlinkSync(lockFile); } catch { /* */ }
88
+ }
89
+
90
+ // onnxruntime can retain background handles after model disposal on Windows.
91
+ // This is a detached one-shot migration, so terminate deterministically after
92
+ // every cache/status write and cleanup has completed.
93
+ process.exit(0);
@@ -278,16 +278,17 @@ server.registerTool('brain_lens', {
278
278
 
279
279
  server.registerTool('brain_connect', {
280
280
  title: 'Connect related-but-unlinked brain cards (densify the graph)',
281
- description: 'Finds genuinely related cards that AREN\'T linked yet and proposes connections — semantic similarity when the on-device model is installed, else shared tags + [[mentions]]. Dry-run by default (review the suggestions); pass apply:true to draw them (ADDITIVE — never deletes; the human can remove any arrow). Use after brain_insights flags many orphans, to turn a flat list into a real knowledge graph. To DISMISS a brain_reconcile false-positive contradiction, pass pairs:[{fromId,toId}] (the ids are in the reconcile output) with relationship:"not_contradiction" — a persisted dismissal that stops that pair ever resurfacing as a candidate.',
281
+ description: 'Repairs orphaned decision/milestone cards first (scope:"orphans", the default) by proposing genuinely related unlinked pairs — semantic similarity at a conservative 0.55 threshold when the on-device model is installed, else shared tags + [[mentions]]. The dry run includes a before→projected orphan receipt; apply:true draws only additive, removable arrows and reports the measured after count. It NEVER archives or rewrites cards. Use scope:"all" for deliberate whole-graph densification. To DISMISS a brain_reconcile false-positive contradiction, pass pairs:[{fromId,toId}] with relationship:"not_contradiction".',
282
282
  inputSchema: {
283
283
  canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
284
284
  apply: z.boolean().optional().describe('false (default) = suggest only; true = draw the connections.'),
285
285
  max: z.number().optional().describe('Max connections to propose/draw (default 24).'),
286
- threshold: z.number().optional().describe('Min semantic similarity 0–1 to link (default 0.45). Higher = fewer, tighter links.'),
286
+ threshold: z.number().optional().describe('Min semantic similarity 0–1. Default 0.55 for orphan repair; 0.45 for scope:"all". Higher = fewer, tighter links.'),
287
+ scope: z.enum(['orphans', 'all']).optional().describe('"orphans" (default) repairs isolated decision/milestone cards; "all" proposes across every live card.'),
287
288
  pairs: z.array(z.object({ fromId: z.string(), toId: z.string() })).optional().describe('Explicit card-id pairs to connect (bypasses auto-proposal). Use to dismiss a reconcile false-positive: pass the two card ids with relationship:"not_contradiction".'),
288
289
  relationship: z.string().optional().describe('Relationship for explicit `pairs` (e.g. "not_contradiction" to permanently dismiss a contradiction candidate, or "relates_to", "depends_on", "supports").'),
289
290
  },
290
- }, async ({ canvas, apply, max, threshold, pairs, relationship }) => toContent(await opBrainConnect({ vault: mcpPresence.vault, canvas, apply, max, threshold, pairs, relationship, log })));
291
+ }, async ({ canvas, apply, max, threshold, scope, pairs, relationship }) => toContent(await opBrainConnect({ vault: mcpPresence.vault, canvas, apply, max, threshold, scope, pairs, relationship, log })));
291
292
 
292
293
  server.registerTool('brain_reconcile', {
293
294
  title: 'Reconcile the brain — contradictions between cards + unrecorded migrations',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.51.1",
3
+ "version": "1.52.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -37,6 +37,7 @@
37
37
  "klypix-conformance": "bin/klypix-conformance.mjs",
38
38
  "klypix-link": "bin/klypix-link.mjs",
39
39
  "klypix-doctor": "bin/klypix-doctor.mjs",
40
+ "klypix-runtime": "bin/klypix-runtime.mjs",
40
41
  "klypix-a2a": "bin/klypix-a2a.mjs",
41
42
  "klypix-read": "bin/klypix-read.mjs",
42
43
  "klypix-write": "bin/klypix-write.mjs",
@@ -53,6 +54,8 @@
53
54
  "./mcp-presence": "./src/mcp-presence.mjs",
54
55
  "./presence-relay": "./src/presence-relay.mjs",
55
56
  "./supervisor": "./src/mcp-supervisor.mjs",
57
+ "./runtime-inspector": "./src/runtime-inspector.mjs",
58
+ "./runtime": "./src/runtime-inspector.mjs",
56
59
  "./auto-update": "./src/mcp-auto-update.mjs"
57
60
  },
58
61
  "files": [
@@ -70,9 +73,10 @@
70
73
  "node": ">=18"
71
74
  },
72
75
  "scripts": {
73
- "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
76
+ "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
74
77
  "test:memory": "node test/memory-runtime.mjs",
75
- "test:memory:soak": "node --expose-gc test/memory-soak.mjs"
78
+ "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
79
+ "runtime": "node bin/klypix-runtime.mjs"
76
80
  },
77
81
  "dependencies": {
78
82
  "@modelcontextprotocol/ext-apps": "^1.7.4",
@@ -225,6 +225,12 @@ export function upsertSession({
225
225
  ? String(intent || '').replace(/\s+/g, ' ').trim().slice(0, 160)
226
226
  : (previous.intent || '');
227
227
  const intentChanged = intent !== undefined && nextIntent !== (previous.intent || '');
228
+ // A connection heartbeat proves transport liveness, not that a task is in
229
+ // progress. Stamp only events that carry real user/tool work so doctor can
230
+ // distinguish an idle connected host from an active sync-silent session.
231
+ const activityEvent = /^(?:McpToolUse|McpTaskStart|McpTaskCheckpoint|UserPromptSubmit|PreToolUse|PostToolUse)$/i.test(String(event || ''))
232
+ ? String(event)
233
+ : null;
228
234
  const next = {
229
235
  ...previous,
230
236
  id: String(id),
@@ -240,6 +246,9 @@ export function upsertSession({
240
246
  : (previous.intentAt ? { intentAt: previous.intentAt, intentSource: previous.intentSource || null } : {})),
241
247
  files: mergedFiles,
242
248
  event: event ?? previous.event ?? null,
249
+ ...(activityEvent
250
+ ? { activityAt: now, activityKind: activityEvent }
251
+ : (previous.activityAt ? { activityAt: previous.activityAt, activityKind: previous.activityKind || null } : {})),
243
252
  ...(Object.keys(channelSeen).length
244
253
  ? { channels: Object.keys(channelSeen), channelSeen }
245
254
  : {}),
@@ -263,6 +263,13 @@ function inspectPeers(brainDir, brainPath, now, selfId = null) {
263
263
  hostPid: s.hostPid || null,
264
264
  // Sync-rich vs sync-silent: has this session declared any task scope?
265
265
  synced: Boolean(String(s.intent || '').trim() || (Array.isArray(s.files) && s.files.length)),
266
+ // Transport liveness is not task activity. Only real prompt/tool work
267
+ // stamps activityAt; heartbeats deliberately preserve rather than refresh it.
268
+ activityAt: Number(s.activityAt || 0) || null,
269
+ activityKind: s.activityKind || null,
270
+ activeUnscoped: !Boolean(String(s.intent || '').trim() || (Array.isArray(s.files) && s.files.length))
271
+ && Number(s.activityAt || 0) > 0
272
+ && now - Number(s.activityAt) < SESSION_FRESH_MS,
266
273
  lastSeenMin: Math.round((now - liveness.lastSeen) / 60000),
267
274
  }));
268
275
  // Twin rows = channel merge NOT occurring: >1 live row sharing one hostPid is
@@ -278,7 +285,13 @@ function inspectPeers(brainDir, brainPath, now, selfId = null) {
278
285
  now,
279
286
  })
280
287
  : { sent: 0, receipts: [] };
281
- return { file, live, count: live.length, rawRecent, syncedCount: live.filter(p => p.synced).length, twinGroups, receipts };
288
+ const syncedCount = live.filter(p => p.synced).length;
289
+ const activeUnscopedCount = live.filter(p => p.activeUnscoped).length;
290
+ return {
291
+ file, live, count: live.length, rawRecent, syncedCount, activeUnscopedCount,
292
+ idleUnscopedCount: Math.max(0, live.length - syncedCount - activeUnscopedCount),
293
+ twinGroups, receipts,
294
+ };
282
295
  }
283
296
 
284
297
  // ── DECAY-GUARD layer (fast-decay status protection, 2026-07-28) ─────────────
@@ -408,9 +421,9 @@ export function inspect(opts = {}) {
408
421
  // coordination. That is a readiness gap, not file/runtime drift: say PARTIAL
409
422
  // instead of the misleading all-clear that prompted the field audit.
410
423
  const readinessWarnings = [];
411
- const silentSessions = Math.max(0, peers.count - peers.syncedCount);
412
- if (peers.count > 1 && silentSessions > 0) {
413
- readinessWarnings.push(`${silentSessions} of ${peers.count} active sessions have not declared task scope`);
424
+ const activeSilentSessions = peers.activeUnscopedCount || 0;
425
+ if (peers.count > 1 && activeSilentSessions > 0) {
426
+ readinessWarnings.push(`${activeSilentSessions} active session${activeSilentSessions === 1 ? '' : 's'} used KLYPIX without declared task scope (${peers.count} live connections total)`);
414
427
  }
415
428
  if (Number(autoUpdate.harness?.failed || 0) > 0) {
416
429
  readinessWarnings.push(`${autoUpdate.harness.failed} automatic harness repair(s) remain partial`);
@@ -570,10 +583,17 @@ export function render(r, opts = {}) {
570
583
  if (!r.sessions.count) L.push(`${ok} ${c.bold}SESSIONS${c.rst} 0 active sessions ${c.dim}(saved/recent chats are history, not active)${c.rst}`);
571
584
  else {
572
585
  const hiddenIdle = Math.max(0, (r.sessions.rawRecent || r.sessions.count) - r.sessions.count);
573
- L.push(`${r.sessions.count > 1 ? warn : ok} ${c.bold}SESSIONS${c.rst} ${r.sessions.count} active session${r.sessions.count === 1 ? '' : 's'} · ${r.sessions.syncedCount ?? r.sessions.count} with declared task scope ${c.dim}(all hosts; channel-aware liveness${hiddenIdle ? `; +${hiddenIdle} row(s) with only a dead mcp channel excluded` : ''})${c.rst}`);
586
+ const activeSilent = r.sessions.activeUnscopedCount || 0;
587
+ const idleConnections = r.sessions.idleUnscopedCount || 0;
588
+ L.push(`${activeSilent ? warn : ok} ${c.bold}SESSIONS${c.rst} ${r.sessions.count} live connection${r.sessions.count === 1 ? '' : 's'} · ${r.sessions.syncedCount ?? r.sessions.count} with declared task scope${activeSilent ? ` · ${activeSilent} active without scope` : ''}${idleConnections ? ` · ${idleConnections} idle/unscoped (not graded)` : ''} ${c.dim}(all hosts; channel-aware liveness${hiddenIdle ? `; +${hiddenIdle} row(s) with only a dead mcp channel excluded` : ''})${c.rst}`);
574
589
  for (const p of r.sessions.live) {
575
590
  const intentAge = p.intentAgeMin !== null && p.intentAgeMin - p.lastSeenMin > 3 ? ` (set ${p.intentAgeMin}m ago)` : '';
576
- L.push(` · ${p.client}:${p.id}${p.branch ? ' @' + p.branch : ''}${p.channels.length ? ` [${p.channels.join('+')}]` : ''}${p.intent ? ` “${p.intent.slice(0, 50)}”${intentAge}` : ''}${p.synced ? '' : ` ${c.yel}· sync-silent (no brain_sync intent/scope)${c.rst}`} ${c.dim}(${p.lastSeenMin}m ago)${c.rst}`);
591
+ const scopeState = p.synced
592
+ ? ''
593
+ : (p.activeUnscoped
594
+ ? ` ${c.yel}· active sync-silent (used KLYPIX without brain_sync scope)${c.rst}`
595
+ : ` ${c.dim}· idle connection (no task activity to grade)${c.rst}`);
596
+ L.push(` · ${p.client}:${p.id}${p.branch ? ' @' + p.branch : ''}${p.channels.length ? ` [${p.channels.join('+')}]` : ''}${p.intent ? ` “${p.intent.slice(0, 50)}”${intentAge}` : ''}${scopeState} ${c.dim}(${p.lastSeenMin}m ago)${c.rst}`);
577
597
  }
578
598
  for (const g of (r.sessions.twinGroups || [])) {
579
599
  L.push(` ${c.yel}⚠ twin rows — channel merge NOT occurring: host pid ${g.hostPid} appears as ${g.ids.join(' + ')} (one logical session counted ${g.ids.length}×)${c.rst}`);
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // WHY a separate module: the hook's per-prompt recall is LEXICAL (keyword) — fast
4
4
  // and bulletproof. Semantic (embedding) recall would understand paraphrase, but the
5
- // hook is a ONE-SHOT process (no daemon allowed), so loading the 23MB MiniLM model
6
- // costs ~550ms EVERY prompt — unacceptable on the common path. The measured-and-
5
+ // hook is a ONE-SHOT process (no daemon allowed), so loading an embedding model
6
+ // on EVERY prompt is unacceptable on the common path. The measured-and-
7
7
  // verified design is therefore: run semantic ONLY when lexical found nothing (the
8
8
  // paraphrase / keyword-miss case, where you'd otherwise get zero recall), pay the
9
9
  // cost just there, timeout-bounded, and read-only.
@@ -22,6 +22,13 @@ import crypto from 'crypto';
22
22
  const PB_DIR = path.join(os.homedir(), '.claude', 'project-brain');
23
23
  const EMB_DIR = path.join(PB_DIR, 'embeddings');
24
24
  const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
25
+ // Keep these values byte-identical to semantic-memory.mjs. The one-shot hook is
26
+ // intentionally standalone, so it does not import the long-lived runtime merely
27
+ // to read configuration.
28
+ export const HOOK_EMBEDDING_MODEL_ID = 'Xenova/bge-small-en-v1.5';
29
+ export const HOOK_EMBEDDING_QUERY_PREFIX = 'Represent this sentence for searching relevant passages: ';
30
+ export const HOOK_EMBEDDING_POOLING = 'cls';
31
+ export const HOOK_EMBEDDING_CACHE_KEY = `${HOOK_EMBEDDING_MODEL_ID}|q8|${HOOK_EMBEDDING_POOLING}|query-instruction-v1`;
25
32
 
26
33
  // Memoized within THIS process only (a one-shot hook run) — across prompts it
27
34
  // reloads cold, which is exactly why we only pay it on a lexical miss.
@@ -41,36 +48,48 @@ function getEmbedder() {
41
48
  if (!t) throw last;
42
49
  }
43
50
  t.env.cacheDir = path.join(PB_DIR, 'hf-cache');
44
- return await t.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
51
+ return await t.pipeline('feature-extraction', HOOK_EMBEDDING_MODEL_ID, { dtype: 'q8' });
45
52
  })().catch(() => null); // not installed / load fail → null → lexical fallback
46
53
  return _embedder;
47
54
  }
48
55
 
49
- async function embedTexts(pipe, texts) {
50
- const out = await pipe(texts, { pooling: 'mean', normalize: true });
51
- const [n, d] = out.dims;
52
- const vecs = [];
53
- for (let i = 0; i < n; i++) vecs.push(Array.from(out.data.slice(i * d, (i + 1) * d)));
54
- return vecs;
56
+ async function embedQueries(pipe, texts) {
57
+ let out = null;
58
+ try {
59
+ const instructed = texts.map(text => `${HOOK_EMBEDDING_QUERY_PREFIX}${text}`);
60
+ out = await pipe(instructed, { pooling: HOOK_EMBEDDING_POOLING, normalize: true });
61
+ const [n, d] = out.dims;
62
+ const vecs = [];
63
+ for (let i = 0; i < n; i++) vecs.push(Array.from(out.data.slice(i * d, (i + 1) * d)));
64
+ return vecs;
65
+ } finally {
66
+ try { out?.dispose?.(); } catch { /* one-shot process exit remains the fallback */ }
67
+ }
55
68
  }
56
69
  // Vectors are unit-normalized, so dot == cosine.
57
70
  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; };
58
71
 
59
72
  // READ-ONLY card-vector cache — NEVER embeds or writes (embedding all cards in the
60
73
  // per-prompt process is the multi-second stall we forbid). Reuses the SAME warm
61
- // cache the MCP host fills, keyed sha1(path, slashes-only). The MCP keys WITHOUT
62
- // lowercasing the drive letter, so a brain can have an `e:`-keyed and an `E:`-keyed
63
- // cache file — try the drive-case variants so we hit whichever exists.
74
+ // cache the MCP host fills. Current workers canonicalize absolute Windows paths
75
+ // to a lowercase drive letter; legacy caches used raw drive casing, so probe both
76
+ // identities while accepting only the exact current modelKey.
64
77
  function readCachedVecs(brainPath, cards) {
65
78
  const slashed = String(brainPath).replace(/\\/g, '/');
79
+ const resolved = path.resolve(String(brainPath)).replace(/\\/g, '/');
66
80
  const variants = [...new Set([
81
+ resolved.replace(/^([a-zA-Z]):/, (_m, d) => d.toLowerCase() + ':'),
82
+ resolved,
67
83
  slashed,
68
84
  slashed.replace(/^([a-zA-Z]):/, (_m, d) => d.toLowerCase() + ':'),
69
85
  slashed.replace(/^([a-zA-Z]):/, (_m, d) => d.toUpperCase() + ':'),
70
86
  ])];
71
87
  let cache = null;
72
88
  for (const v of variants) {
73
- try { const c = JSON.parse(fs.readFileSync(path.join(EMB_DIR, sha1(v) + '.json'), 'utf8')); if (c && c.cards) { cache = c; break; } } catch { /* try next variant */ }
89
+ try {
90
+ const c = JSON.parse(fs.readFileSync(path.join(EMB_DIR, sha1(v) + '.json'), 'utf8'));
91
+ if (c?.modelKey === HOOK_EMBEDDING_CACHE_KEY && c.cards) { cache = c; break; }
92
+ } catch { /* try next variant */ }
74
93
  }
75
94
  const map = new Map();
76
95
  if (cache && cache.cards) for (const c of cards) { const e = cache.cards[c.id]; if (e && e.v) map.set(c.id, e.v); }
@@ -98,7 +117,7 @@ export async function semanticVecs(brainPath, struct, query, { timeoutMs = 1200
98
117
  if (!vecsMap.size) return null;
99
118
  const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), timeoutMs))]);
100
119
  if (!pipe) return null;
101
- const [qv] = await embedTexts(pipe, [q]);
120
+ const [qv] = await embedQueries(pipe, [q]);
102
121
  if (!qv) return null;
103
122
  return { qv, vecsMap, dot };
104
123
  } catch { return null; }
@@ -390,6 +390,10 @@ function touchSession(sid, patch = {}) {
390
390
  const wantsIntent = patch.intent !== undefined && !declaredFresh;
391
391
  const nextIntent = wantsIntent ? patch.intent : (prev.intent ?? '');
392
392
  const intentChanged = wantsIntent && nextIntent !== (prev.intent ?? '');
393
+ const recordsActivity = patch.intent !== undefined
394
+ || patch.addFiles !== undefined
395
+ || patch.files !== undefined
396
+ || patch.ships !== undefined;
393
397
  list.push({
394
398
  ...prev, // unknown/foreign keys (an MCP writer's, a future field) survive this touch
395
399
  id: sid, pid: process.pid, project: path.basename(CWD),
@@ -421,6 +425,9 @@ function touchSession(sid, patch = {}) {
421
425
  // list silently dropped unknown patch keys, which made the
422
426
  // dedup dead code (review-caught).
423
427
  statusDigestHash: patch.statusDigestHash !== undefined ? patch.statusDigestHash : (prev.statusDigestHash ?? null),
428
+ ...(recordsActivity
429
+ ? { activityAt: now, activityKind: patch.intent !== undefined ? 'UserPromptSubmit' : 'ObservedWork' }
430
+ : (prev.activityAt ? { activityAt: prev.activityAt, activityKind: prev.activityKind || null } : {})),
424
431
  channels: Object.keys(channelSeen), channelSeen,
425
432
  startedAt: prev.startedAt || now, lastSeen: now,
426
433
  });