klypix-mcp 1.10.0 → 1.11.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.
@@ -21,7 +21,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
21
  const PKG_ROOT = path.resolve(__dirname, '..');
22
22
  const SRC = path.join(PKG_ROOT, 'src');
23
23
  const BIN = path.join(PKG_ROOT, 'bin');
24
- const MODS = path.join(PKG_ROOT, 'node_modules');
25
24
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version || ''; } catch { return ''; } })();
26
25
  const FORCE = process.argv.includes('--force');
27
26
 
@@ -73,32 +72,40 @@ try {
73
72
  const s = path.join(BIN, src); if (exists(s)) { fs.writeFileSync(path.join(BRAIN_DIR, dst), flatten(fs.readFileSync(s, 'utf8'))); n++; }
74
73
  }
75
74
  // 3) runtime dependency CLOSURE (jszip+fractional-indexing for the hook/engine,
76
- // @modelcontextprotocol/sdk+zod for the local MCP server) — walk each
77
- // package.json's deps + nested node_modules so the SDK's transitive tree comes
78
- // along. @huggingface/transformers (optional, huge) is intentionally skipped —
79
- // semantic recall degrades to lexical until the host warms it.
75
+ // @modelcontextprotocol/sdk+zod for the local MCP server). Resolve each via
76
+ // createRequire so it's found wherever the package manager put it — CRITICAL
77
+ // for `npx`, which HOISTS deps to its cache root (not PKG_ROOT/node_modules).
78
+ // Walk transitive deps resolved FROM each package's own context (handles
79
+ // nesting). @huggingface/transformers (optional, huge) is intentionally
80
+ // skipped — semantic recall degrades to lexical until the host warms it.
81
+ // Resolve a package DIR by walking node_modules upward (Node-style): checks
82
+ // fromDir/node_modules/<name>, then each parent — so it finds hoisted deps under
83
+ // npx AND nested ones. fs-based on purpose: require.resolve('<name>/package.json')
84
+ // is blocked by restrictive "exports" (e.g. fractional-indexing v3) and would
85
+ // silently drop a dep the hook needs.
80
86
  const destMods = path.join(BRAIN_DIR, 'node_modules');
81
- const queue = ['jszip', 'fractional-indexing', '@modelcontextprotocol/sdk', 'zod'];
82
- const seen = new Set();
83
- const enqueueDepsOf = (pkgDir) => {
84
- try { const pj = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); for (const d of Object.keys(pj?.dependencies || {})) queue.push(d); } catch { /* no readable package.json */ }
85
- const nested = path.join(pkgDir, 'node_modules');
86
- if (!exists(nested)) return;
87
- for (const e of fs.readdirSync(nested, { withFileTypes: true })) {
88
- if (!e.isDirectory()) continue;
89
- if (e.name.startsWith('@')) { for (const s of fs.readdirSync(path.join(nested, e.name), { withFileTypes: true })) { if (s.isDirectory()) enqueueDepsOf(path.join(nested, e.name, s.name)); } }
90
- else enqueueDepsOf(path.join(nested, e.name));
87
+ const findPkgDir = (name, fromDir) => {
88
+ let dir = fromDir;
89
+ for (; ;) {
90
+ const cand = path.join(dir, 'node_modules', ...name.split('/'));
91
+ if (exists(path.join(cand, 'package.json'))) return cand;
92
+ const parent = path.dirname(dir);
93
+ if (parent === dir) return null;
94
+ dir = parent;
91
95
  }
92
96
  };
93
- let deps = 0;
97
+ const seen = new Set();
98
+ const queue = ['jszip', 'fractional-indexing', '@modelcontextprotocol/sdk', 'zod'].map(name => ({ name, fromDir: PKG_ROOT }));
99
+ let deps = 0; const missing = [];
94
100
  while (queue.length) {
95
- const pkg = queue.shift();
96
- if (seen.has(pkg)) continue; seen.add(pkg);
97
- const src = path.join(MODS, pkg);
98
- if (!exists(src)) continue;
99
- if (!exists(path.join(destMods, pkg))) { copyDir(src, path.join(destMods, pkg)); deps++; }
100
- enqueueDepsOf(src);
101
+ const { name, fromDir } = queue.shift();
102
+ if (seen.has(name)) continue; seen.add(name);
103
+ const dir = findPkgDir(name, fromDir);
104
+ if (!dir) { missing.push(name); continue; }
105
+ if (!exists(path.join(destMods, name))) { copyDir(dir, path.join(destMods, name)); deps++; }
106
+ try { const pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); for (const d of Object.keys(pj?.dependencies || {})) queue.push({ name: d, fromDir: dir }); } catch { /* no readable package.json */ }
101
107
  }
