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.
- package/bin/klypix-install.mjs +4 -1
- package/bin/klypix-mcp.mjs +24 -0
- package/package.json +4 -1
- package/src/agent-rules.mjs +14 -5
- package/src/global-brain-hook.mjs +116 -11
package/bin/klypix-install.mjs
CHANGED
|
@@ -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
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -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.
|
|
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",
|
package/src/agent-rules.mjs
CHANGED
|
@@ -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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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()
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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 };
|