klypix-mcp 1.13.0 → 1.14.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.
@@ -58,13 +58,16 @@ const flatten = (code) => code
58
58
  .replace(/from '\.\.\/src\/klypix-core\.mjs'/g, "from './klypix-core.mjs'")
59
59
  .replace(/from '\.\.\/src\/klypix-format\.mjs'/g, "from './klypix-format.mjs'")
60
60
  .replace(/\.\.\/src\/klypix-(core|format)\.mjs/g, './klypix-$1.mjs')
61
+ // brain-doctor + agent-rules (the server's lazy `import('../src/brain-doctor.mjs')`
62
+ // for the brain_doctor tool) → flat sibling refs in the runtime layout.
63
+ .replace(/\.\.\/src\/(brain-doctor|agent-rules)\.mjs/g, './$1.mjs')
61
64
  .replace(/const PKG_VERSION = \(\(\) => \{[\s\S]*?\}\)\(\);/, `const PKG_VERSION = '${VERSION}'; // baked at install (flat layout has no package.json)`);
62
65
 
63
66
  try {
64
67
  fs.mkdirSync(BRAIN_DIR, { recursive: true });
65
68
  // 1) flat engine + hook scripts (their imports are already './…' → verbatim)
66
69
  let n = 0;
67
- for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs']) {
70
+ for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'agent-rules.mjs', 'brain-doctor.mjs']) {
68
71
  const s = path.join(SRC, f); if (exists(s)) { fs.writeFileSync(path.join(BRAIN_DIR, f), fs.readFileSync(s, 'utf8')); n++; }
69
72
  }
70
73
  // 2) the two servers, flattened to the *-server.mjs names the runtime/config expect
@@ -199,6 +199,30 @@ server.registerTool('brain_note', {
199
199
  return toContent(await opBrainNote({ vault: VAULT, canvas, text, area, marker: marker || '', closes, via }));
200
200
  });
201
201
 