108
+ if (missing.length) { console.error(`✗ could not resolve required dep(s): ${missing.join(', ')} — aborting (the brain hook needs them).`); process.exit(1); }
102
109
  // 4) mark the dir an ESM package
103
110
  fs.writeFileSync(path.join(BRAIN_DIR, 'package.json'), JSON.stringify({ name: 'klypix-project-brain', private: true, type: 'module' }, null, 2));
104
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.10.0",
3
+ "version": "1.11.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",
@@ -278,7 +278,8 @@ function touchSession(sid, patch = {}) {
278
278
  const now = Date.now();
279
279
  const got = acquireLock(SESSIONS_LOCK, { tries: 20, waitMs: 25 }); // ~0.5s budget then best-effort
280
280
  try {
281
- const all = pruneSessions(readSessions(), now);
281
+ let data0 = {}; try { data0 = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { /* fresh */ }
282
+ const all = pruneSessions(Array.isArray(data0.sessions) ? data0.sessions : [], now);
282
283
  const prev = all.find(s => s.id === sid) || {};
283
284
  const list = all.filter(s => s.id !== sid);
284
285
  list.push({
@@ -292,7 +293,10 @@ function touchSession(sid, patch = {}) {
292
293
  startedAt: prev.startedAt || now, lastSeen: now,
293
294
  });
294
295
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
295
- fs.writeFileSync(SESSIONS_FILE, JSON.stringify({ sessions: list.slice(-20) }));
296
+ // Preserve the messages lane — touchSession runs AFTER postMessages in a
297
+ // capture, and dropping the field here would clobber a just-posted note.
298
+ const keptMsgs = (Array.isArray(data0.messages) ? data0.messages : []).filter(m => m && (now - (m.ts || 0) < MSG_FRESH_MS));
299
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify({ sessions: list.slice(-20), messages: keptMsgs }));
296
300
  } finally { if (got) releaseLock(SESSIONS_LOCK); }
297
301
  } catch { /* coordination is best-effort */ }
298
302
  }
@@ -335,6 +339,64 @@ function peerFooter(sid) {
335
339
  return lines.join('\n');
336
340
  }
337
341
 
