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.
- package/bin/klypix-install.mjs +29 -22
- package/package.json +1 -1
- package/src/global-brain-hook.mjs +82 -6
package/bin/klypix-install.mjs
CHANGED
|
@@ -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)
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
96
|
-
if (seen.has(
|
|
97
|
-
const
|
|
98
|
-
if (!
|
|
99
|
-
if (!exists(path.join(destMods,
|
|
100
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|