202
+ server.registerTool('brain_doctor', {
203
+ title: 'Brain doctor — is this brain current, wired, and in sync?',
204
+ description: 'Read-only self-check of the installed klypix brain, as ONE verdict: VERSION (the deployed brain-core version + optional npm-latest currency), HOOKS (are all 4 Claude Code hooks wired — liveness vs readiness), TOOLS (the discoverable MCP verb manifest), PEERS (other live sessions on this project\'s brain right now), and HARNESS (per-file projection drift: ok/stale/hand-edited/missing). Use to answer "is my brain current + correctly installed + in sync, and who else is live?" without file-spelunking. Never writes. The MCP-callable twin of `npx klypix-mcp doctor`.',
205
+ inputSchema: {
206
+ project: z.string().optional().describe('Project dir to audit harness + peers for. Defaults to the server\'s working directory.'),
207
+ check_npm: z.boolean().optional().describe('Also fetch npm latest to flag a stale brain (default false — this one does a network `npm view`).'),
208
+ },
209
+ }, async ({ project, check_npm }) => {
210
+ try {
211
+ // Lazy import so a flat runtime missing brain-doctor.mjs can't crash server STARTUP —
212
+ // the tool degrades gracefully (errors only when called) instead of taking the server down.
213
+ const { inspect, render } = await import('../src/brain-doctor.mjs');
214
+ let npmLatest = null;
215
+ if (check_npm) {
216
+ try { const { execSync } = await import('child_process'); npmLatest = execSync('npm view klypix-mcp version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 8000 }).trim(); }
217
+ catch { npmLatest = '(offline)'; }
218
+ }
219
+ const report = inspect({ projectDir: project ? path.resolve(project) : process.cwd(), npmLatest });
220
+ return { content: [{ type: 'text', text: render(report, { color: false }) }] };
221
+ } catch (e) {
222
+ return { content: [{ type: 'text', text: `brain_doctor unavailable: ${e?.message || e}` }], isError: true };
223
+ }
224
+ });
225
+
202
226
  const transport = new StdioServerTransport();
203
227
  await server.connect(transport);
204
228
  log(`ready · vault=${VAULT}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "An open, local-first, agent-neutral canvas file your AI reads and writes over MCP — works with Claude, Cursor, Cline, any model.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,6 +52,9 @@
52
52
  "engines": {
53
53
  "node": ">=18"
54
54
  },
55
+ "scripts": {
56
+ "test": "node test/brain-doctor.mjs && node test/version-currency.mjs"
57
+ },
55
58
  "dependencies": {
56
59
  "@modelcontextprotocol/sdk": "^1.29.0",
57
60
  "jszip": "^3.10.1",
@@ -152,13 +152,22 @@ function mergeMcpJson(file, wrapKey, withType) {
152
152
  return { action: before === undefined ? 'created' : 'updated' };
153
153
  }
154
154
 
155
- // Check-only classifier for an MCP json: is the klypix-canvas server wired?
155
+ // Check-only classifier for an MCP json: is the klypix-canvas server wired AND does
156
+ // its entry still actually launch klypix-mcp? Presence alone isn't enough — a hand-edit
157
+ // that mangles the command/args (so the server never starts) reads as "wired" but is
158
+ // broken. We DON'T strict-hash the entry: a customized `--vault <path>` is legitimate,
159
+ // so only the invocation (npx/node … klypix-mcp …) must survive; if it doesn't, that's
160
+ // drift the user needs to re-`link`.
156
161
  function classifyMcp(file, wrapKey) {
157
162
  if (!exists(file)) return { status: 'missing' };
158
- try {
159
- const cfg = JSON.parse(fs.readFileSync(file, 'utf8') || '{}');
160
- return cfg?.[wrapKey]?.['klypix-canvas'] ? { status: 'ok' } : { status: 'missing' };
161
- } catch { return { status: 'hand-edited', why: 'invalid JSON' }; }
163
+ let cfg;
164
+ try { cfg = JSON.parse(fs.readFileSync(file, 'utf8') || '{}'); }
165
+ catch { return { status: 'hand-edited', why: 'invalid JSON' }; }
166
+ const entry = cfg?.[wrapKey]?.['klypix-canvas'];
167
+ if (!entry) return { status: 'missing' };
168
+ const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
169
+ const launches = (entry.command === 'npx' || entry.command === 'node') && args.some(a => a.includes('klypix-mcp'));
170
+ return launches ? { status: 'ok' } : { status: 'hand-edited', why: 'entry no longer launches klypix-mcp' };
162
171
  }
163
172
 
164
173
  // The projection map — the single source of truth shared by WRITE (linkProject) and
@@ -21,6 +21,7 @@ import fs from 'fs';
21
21
  import os from 'os';
22
22
  import path from 'path';
23
23
  import crypto from 'crypto';
24
+ import https from 'https';
24
25
  import { execSync } from 'child_process';
25
26
 
26
27
  const CWD = process.cwd();
@@ -41,6 +42,9 @@ const STATE = path.resolve(CWD, '.claude', 'brain-capture-state.json');
41
42
  // flag the card when that code drifts.
42
43
  const MARKER = /🧠\s*BRAIN\s*(?:\[([^\]]+)\])?\s*([?!✓~+]?)\s*:\s*(.+)$/i;
43
44
  const sha = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
45
+ // Numeric semver compare (major.minor.patch; pre-release tags ignored). <0 if
46
+ // a<b, 0 equal, >0 if a>b. Shared by the version-currency footer below.
47
+ const cmpSemver = (a, b) => { const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0), pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0); for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); } return 0; };
44
48
 
45
49
  // ── Evidence anchors + close-links (decision lifecycle) ──────────────────────
46
50
  // `git rev-parse HEAD:<path>` → the file's blob OID at HEAD (stable across
@@ -207,6 +211,11 @@ function readHookInput() {
207
211
  // bytes — so a dead/stale/unsynced live copy stops being invisible.
208
212
  const LEDGER = path.resolve(CWD, '.claude', 'brain-capture-log.jsonl');
209
213
  const HEALTH = path.join(os.homedir(), '.claude', 'project-brain', '.hook-health.jsonl');
214
+ // npm-currency cache — the Stop hook refreshes this at most once/day (best-effort,
215
+ // failure-silent); the SessionStart footer reads ONLY this file (zero network) to
216
+ // surface a stale install. {pkg, latest, checkedAt, lastError?}.
217
+ const NPM_CURRENCY = path.join(os.homedir(), '.claude', 'project-brain', '.npm-currency.json');
218
+ const NPM_CURRENCY_TTL = 24 * 60 * 60 * 1000; // ≤ once/day refresh throttle
210
219
  const LOCK = path.resolve(CWD, '.claude', 'brain-capture.lock'); // serialize concurrent captures
211
220
  const DRY = process.argv.includes('--dry-run'); // inspect a capture without writing
212
221
  const nowIso = () => { try { return new Date().toISOString(); } catch { return ''; } };
@@ -1250,6 +1259,87 @@ function selfCheckFooter() {
1250
1259
  } catch { return ''; }
1251
1260
  }
1252
1261
 
1262
+ // ── Version-currency — the brain surfaces its OWN staleness, ambiently ───────
1263
+ // doctorFooter (below) is deliberately network-free, so a stale install never
1264
+ // announced itself until someone ran `npx klypix-mcp doctor --npm` — the human was
1265
+ // the drift detector (the desktop-lag incident). These two functions close that gap
1266
+ // WITHOUT breaking the no-network-in-session-start rule:
1267
+ // • refreshNpmCurrency() runs on the Stop hook (post-session), ≤ once/day,
1268
+ // best-effort + failure-silent — it fetches npm `latest` into a local cache.
1269
+ // • versionCurrencyFooter() runs at SessionStart and reads ONLY that cache
1270
+ // (pure fs, NO fetcher) — so it CANNOT make a network call by construction.
1271
+ // `doctor` stays the authoritative on-demand full check (unchanged).
1272
+
1273
+ // The ONLY network path (kept separate so the footer is fetcher-less). Resolves the
1274
+ // published `latest` via the registry's lightweight per-version endpoint. Zero deps
1275
+ // (node https), tight timeout, rejects on any failure. Injectable in tests.
1276
+ function httpsFetchLatest(pkg = 'klypix-mcp', timeoutMs = 4000) {
1277
+ return new Promise((resolve, reject) => {
1278
+ // GET /{pkg}/latest with the DEFAULT json accept — the abbreviated
1279
+ // `vnd.npm.install-v1+json` type is only served by the full packument
1280
+ // endpoint and 406s here. This returns the latest version manifest (~few KB).
1281
+ const req = https.get(`https://registry.npmjs.org/${pkg}/latest`,
1282
+ { headers: { accept: 'application/json' } }, (res) => {
1283
+ if (res.statusCode !== 200) { res.resume(); return reject(new Error('http ' + res.statusCode)); }
1284
+ let body = '';
1285
+ res.setEncoding('utf8');
1286
+ res.on('data', (c) => { body += c; if (body.length > 1_000_000) req.destroy(new Error('too large')); });
1287
+ res.on('end', () => { try { const v = JSON.parse(body).version; v ? resolve(String(v)) : reject(new Error('no version')); } catch (e) { reject(e); } });
1288
+ });
1289
+ req.on('error', reject);
1290
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout')));
1291
+ });
1292
+ }
1293
+
1294
+ // Throttled (≤ once/day), best-effort, failure-silent refresh of the npm-latest
1295
+ // cache — runs on the Stop hook only. NEVER throws. `now`/`fetcher`/`file`/`ttl`
1296
+ // are injectable so tests stay hermetic (no real network). A failed fetch still
1297
+ // stamps `checkedAt` (so we don't hammer when offline) and keeps a prior good
1298
+ // `latest`. Returns a small status object; callers ignore it.
1299
+ async function refreshNpmCurrency({ now = Date.now(), fetcher = httpsFetchLatest, file = NPM_CURRENCY, ttl = NPM_CURRENCY_TTL, pkg = 'klypix-mcp' } = {}) {
1300
+ try {
1301
+ let prev = null;
1302
+ try { prev = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* no cache yet */ }
1303
+ if (prev && Number.isFinite(prev.checkedAt) && (now - prev.checkedAt) < ttl) return { skipped: 'throttled', prev };
1304
+ let latest = (prev && prev.latest) || null, lastError = null;
1305
+ try { latest = await fetcher(pkg); } catch (e) { lastError = String((e && e.message) || e).slice(0, 120); }
1306
+ const next = { pkg, latest: latest || null, checkedAt: now, ...(lastError ? { lastError } : {}) };
1307
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(next, null, 2)); } catch { /* best-effort */ }
1308
+ return { fetched: !lastError, latest: next.latest, lastError };
1309
+ } catch { return { skipped: 'error' }; }
1310
+ }
1311
+
1312
+ // Read the BAKED brain-core version from the deployed klypix-mcp-server.mjs — the
1313
+ // channel-independent source of truth (the install stamp's version key varies by
1314
+ // channel). Null when not deployed (a dev source checkout w/o a server file) → the
1315
+ // footer then stays silent (nothing to compare).
1316
+ function bakedBrainVersion(brainDir = path.dirname(NPM_CURRENCY)) {
1317
+ try {
1318
+ const m = fs.readFileSync(path.join(brainDir, 'klypix-mcp-server.mjs'), 'utf8').match(/const PKG_VERSION = '([^']+)'/);
1319
+ return m ? m[1] : null;
1320
+ } catch { return null; }
1321
+ }
1322
+
1323
+ // SessionStart footer — ambient version drift. Reads ONLY the local cache (zero
1324
+ // network, no fetcher) + the baked version, and emits ONE advisory line when the
1325
+ // installed brain is behind npm `latest`. SILENT when current/ahead, when the cache
1326
+ // is missing or unknown ("(offline)" sentinel), or when there's no baked version to
1327
+ // compare against — no nag, no noise.
1328
+ function versionCurrencyFooter({ file = NPM_CURRENCY, brainDir = path.dirname(NPM_CURRENCY) } = {}) {
1329
+ try {
1330
+ let cache; try { cache = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return ''; }
1331
+ const latest = cache && cache.latest;
1332
+ // Require a well-formed semver before comparing — rejects missing, the
1333
+ // "(offline)" sentinel, and any hand-corrupted cache value (e.g. `123`,
1334
+ // `v1.14.0`) that could otherwise false-nag or false-silence. Silent.
1335
+ if (!latest || !/^\d+\.\d+\.\d+/.test(String(latest))) return '';
1336
+ const baked = bakedBrainVersion(brainDir);
1337
+ if (!baked) return ''; // nothing to compare → silent
1338
+ if (cmpSemver(latest, baked) <= 0) return ''; // current or ahead → silent
1339
+ return `\n\n---\n⚠️ **Brain update available** — installed brain core \`v${baked}\` < npm latest \`v${latest}\`. Update: \`npx klypix-mcp install\`.\n`;
1340
+ } catch { return ''; }
1341
+ }
1342
+
1253
1343
  // ── Readiness footer — catch a HALF-WIRED install (liveness ≠ readiness) ─────