342
+ // ── Brain messaging — async agent↔agent notes via the shared per-project lane ─
343
+ // The COMPLEMENT to the live-ledger: the ledger surfaces a peer's AUTOMATIC high-
344
+ // signal events (ships, version bumps); this lets a session leave a DELIBERATE,
345
+ // TARGETED note for another ("merged the hook refactor — rebase your PR first").
346
+ // SEND by emitting `🧠 MSG [<to>]: <text>` in a reply (to = a peer's id-prefix or
347
+ // branch, omitted = all); the Stop hook posts it to the lane, and the recipient's
348
+ // next prompt/session-start surfaces it ONCE (ack'd under the lane lock). Still
349
+ // async (delivered at the peer's next hook event), by design — not real-time.
350
+ const MSG_RE = /🧠\s*MSG\s*(?:\[([^\]]*)\])?\s*:\s*(.+)$/i;
351
+ const MSG_FRESH_MS = 24 * 60 * 60 * 1000;
352
+ function postMessages(msgs) {
353
+ if (!msgs || !msgs.length) return;
354
+ const got = acquireLock(SESSIONS_LOCK, { tries: 20, waitMs: 25 });
355
+ try {
356
+ let data = {}; try { data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { /* fresh */ }
357
+ const sessions = Array.isArray(data.sessions) ? data.sessions : [];
358
+ const now = Date.now();
359
+ const kept = (Array.isArray(data.messages) ? data.messages : []).filter(m => m && (now - (m.ts || 0) < MSG_FRESH_MS));
360
+ for (const m of msgs) kept.push(m);
361
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
362
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify({ sessions, messages: kept.slice(-30) }));
363
+ } catch { /* best-effort */ } finally { if (got) releaseLock(SESSIONS_LOCK); }
364
+ }
365
+ // to==='all'/'' → everyone; else the hint must appear in my id-prefix / branch / intent.
366
+ function msgTargetsMe(m, me, sid) {
367
+ const to = String(m.to || '').trim().toLowerCase();
368
+ if (!to || to === 'all' || to === '*') return true;
369
+ return [String(sid || '').slice(0, 8), me?.branch || '', me?.intent || ''].join(' ').toLowerCase().includes(to);
370
+ }
371
+ // Surface unseen messages addressed to me, mark them seen (delivered once) under lock.
372
+ function messageFooter(sid) {
373
+ if (!sid) return '';
374
+ let data = {}; try { data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { return ''; }
375
+ const all = Array.isArray(data.messages) ? data.messages : [];
376
+ if (!all.length) return '';
377
+ const now = Date.now();
378
+ const me = (Array.isArray(data.sessions) ? data.sessions : []).find(s => s.id === sid);
379
+ const unseen = all.filter(m => m && (now - (m.ts || 0) < MSG_FRESH_MS) && m.from !== sid
380
+ && !(Array.isArray(m.seen) && m.seen.includes(sid)) && msgTargetsMe(m, me, sid));
381
+ if (!unseen.length) return '';
382
+ const got = acquireLock(SESSIONS_LOCK, { tries: 15, waitMs: 25 }); // ack under lock so a peer read can't lose it
383
+ try {
384
+ let d2 = {}; try { d2 = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { /* */ }
385
+ const ids = new Set(unseen.map(u => u.id));
386
+ for (const m of (Array.isArray(d2.messages) ? d2.messages : [])) {
387
+ if (!ids.has(m.id)) continue;
388
+ if (!Array.isArray(m.seen)) m.seen = [];
389
+ if (!m.seen.includes(sid)) m.seen.push(sid);
390
+ }
391
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify(d2));
392
+ } catch { /* */ } finally { if (got) releaseLock(SESSIONS_LOCK); }
393
+ const ago = (ts) => { const mm = Math.max(0, Math.round((now - (ts || now)) / 60000)); return mm <= 0 ? 'just now' : `${mm}m ago`; };
394
+ const out = ['', '## 📨 Message(s) from another session in this project (delivered once — act on or reply to them)'];
395
+ for (const m of unseen.slice(0, 6)) out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 200)}`);
396
+ out.push('Reply with `🧠 MSG [<their-id or all>]: <text>` — it reaches them on their next prompt.');
397
+ return '\n' + out.join('\n');
398
+ }
399
+
338
400
  // Pull assistant text out of one transcript line (Claude Code JSONL: content is
339
401
  // a string or an array of {type:'text',text}).
340
402
  function textOf(entry) {
@@ -755,6 +817,8 @@ async function capture(lib) {
755
817
  const resolutions = [];
756
818
  const updates = [];
757
819
  const ledger = []; // one entry per marker decision (observability)
820
+ const messages = []; // 🧠 MSG markers → posted to the per-project lane
821
+ const sid = input.session_id || '';
758
822
  // Rolling set of the most-recently-touched file/dir tags (deduped, newest
759
823
  // last). A marker emitted after some edits gets tagged with the files that
760
824
  // work touched — captured by scanning EVERY entry's tool_use, not just the
@@ -776,7 +840,15 @@ async function capture(lib) {
776
840
  const text = textOf(e);
777
841
  if (!text.includes('🧠')) continue;
778
842
  for (const raw of text.split('\n')) {
779
- const m = MARKER.exec(raw.trim()); if (!m) continue;
843
+ const trimmed = raw.trim();
844
+ // 🧠 MSG [to]: text — an async note to another session (not a brain card).
845
+ const mg = MSG_RE.exec(trimmed);
846
+ if (mg) {
847
+ const txt = (mg[2] || '').trim();
848
+ if (txt) messages.push({ id: sha(sid + '|' + txt + '|' + Date.now() + '|' + Math.random()), from: sid, to: (mg[1] || '').trim() || 'all', text: txt.slice(0, 400), ts: Date.now(), seen: [] });
849
+ continue;
850
+ }
851
+ const m = MARKER.exec(trimmed); if (!m) continue;
780
852
  const area = (m[1] || '').trim(), type = m[2] || '';
781
853
  let body = m[3].trim(); if (!body) continue;
782
854
  // Strip optional `closes:` / `ev:` suffixes off the body so they don't
@@ -833,6 +905,7 @@ async function capture(lib) {
833
905
  ledger.push({ action: type === '?' ? 'add-question' : type === '!' ? 'add-milestone' : isSkill ? 'add-skill' : 'add-decision', area, preview, files: fileTags, ...(closes ? { closes } : {}), ...(evidence ? { ev: evidence.map(e => e.ref) } : {}) });
834
906
  }
835
907
  }
908
+ postMessages(messages); // deliver any 🧠 MSG notes to peers' lanes (even if no cards this turn)
836
909
  // Ship-event auto-capture: deterministic high-signal events (PR merges, releases,
837
910
  // npm publishes, tags) from SUCCESSFUL shell calls in the transcript — no marker
838
911
  // required (the gap a concurrent session hit: 3 releases + 6 PRs → zero cards).
@@ -1015,6 +1088,7 @@ async function promptRetrieve(lib) {
1015
1088
  // Other live sessions in THIS repo — surfaced even when the prompt retrieves
1016
1089
  // nothing (a peer's presence/ship is itself the signal). Empty string when solo.
1017
1090
  const peers = peerFooter(sid);
1091
+ const messages = messageFooter(sid); // 📨 deliberate notes another session left for me (delivered once)
1018
1092
  let hits = [], repeats = [], struct = null;
1019
1093
  if (tokens.length) {
1020
1094
  struct = await cachedStruct(lib);
@@ -1061,7 +1135,7 @@ async function promptRetrieve(lib) {
1061
1135
  // Other live sessions' in-flight events (struct may be null on a token-less
1062
1136
  // prompt — then representedInBrain just keeps entries; they're rarely in-brain yet).
1063
1137
  const inflight = inflightFooter(sid, struct);
1064
- if (!repeats.length && !freshHits.length && !peers && !inflight) return; // nothing → zero output, zero added context
1138
+ if (!repeats.length && !freshHits.length && !peers && !inflight && !messages) return; // nothing → zero output, zero added context
1065
1139
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
1066
1140
  const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
1067
1141
  const head = (c, n = 120) => { const t = flat(c.text); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
@@ -1086,6 +1160,7 @@ async function promptRetrieve(lib) {
1086
1160
  if (lines.length) parts.push(lines.join('\n'));
1087
1161
  if (inflight) parts.push(inflight.replace(/^\n+/, '')); // ⚡ in-flight peers' events (its own block)
1088
1162
  if (peers) parts.push(peers.replace(/^\n+/, '')); // live-session presence footer (its own block)
1163
+ if (messages) parts.push(messages.replace(/^\n+/, '')); // 📨 inbound deliberate peer notes
1089
1164
  process.stdout.write(parts.join('\n\n') + '\n'); // UserPromptSubmit injects stdout as context
1090
1165
  }
1091
1166
 
@@ -1196,7 +1271,8 @@ function legendFooter() {
1196
1271
  return '\n\n---\n'
1197
1272
  + '🧠 **Capture markers** — write these in your reply; the Stop hook harvests them into the brain (no separate log step). Use sparingly, for real decisions / milestones / discoveries:\n'
1198
1273
  + '`🧠 BRAIN [Area]: decision` · `[Area] ?: open question` · `[Area] !: milestone` · `[Area] +: 🛠️ skill (reusable how-to / gotcha — resurfaces every session, never ages out)` · `[Area] ✓: resolves+archives the matching card` · `[Area] ~: updates it in place` · 🎯 in text = a goal (reads as open).\n'
1199
- + 'Optional suffixes: `closes: <card title / [[wikilink]]>` (resolve the strategy/question this fulfils) · `ev: <file[:line]>, PR#<n>` (anchor to code → auto drift-badge).\n';
1274
+ + 'Optional suffixes: `closes: <card title / [[wikilink]]>` (resolve the strategy/question this fulfils) · `ev: <file[:line]>, PR#<n>` (anchor to code → auto drift-badge).\n'
1275
+ + 'Coordinate with a concurrent session: `🧠 MSG [<their-id or all>]: <text>` — a one-time note (NOT a brain card) delivered to that session on its next prompt.\n';
1200
1276
  }
1201
1277
 
1202
1278
  async function read(lib) {
@@ -1213,7 +1289,7 @@ async function read(lib) {
1213
1289
  : lib.structToMarkdown(struct);
1214
1290
  // ⚡ In-flight footer goes RIGHT AFTER the brief (highest signal: what a peer
1215
1291
  // shipped seconds ago, before it's in the brain) — closes the 1.3.17-blindness gap.
1216
- process.stdout.write(outStr + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct) + selfCheckFooter() + legendFooter() + memoryFooter());
1292
+ process.stdout.write(outStr + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct) + selfCheckFooter() + messageFooter(input.session_id || '') + legendFooter() + memoryFooter());
1217
1293
  // Heartbeat: prove the brief actually injected (and how big) so a dead or
1218
1294
  // stale live-copy of the hook stops being a silent no-op.
1219
1295
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(outStr), cards: struct?.counts?.cards ?? null }, 500);