1254
1344
  // SessionStart firing proves the brain is ALIVE; but the OTHER three hooks are what
1255
1345
  // make it LEARN — UserPromptSubmit (recall), Stop (capture), PostToolUse (live sync).
@@ -1326,7 +1416,7 @@ async function read(lib) {
1326
1416
  : lib.structToMarkdown(struct);
1327
1417
  // ⚡ In-flight footer goes RIGHT AFTER the brief (highest signal: what a peer
1328
1418
  // shipped seconds ago, before it's in the brain) — closes the 1.3.17-blindness gap.
1329
- process.stdout.write(outStr + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct) + selfCheckFooter() + doctorFooter() + messageFooter(input.session_id || '') + legendFooter() + memoryFooter());
1419
+ process.stdout.write(outStr + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct) + selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + messageFooter(input.session_id || '') + legendFooter() + memoryFooter());
1330
1420
  // Heartbeat: prove the brief actually injected (and how big) so a dead or
1331
1421
  // stale live-copy of the hook stops being a silent no-op.
1332
1422
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(outStr), cards: struct?.counts?.cards ?? null }, 500);
@@ -1378,15 +1468,30 @@ async function main() {
1378
1468
  // try/finally so the clear runs across capture()'s early returns AND a throw
1379
1469
  // (on throw, ship/milestone re-capture next Stop; a version is on disk — no loss).
1380
1470
  try { await capture(lib); } finally { clearLiveLedgerForSession(readHookInput().session_id); }
1471
+ // Ambient version-currency: piggyback the post-session Stop hook to refresh the
1472
+ // npm-latest cache (≤ once/day, best-effort, failure-silent) so the next
1473
+ // SessionStart footer can surface a stale install with ZERO network. Awaited so
1474
+ // the throttled request finishes before exit; bulletproof (never throws/blocks).
1475
+ await refreshNpmCurrency().catch(() => {});
1381
1476
  } else await read(lib);
1382
1477
  }
1383
- main().catch((e) => {
1384
- // The whole point of the observability work: a real failure (missing jszip
1385
- // at the live path, unreadable transcript, corrupt brain) used to vanish
1386
- // here. Now it leaves a breadcrumb — without breaking the never-throw,
1387
- // always-exit-0 contract.
1388
- try {
1389
- const mode = process.argv.includes('--prompt') ? 'prompt' : process.argv.includes('--capture') ? 'capture' : 'read';
1390
- appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode, ok: false, err: String((e && e.message) || e).slice(0, 200) }, 500);
1391
- } catch { /* even the breadcrumb is best-effort */ }
1392
- }).finally(() => process.exit(0));
1478
+ // Run as the hook by default. The ONLY way main() is skipped is the explicit
1479
+ // opt-out flag a hermetic test sets before importing this module for its pure
1480
+ // exports — production never sets it, so runtime behavior is byte-identical (no
1481
+ // fragile entry-point/argv path-matching in the most safety-critical hook).
1482
+ if (!process.env.KLYPIX_BRAIN_NO_MAIN) {
1483
+ main().catch((e) => {
1484
+ // The whole point of the observability work: a real failure (missing jszip
1485
+ // at the live path, unreadable transcript, corrupt brain) used to vanish
1486
+ // here. Now it leaves a breadcrumb — without breaking the never-throw,
1487
+ // always-exit-0 contract.
1488
+ try {
1489
+ const mode = process.argv.includes('--prompt') ? 'prompt' : process.argv.includes('--capture') ? 'capture' : 'read';
1490
+ appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode, ok: false, err: String((e && e.message) || e).slice(0, 200) }, 500);
1491
+ } catch { /* even the breadcrumb is best-effort */ }
1492
+ }).finally(() => process.exit(0));
1493
+ }
1494
+
1495
+ // Exported for hermetic unit tests only (gated by KLYPIX_BRAIN_NO_MAIN above so the
1496
+ // import doesn't run main()/exit the test). Not part of the runtime hook contract.
1497
+ export { refreshNpmCurrency, versionCurrencyFooter, bakedBrainVersion, httpsFetchLatest, cmpSemver };