klypix-mcp 1.15.0 → 1.16.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-append.mjs +1 -1
- package/bin/klypix-mcp.mjs +15 -2
- package/bin/klypix-write.mjs +1 -1
- package/package.json +2 -2
- package/src/agent-rules.mjs +48 -10
- package/src/global-brain-hook.mjs +43 -7
- package/src/klypix-core.mjs +72 -4
- package/src/klypix-format.mjs +30 -5
package/bin/klypix-append.mjs
CHANGED
|
@@ -41,4 +41,4 @@ await atomicWrite(file, buf);
|
|
|
41
41
|
const cardCount = Array.isArray(addition.cards) ? addition.cards.length : 0;
|
|
42
42
|
const connCount = Array.isArray(addition.connections) ? addition.connections.length : 0;
|
|
43
43
|
console.log(`Appended ${cardCount} card(s), ${connCount} connection(s) to ${file}.`);
|
|
44
|
-
console.log(`Reopen it in KLYPIX, or verify: node scripts/read-klypix.mjs "${file}"`);
|
|
44
|
+
console.log(`Reopen it in the KLYPIX app, or verify: node scripts/read-klypix.mjs "${file}"`);
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
24
24
|
import {
|
|
25
25
|
resolveVault, getEmbedder, buildKlypixMap, cardSchema, connSchema,
|
|
26
26
|
opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
|
|
27
|
-
opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote,
|
|
27
|
+
opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage,
|
|
28
28
|
} from '../src/klypix-core.mjs';
|
|
29
29
|
|
|
30
30
|
// Real package version for the MCP handshake (was hardcoded '1.0.0', which
|
|
@@ -160,7 +160,7 @@ server.registerTool('brain_garden', {
|
|
|
160
160
|
|
|
161
161
|
server.registerTool('create_canvas', {
|
|
162
162
|
title: 'Create a KLYPIX canvas',
|
|
163
|
-
description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in KLYPIX (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows.',
|
|
163
|
+
description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in the KLYPIX app (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows.',
|
|
164
164
|
inputSchema: {
|
|
165
165
|
title: z.string().describe('Canvas title (also the filename).'),
|
|
166
166
|
cards: z.array(cardSchema).min(1).describe('The cards. 5-12 atomic cards is ideal.'),
|
|
@@ -199,6 +199,19 @@ 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_message', {
|
|
203
|
+
title: 'Message the other live agent sessions on this project (one-time note, not a brain card)',
|
|
204
|
+
description: 'Leave a DELIBERATE, targeted note for the OTHER live agent sessions working on this project right now ("merged the hook refactor — rebase before you commit", "don\'t touch canvasStore, mid-refactor"). Any MCP client can SEND (the twin of the `🧠 MSG [to]: text` marker); delivery is to HOOK-WIRED sessions (Claude Code with the brain hooks) — each sees the note once at its next prompt. A hookless peer (plain Cursor/Cline session) will NOT receive it, so don\'t rely on this to warn one. Ephemeral (expires in 24h) and NOT persisted to the brain — for a durable decision use brain_note instead.',
|
|
205
|
+
inputSchema: {
|
|
206
|
+
text: z.string().describe('The note to deliver (kept to 400 chars).'),
|
|
207
|
+
to: z.string().optional().describe('Target hint — a peer session id-prefix or branch name; omit or "all" for every live session.'),
|
|
208
|
+
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
209
|
+
},
|
|
210
|
+
}, async ({ text, to, canvas }) => {
|
|
211
|
+
let via; try { via = server.server.getClientVersion()?.name; } catch { /* optional */ }
|
|
212
|
+
return toContent(await opBrainMessage({ vault: VAULT, canvas, text, to, via }));
|
|
213
|
+
});
|
|
214
|
+
|
|
202
215
|
server.registerTool('brain_doctor', {
|
|
203
216
|
title: 'Brain doctor — is this brain current, wired, and in sync?',
|
|
204
217
|
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`.',
|
package/bin/klypix-write.mjs
CHANGED
|
@@ -42,4 +42,4 @@ await atomicWrite(outPath, buf);
|
|
|
42
42
|
const cardCount = spec.cards.length;
|
|
43
43
|
const connCount = Array.isArray(spec.connections) ? spec.connections.length : 0;
|
|
44
44
|
console.log(`Wrote ${outPath} — ${cardCount} cards, ${connCount} connections.`);
|
|
45
|
-
console.log(`Open it in KLYPIX (Canvas → Open), or verify: node scripts/read-klypix.mjs "${outPath}"`);
|
|
45
|
+
console.log(`Open it in the KLYPIX app (Canvas → Open), or verify: node scripts/read-klypix.mjs "${outPath}"`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.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",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"node": ">=18"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs"
|
|
56
|
+
"test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/src/agent-rules.mjs
CHANGED
|
@@ -73,6 +73,12 @@ memory the whole team and every agent reads, not a private scratchpad only you c
|
|
|
73
73
|
project decision live *only* in your host's memory or a scratch file — put it here so the next
|
|
74
74
|
session/agent has it.
|
|
75
75
|
|
|
76
|
+
**Working alongside other live sessions?** Send a one-time coordination note with the
|
|
77
|
+
\`brain_message\` MCP tool ("merged the hook refactor — rebase before you commit"); hook-wired peer
|
|
78
|
+
sessions (Claude Code) see it at their next prompt. Any client can SEND; only hook-wired sessions
|
|
79
|
+
receive, so don't rely on it to reach a hookless peer. Notes are ephemeral (24h), NOT brain cards —
|
|
80
|
+
durable decisions still go through \`brain_note\`.
|
|
81
|
+
|
|
76
82
|
**Don't** hand-edit \`brain.klypix\` (it's a packaged canvas — use the tools) or dump file contents into it; capture the *decision*, not the file.`;
|
|
77
83
|
|
|
78
84
|
// Content fingerprint of the canonical instructions — stamped into the fence so a
|
|
@@ -101,17 +107,43 @@ function parseFence(text) {
|
|
|
101
107
|
return { version: m[1] || null, hash: m[2] || null, body: m[3] || '' };
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
// Classify a projected fenced file WITHOUT writing (the drift audit).
|
|
110
|
+
// Classify a projected fenced file WITHOUT writing (the drift audit). THE single
|
|
111
|
+
// source of truth for both check AND write: fenceMerge/writeDedicated skip a file
|
|
112
|
+
// IFF this says 'ok', so classify-ok ⇔ link-unchanged can never disagree (the 1.16
|
|
113
|
+
// adversarial review found three ways they used to: an unrepairable hand-edited
|
|
114
|
+
// stamp, an invisible frontmatter strip, and a check-ok-but-link-downgrades skew).
|
|
105
115
|
// missing — file absent, or present but carries no managed block
|
|
106
|
-
// hand-edited — block body no longer matches the hash it was stamped with
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
116
|
+
// hand-edited — block body no longer matches the hash it was stamped with; or
|
|
117
|
+
// (owned dedicated files) anything OUTSIDE the fence drifted —
|
|
118
|
+
// e.g. a stripped `alwaysApply:`/`trigger:` frontmatter, which
|
|
119
|
+
// silently disables the rule in Cursor/Windsurf
|
|
120
|
+
// ok — block body is EXACTLY what we'd project today AND the stamp is
|
|
121
|
+
// consistent with it — regardless of the stamped version. CONTENT
|
|
122
|
+
// is the contract, the stamp is provenance: a version-only bump
|
|
123
|
+
// must NOT re-drift every adopter into a re-link treadmill. A
|
|
124
|
+
// self-consistent block stamped NEWER than the running brain is
|
|
125
|
+
// also 'ok' (and link leaves it alone — never downgrade).
|
|
126
|
+
// stale — block body differs from today's instructions AND was stamped from
|
|
127
|
+
// an older brain (or is a legacy unstamped block) → re-link refreshes
|
|
128
|
+
const STAMP_RE = /<!--\s*klypix-brain:start[\s\S]*?-->/;
|
|
129
|
+
function classifyFenced(file, version, frontmatter = null) {
|
|
111
130
|
if (!exists(file)) return { status: 'missing' };
|
|
112
|
-
const
|
|
131
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
132
|
+
const fence = parseFence(raw);
|
|
113
133
|
if (!fence) return { status: 'missing' };
|
|
114
134
|
if (fence.hash && sha8(fence.body.trim()) !== fence.hash) return { status: 'hand-edited', stampedVersion: fence.version };
|
|
135
|
+
const bodyCurrent = sha8(fence.body.trim()) === INSTRUCTIONS_HASH;
|
|
136
|
+
const stampConsistent = !fence.hash || fence.hash === INSTRUCTIONS_HASH;
|
|
137
|
+
if (bodyCurrent && stampConsistent) {
|
|
138
|
+
// Owned dedicated files are OURS wholesale — frontmatter included. Compare the
|
|
139
|
+
// whole file modulo the stamp line so a frontmatter strip/flip reads as drift.
|
|
140
|
+
if (frontmatter !== null) {
|
|
141
|
+
const norm = (s) => String(s).replace(STAMP_RE, '<STAMP>').replace(/\r\n/g, '\n').trim();
|
|
142
|
+
const expected = (frontmatter ? frontmatter + '\n' : '') + fencedBlock(version) + '\n';
|
|
143
|
+
if (norm(raw) !== norm(expected)) return { status: 'hand-edited', stampedVersion: fence.version || null, why: 'frontmatter/layout drifted' };
|
|
144
|
+
}
|
|
145
|
+
return { status: 'ok', stampedVersion: fence.version || null };
|
|
146
|
+
}
|
|
115
147
|
if (!fence.version) return { status: 'stale', stampedVersion: null };
|
|
116
148
|
if (cmpSemver(fence.version, version) < 0) return { status: 'stale', stampedVersion: fence.version };
|
|
117
149
|
return { status: 'ok', stampedVersion: fence.version };
|
|
@@ -120,6 +152,10 @@ function classifyFenced(file, version) {
|
|
|
120
152
|
// Shared markdown (AGENTS.md, copilot-instructions.md, GEMINI.md): merge our fenced
|
|
121
153
|
// block in place, preserving the user's own prose outside the fence.
|
|
122
154
|
function fenceMerge(file, version) {
|
|
155
|
+
// Zero-touch IFF the audit says 'ok' (same classifier, so check and write can
|
|
156
|
+
// never disagree): current+consistent block → byte-identical file, no stamp-only
|
|
157
|
+
// churn; a hand-edited stamp or stale body falls through and gets rebuilt.
|
|
158
|
+
if (classifyFenced(file, version).status === 'ok') return { action: 'unchanged' };
|
|
123
159
|
let cur = '';
|
|
124
160
|
let had = false;
|
|
125
161
|
if (exists(file)) { cur = fs.readFileSync(file, 'utf8'); had = true; }
|
|
@@ -136,9 +172,11 @@ function fenceMerge(file, version) {
|
|
|
136
172
|
|
|
137
173
|
// Owned dedicated rules file: rewrite wholesale (optional frontmatter for always-apply).
|
|
138
174
|
function writeDedicated(file, frontmatter, version) {
|
|
139
|
-
|
|
175
|
+
// Zero-touch IFF the audit says 'ok' — which for owned dedicated files includes
|
|
176
|
+
// the frontmatter (a stripped alwaysApply/trigger MUST be repaired, not skipped).
|
|
177
|
+
if (classifyFenced(file, version, frontmatter || '').status === 'ok') return { action: 'unchanged' };
|
|
140
178
|
const had = exists(file);
|
|
141
|
-
|
|
179
|
+
const body = (frontmatter ? frontmatter + '\n' : '') + fencedBlock(version) + '\n';
|
|
142
180
|
ensureDir(file); fs.writeFileSync(file, body, 'utf8');
|
|
143
181
|
return { action: had ? 'updated' : 'created' };
|
|
144
182
|
}
|
|
@@ -219,7 +257,7 @@ export function linkProject(projectDir, opts = {}) {
|
|
|
219
257
|
|
|
220
258
|
const rules = t.rules.map((r) => {
|
|
221
259
|
const file = relFile(projectDir, r.file);
|
|
222
|
-
if (check) return { tool: r.tool, file, ...classifyFenced(r.file, version) };
|
|
260
|
+
if (check) return { tool: r.tool, file, ...classifyFenced(r.file, version, r.kind === 'dedicated' ? (r.frontmatter || '') : null) };
|
|
223
261
|
const res = r.kind === 'merge' ? fenceMerge(r.file, version) : writeDedicated(r.file, r.frontmatter, version);
|
|
224
262
|
return { tool: r.tool, file, ...res };
|
|
225
263
|
});
|
|
@@ -268,7 +268,11 @@ function releaseLock(lockPath) { try { fs.unlinkSync(lockPath); } catch { /* */
|
|
|
268
268
|
// self-pruning (a lane unseen for FRESH_MS is gone). Sibling of the registry/health
|
|
269
269
|
// sidecars; same ~/.claude/project-brain home, same acquireLock/never-throw rules.
|
|
270
270
|
const SESSIONS_DIR = path.join(os.homedir(), '.claude', 'project-brain', 'sessions');
|
|
271
|
-
|
|
271
|
+
// Lane key: canonicalize (on-disk casing + symlinks) BEFORE hashing, so an MCP
|
|
272
|
+
// server that resolved the SAME brain via a differently-cased cwd / KLYPIX_BRAIN
|
|
273
|
+
// lands in the SAME lane file — klypix-core.mjs laneFileFor mirrors this exactly.
|
|
274
|
+
const laneCanon = (p) => { try { return fs.realpathSync.native(p); } catch { return path.resolve(p); } };
|
|
275
|
+
const SESSIONS_FILE = path.join(SESSIONS_DIR, `${sha(normBrainPath(laneCanon(BRAIN)))}.json`); // one lane-file per PROJECT (shared by its concurrent sessions)
|
|
272
276
|
const SESSIONS_LOCK = SESSIONS_FILE + '.lock';
|
|
273
277
|
const SESSION_FRESH_MS = 10 * 60 * 1000; // a lane unseen for 10min is treated as ended
|
|
274
278
|
const safeGit = (args, timeout = 1500) => { try { return execSync(`git ${args}`, { cwd: CWD, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout }); } catch { return ''; } };
|
|
@@ -377,8 +381,34 @@ function msgTargetsMe(m, me, sid) {
|
|
|
377
381
|
if (!to || to === 'all' || to === '*') return true;
|
|
378
382
|
return [String(sid || '').slice(0, 8), me?.branch || '', me?.intent || ''].join(' ').toLowerCase().includes(to);
|
|
379
383
|
}
|
|
384
|
+
// Self-echo guard for the MCP twin (brain_message): an MCP-posted note carries the
|
|
385
|
+
// CLIENT name as `from` (the server has no session identity), so messageFooter's
|
|
386
|
+
// `from !== sid` can't exclude the sender. The sender's own transcript DOES contain
|
|
387
|
+
// the tool_use, though — scan its tail for brain_message inputs and return their
|
|
388
|
+
// texts so the footer can drop (and ack) the session's own sends. Best-effort.
|
|
389
|
+
function ownMcpSendTexts(tp) {
|
|
390
|
+
try {
|
|
391
|
+
if (!tp || !fs.existsSync(tp)) return new Set();
|
|
392
|
+
const buf = fs.readFileSync(tp);
|
|
393
|
+
const s = buf.length > 2_000_000 ? buf.toString('utf8', buf.length - 2_000_000) : buf.toString('utf8');
|
|
394
|
+
if (!s.includes('brain_message')) return new Set();
|
|
395
|
+
const out = new Set();
|
|
396
|
+
for (const ln of s.split('\n')) {
|
|
397
|
+
if (!ln.includes('brain_message')) continue;
|
|
398
|
+
let e; try { e = JSON.parse(ln); } catch { continue; }
|
|
399
|
+
const c = e?.message?.content;
|
|
400
|
+
if (!Array.isArray(c)) continue;
|
|
401
|
+
for (const b of c) {
|
|
402
|
+
if (b?.type === 'tool_use' && /brain_message$/.test(String(b.name || '')) && b.input?.text) {
|
|
403
|
+
out.add(String(b.input.text).trim().slice(0, 400));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
} catch { return new Set(); }
|
|
409
|
+
}
|
|
380
410
|
// Surface unseen messages addressed to me, mark them seen (delivered once) under lock.
|
|
381
|
-
function messageFooter(sid) {
|
|
411
|
+
function messageFooter(sid, tp) {
|
|
382
412
|
if (!sid) return '';
|
|
383
413
|
let data = {}; try { data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { return ''; }
|
|
384
414
|
const all = Array.isArray(data.messages) ? data.messages : [];
|
|
@@ -388,10 +418,12 @@ function messageFooter(sid) {
|
|
|
388
418
|
const unseen = all.filter(m => m && (now - (m.ts || 0) < MSG_FRESH_MS) && m.from !== sid
|
|
389
419
|
&& !(Array.isArray(m.seen) && m.seen.includes(sid)) && msgTargetsMe(m, me, sid));
|
|
390
420
|
if (!unseen.length) return '';
|
|
421
|
+
const own = ownMcpSendTexts(tp);
|
|
422
|
+
const show = own.size ? unseen.filter(m => !own.has(String(m.text || '').trim().slice(0, 400))) : unseen;
|
|
391
423
|
const got = acquireLock(SESSIONS_LOCK, { tries: 15, waitMs: 25 }); // ack under lock so a peer read can't lose it
|
|
392
424
|
try {
|
|
393
425
|
let d2 = {}; try { d2 = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { /* */ }
|
|
394
|
-
const ids = new Set(unseen.map(u => u.id));
|
|
426
|
+
const ids = new Set(unseen.map(u => u.id)); // ack self-sent notes too — they must never resurface here
|
|
395
427
|
for (const m of (Array.isArray(d2.messages) ? d2.messages : [])) {
|
|
396
428
|
if (!ids.has(m.id)) continue;
|
|
397
429
|
if (!Array.isArray(m.seen)) m.seen = [];
|
|
@@ -399,9 +431,10 @@ function messageFooter(sid) {
|
|
|
399
431
|
}
|
|
400
432
|
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(d2));
|
|
401
433
|
} catch { /* */ } finally { if (got) releaseLock(SESSIONS_LOCK); }
|
|
434
|
+
if (!show.length) return '';
|
|
402
435
|
const ago = (ts) => { const mm = Math.max(0, Math.round((now - (ts || now)) / 60000)); return mm <= 0 ? 'just now' : `${mm}m ago`; };
|
|
403
436
|
const out = ['', '## 📨 Message(s) from another session in this project (delivered once — act on or reply to them)'];
|
|
404
|
-
for (const m of
|
|
437
|
+
for (const m of show.slice(0, 6)) out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 400)}`);
|
|
405
438
|
out.push('Reply with `🧠 MSG [<their-id or all>]: <text>` — it reaches them on their next prompt.');
|
|
406
439
|
return '\n' + out.join('\n');
|
|
407
440
|
}
|
|
@@ -1032,7 +1065,10 @@ async function refreshAgentsBrief(lib, buffer) {
|
|
|
1032
1065
|
const agentsPath = path.resolve(CWD, 'AGENTS.md');
|
|
1033
1066
|
if (!fs.existsSync(agentsPath) || typeof lib.structToBrief !== 'function') return;
|
|
1034
1067
|
const { struct } = await lib.parseKlypix(buffer);
|
|
1035
|
-
|
|
1068
|
+
// detailRecent: 0 — this block is committed into adopters' AGENTS.md and read
|
|
1069
|
+
// by every hookless agent each session; it stays headlines-only by contract
|
|
1070
|
+
// (the SessionStart brief is where the detailed-newest tier lives).
|
|
1071
|
+
const brief = lib.structToBrief(struct, { recentDays: 7, maxRecent: 10, maxMilestones: 3, maxConnections: 5, detailRecent: 0 }).trim();
|
|
1036
1072
|
const START = '<!-- klypix-brain-brief:start -->', END = '<!-- klypix-brain-brief:end -->';
|
|
1037
1073
|
const block = `${START}\n<!-- auto-refreshed by the brain hook on capture · headlines only · full cards via the klypix-canvas MCP -->\n${brief}\n${END}`;
|
|
1038
1074
|
const txt = fs.readFileSync(agentsPath, 'utf8');
|
|
@@ -1107,7 +1143,7 @@ async function promptRetrieve(lib) {
|
|
|
1107
1143
|
// Other live sessions in THIS repo — surfaced even when the prompt retrieves
|
|
1108
1144
|
// nothing (a peer's presence/ship is itself the signal). Empty string when solo.
|
|
1109
1145
|
const peers = peerFooter(sid);
|
|
1110
|
-
const messages = messageFooter(sid); // 📨 deliberate notes another session left for me (delivered once)
|
|
1146
|
+
const messages = messageFooter(sid, input.transcript_path); // 📨 deliberate notes another session left for me (delivered once)
|
|
1111
1147
|
let hits = [], repeats = [], struct = null;
|
|
1112
1148
|
if (tokens.length) {
|
|
1113
1149
|
struct = await cachedStruct(lib);
|
|
@@ -1419,7 +1455,7 @@ async function read(lib) {
|
|
|
1419
1455
|
: lib.structToMarkdown(struct);
|
|
1420
1456
|
// ⚡ In-flight footer goes RIGHT AFTER the brief (highest signal: what a peer
|
|
1421
1457
|
// shipped seconds ago, before it's in the brain) — closes the 1.3.17-blindness gap.
|
|
1422
|
-
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());
|
|
1458
|
+
process.stdout.write(outStr + inflightFooter(input.session_id, struct) + selfHealFooter(drifted) + reconcileFooter(lib, struct) + staleOpenFooter(lib, struct) + selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + messageFooter(input.session_id || '', input.transcript_path) + legendFooter() + memoryFooter());
|
|
1423
1459
|
// Heartbeat: prove the brief actually injected (and how big) so a dead or
|
|
1424
1460
|
// stale live-copy of the hook stops being a silent no-op.
|
|
1425
1461
|
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(outStr), cards: struct?.counts?.cards ?? null }, 500);
|
package/src/klypix-core.mjs
CHANGED
|
@@ -456,7 +456,7 @@ export async function opBrainGarden({ vault, canvas, apply = false, syntheses })
|
|
|
456
456
|
if (!stats.synthCards) return { blocks: [text(`No areas consolidated — each synthesis \`title\` must match a dry-run area title exactly.${skippedNote}`)] };
|
|
457
457
|
let out = buffer; try { out = (await tidyBrain(buffer)).buffer; } catch { /* keep apply result if tidy fails */ }
|
|
458
458
|
await atomicWrite(file, out);
|
|
459
|
-
return { blocks: [text(`🌿 Gardened ${stats.areas} area(s): ${stats.archived} old card(s) → ${stats.synthCards} synthesis card(s); originals archived with "consolidated into" arrows (any prose-dropped figures appended verbatim). Reopen the brain in KLYPIX to see it.${skippedNote}`)] };
|
|
459
|
+
return { blocks: [text(`🌿 Gardened ${stats.areas} area(s): ${stats.archived} old card(s) → ${stats.synthCards} synthesis card(s); originals archived with "consolidated into" arrows (any prose-dropped figures appended verbatim). Reopen the brain in the KLYPIX app to see it.${skippedNote}`)] };
|
|
460
460
|
} catch (e) {
|
|
461
461
|
return err(`Garden apply failed (brain unchanged): ${e.message}`);
|
|
462
462
|
}
|
|
@@ -530,7 +530,7 @@ export async function opCreateCanvas({ vault, title, cards, connections, filenam
|
|
|
530
530
|
let detail = '', struct;
|
|
531
531
|
try { ({ struct } = await parseKlypix(buf)); detail = cardDetailBlock(struct); } catch { /* detail is optional */ }
|
|
532
532
|
return {
|
|
533
|
-
blocks: [text(`Created ${out} — ${cards.length} cards, ${(connections || []).length} connections. Open it in KLYPIX (Canvas → Open).${detail}`)],
|
|
533
|
+
blocks: [text(`Created ${out} — ${cards.length} cards, ${(connections || []).length} connections. Open it in the KLYPIX app (Canvas → Open).${detail}`)],
|
|
534
534
|
file: { name, buffer: buf }, struct,
|
|
535
535
|
};
|
|
536
536
|
} catch (e) {
|
|
@@ -555,7 +555,7 @@ export async function opAddToCanvas({ vault, canvas, cards, connections, via })
|
|
|
555
555
|
detail = cardDetailBlock(struct, new Set(struct.cards.map(c => c.id).filter(id => !beforeIds.has(id))));
|
|
556
556
|
} catch { /* detail is optional */ }
|
|
557
557
|
return {
|
|
558
|
-
blocks: [text(`Added ${cards.length} card(s) to ${path.relative(vault, file)}. Reopen the canvas in KLYPIX to see them.${detail}`)],
|
|
558
|
+
blocks: [text(`Added ${cards.length} card(s) to ${path.relative(vault, file)}. Reopen the canvas in the KLYPIX app to see them.${detail}`)],
|
|
559
559
|
file: { name: path.basename(file), buffer: buf }, struct,
|
|
560
560
|
};
|
|
561
561
|
} catch (e) {
|
|
@@ -587,11 +587,79 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
|
|
|
587
587
|
for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
|
|
588
588
|
// Name the resolved brain explicitly (basename + how) so a write never lands
|
|
589
589
|
// in a surprise file silently — the write-side twin of the read-op stamp.
|
|
590
|
-
return { blocks: [text(`✓ brain_note → ${path.basename(file)} (via ${t.how}) · ${bits.join(' · ')}. Reopen the brain in KLYPIX to see it.`)] };
|
|
590
|
+
return { blocks: [text(`✓ brain_note → ${path.basename(file)} (via ${t.how}) · ${bits.join(' · ')}. Reopen the brain in the KLYPIX app to see it.`)] };
|
|
591
591
|
} catch (e) {
|
|
592
592
|
return err(`brain_note failed (brain unchanged): ${e.message}`);
|
|
593
593
|
}
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
// ── brain_message — the MCP twin of the hook's 🧠 MSG marker ─────────────────
|
|
597
|
+
// A DELIBERATE one-time note to the OTHER live agent sessions on this project
|
|
598
|
+
// ("merged the hook refactor — rebase before you commit"), delivered once to each
|
|
599
|
+
// peer at its next prompt via the per-project coordination lane. Hook agents send
|
|
600
|
+
// these by emitting `🧠 MSG [to]: text`; this op gives HOOKLESS clients (Cursor /
|
|
601
|
+
// Cline / Windsurf / Desktop) the same send path. Ephemeral (24h), NOT a brain
|
|
602
|
+
// card — durable decisions go through brain_note.
|
|
603
|
+
//
|
|
604
|
+
// The lane primitives below MUST byte-match src/global-brain-hook.mjs (sha-16 of
|
|
605
|
+
// normBrainPath(brain), sessions/<key>.json layout, MSG_FRESH_MS, the wx-lockfile) —
|
|
606
|
+
// the hook is the READER of what we post. test/lane-message.mjs drives the REAL
|
|
607
|
+
// hook over a message posted by this op; if the two implementations drift, it fails.
|
|
608
|
+
const laneSha16 = (s) => crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
|
|
609
|
+
const laneNormBrainPath = (p) => String(p).replace(/\\/g, '/').replace(/^[a-zA-Z]:/, (m) => m.toLowerCase());
|
|
610
|
+
const LANE_MSG_FRESH_MS = 24 * 60 * 60 * 1000; // messages expire after a day
|
|
611
|
+
const LANE_SESSION_FRESH_MS = 10 * 60 * 1000; // a lane unseen for 10min is treated as ended
|
|
612
|
+
// Canonicalize (on-disk casing + symlinks) before hashing — the hook does the same,
|
|
613
|
+
// so a brain resolved via a differently-cased cwd/KLYPIX_BRAIN still lands in the
|
|
614
|
+
// hook's lane file instead of a silently-unread sibling.
|
|
615
|
+
const laneCanon = (p) => { try { return fs.realpathSync.native(p); } catch { return path.resolve(p); } };
|
|
616
|
+
const laneFileFor = (brainAbs) => path.join(os.homedir(), '.claude', 'project-brain', 'sessions', `${laneSha16(laneNormBrainPath(laneCanon(brainAbs)))}.json`);
|
|
617
|
+
const laneSleep = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* */ } };
|
|
618
|
+
function laneLock(lockPath, tries = 20, waitMs = 25) {
|
|
619
|
+
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch { /* */ }
|
|
620
|
+
for (let i = 0; i < tries; i++) {
|
|
621
|
+
try { const fd = fs.openSync(lockPath, 'wx'); fs.writeSync(fd, String(process.pid)); fs.closeSync(fd); return true; }
|
|
622
|
+
catch (e) {
|
|
623
|
+
if (e && e.code !== 'EEXIST') return false;
|
|
624
|
+
try { if (Date.now() - fs.statSync(lockPath).mtimeMs > 15000) { fs.unlinkSync(lockPath); continue; } } catch { /* lost a race — retry */ }
|
|
625
|
+
laneSleep(waitMs);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return false; // contended → write best-effort (same contract as the hook)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export async function opBrainMessage({ vault, canvas, text: msgText, to, via }) {
|
|
632
|
+
const t = brainTarget(vault, canvas);
|
|
633
|
+
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
634
|
+
if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
|
|
635
|
+
const txt = String(msgText || '').trim();
|
|
636
|
+
if (!txt) return err('brain_message needs a non-empty text.');
|
|
637
|
+
const laneFile = laneFileFor(t.file);
|
|
638
|
+
const lock = laneFile + '.lock';
|
|
639
|
+
const now = Date.now();
|
|
640
|
+
const msg = {
|
|
641
|
+
id: laneSha16('mcp|' + txt + '|' + now + '|' + Math.random()),
|
|
642
|
+
from: via ? String(via).replace(/\s+/g, '-').slice(0, 24) : 'mcp',
|
|
643
|
+
// cap + collapse the target hint too — an oversized `to` would bloat the lane
|
|
644
|
+
// file every hook of every session re-reads for 24h (and matches no one anyway)
|
|
645
|
+
to: String(to || 'all').replace(/\s+/g, ' ').trim().slice(0, 64) || 'all',
|
|
646
|
+
text: txt.slice(0, 400), ts: now, seen: [],
|
|
647
|
+
};
|
|
648
|
+
const got = laneLock(lock);
|
|
649
|
+
let live = 0;
|
|
650
|
+
try {
|
|
651
|
+
let data = {}; try { data = JSON.parse(fs.readFileSync(laneFile, 'utf8')); } catch { /* fresh lane */ }
|
|
652
|
+
const sessions = Array.isArray(data.sessions) ? data.sessions : [];
|
|
653
|
+
live = sessions.filter(s => s && s.id && now - (s.lastSeen || 0) < LANE_SESSION_FRESH_MS).length;
|
|
654
|
+
const kept = (Array.isArray(data.messages) ? data.messages : []).filter(m => m && now - (m.ts || 0) < LANE_MSG_FRESH_MS);
|
|
655
|
+
kept.push(msg);
|
|
656
|
+
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
657
|
+
fs.writeFileSync(laneFile, JSON.stringify({ sessions, messages: kept.slice(-30) }));
|
|
658
|
+
} catch (e) {
|
|
659
|
+
return err(`brain_message failed: ${e.message}`);
|
|
660
|
+
} finally { if (got) { try { fs.unlinkSync(lock); } catch { /* */ } } }
|
|
661
|
+
return { blocks: [text(`📨 posted to this project's coordination lane (to: ${msg.to}) — ${live} live hook-wired session(s) right now; each sees it once at its next prompt. Hookless clients can send but not receive. Ephemeral (24h), not a brain card — use brain_note for durable decisions.`)] };
|
|
662
|
+
}
|
|
663
|
+
|
|
596
664
|
// Re-export the format helpers the bins need for non-op work (init onboarding).
|
|
597
665
|
export { buildKlypixMap, parseKlypix };
|
package/src/klypix-format.mjs
CHANGED
|
@@ -561,7 +561,7 @@ export async function tidyBrain(buffer) {
|
|
|
561
561
|
// decisions + milestones. Everything older stays in the file, reachable via the
|
|
562
562
|
// klypix-canvas MCP search or `--full`. Keeps the session-start cost flat as
|
|
563
563
|
// the brain grows (the full markdown scales with history; this doesn't).
|
|
564
|
-
export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMilestones = 8, maxConnections = 30, maxSkills = 24, freshness = null } = {}) {
|
|
564
|
+
export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMilestones = 8, maxConnections = 30, maxSkills = 24, detailRecent = 8, freshness = null } = {}) {
|
|
565
565
|
const cutoff = Date.now() - recentDays * 86_400_000;
|
|
566
566
|
const texts = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim());
|
|
567
567
|
const containers = struct.cards.filter(c => c.type === 'container');
|
|
@@ -593,18 +593,33 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
593
593
|
const fr = (c) => (freshness && freshness[c.id]) ? freshness[c.id] + ' ' : '';
|
|
594
594
|
// HEADLINE = first sentence-ish, hard-capped — the brief is a scannable
|
|
595
595
|
// changelog; the agent pulls any card's full text via the MCP when needed.
|
|
596
|
+
// Hard cuts must not split a UTF-16 surrogate pair (an emoji straddling the cap
|
|
597
|
+
// would serialize as U+FFFD in the brief / AGENTS.md).
|
|
598
|
+
const safeCut = (t, n) => { let s = t.slice(0, n); if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1); return s.trimEnd() + '…'; };
|
|
596
599
|
const headline = (c, max = 160) => {
|
|
597
600
|
const t = flat(c.text);
|
|
598
601
|
const stop = t.search(/(?<=[.!?])\s/);
|
|
599
602
|
const h = stop > 40 && stop < max ? t.slice(0, stop) : t;
|
|
600
|
-
return h.length > max ? h
|
|
603
|
+
return h.length > max ? safeCut(h, max - 1) : h;
|
|
604
|
+
};
|
|
605
|
+
// EXCERPT = the first few sentences, cut at the last sentence boundary under the
|
|
606
|
+
// cap. The newest `detailRecent` decisions get this instead of a headline: the
|
|
607
|
+
// recall eval showed detail questions about the freshest cards are exactly what a
|
|
608
|
+
// 160-char headline loses (status, resolution, the "what exactly shipped").
|
|
609
|
+
const excerpt = (c, max = 420) => {
|
|
610
|
+
const t = flat(c.text);
|
|
611
|
+
if (t.length <= max) return t;
|
|
612
|
+
let cut = -1;
|
|
613
|
+
const re = /(?<=[.!?])\s/g; let m;
|
|
614
|
+
while ((m = re.exec(t)) && m.index < max) cut = m.index;
|
|
615
|
+
return cut > 120 ? t.slice(0, cut) : safeCut(t, max - 1);
|
|
601
616
|
};
|
|
602
617
|
const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
|
|
603
618
|
|
|
604
619
|
// TOKEN BUDGET (≈ chars/4): sections are added in priority order — Focus,
|
|
605
620
|
// Open, Areas, Milestones, Recent, Connections — and Recent stops when the
|
|
606
621
|
// budget is hit. The brief stays ~flat forever no matter how active a week.
|
|
607
|
-
const BUDGET_CHARS =
|
|
622
|
+
const BUDGET_CHARS = 13_500; // ≈ 3.3k tokens (raised for the detailed-newest tier; still hard-capped/flat)
|
|
608
623
|
let used = 0;
|
|
609
624
|
const out = [];
|
|
610
625
|
const push = (...lines) => { for (const l of lines) { out.push(l); used += l.length + 1; } };
|
|
@@ -635,10 +650,20 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
635
650
|
}
|
|
636
651
|
let shownRecent = 0;
|
|
637
652
|
if (recent.length) {
|
|
638
|
-
push('', `## Recent decisions (last ${recentDays}d — headlines)`);
|
|
653
|
+
push('', `## Recent decisions (last ${recentDays}d — newest first in detail, rest headlines)`);
|
|
654
|
+
// Detail tier FIRST (recent is newest-first), so the budget can never be
|
|
655
|
+
// eaten by older headlines before the cards the header promises in detail.
|
|
656
|
+
const detail = recent.slice(0, detailRecent);
|
|
657
|
+
for (const c of detail) {
|
|
658
|
+
if (used > BUDGET_CHARS) break;
|
|
659
|
+
push(`- ${fr(c)}[${flat(c.area) || 'Notes'}] ${day(c.createdAt)} ${excerpt(c)}`);
|
|
660
|
+
shownRecent++;
|
|
661
|
+
}
|
|
662
|
+
const rest = recent.slice(detailRecent);
|
|
639
663
|
const byArea = new Map();
|
|
640
|
-
for (const c of
|
|
664
|
+
for (const c of rest) { const a = flat(c.area) || 'Notes'; if (!byArea.has(a)) byArea.set(a, []); byArea.get(a).push(c); }
|
|
641
665
|
outer: for (const [a, cs] of byArea) {
|
|
666
|
+
if (used > BUDGET_CHARS) break;
|
|
642
667
|
push(`### ${a}`);
|
|
643
668
|
for (const c of cs) {
|
|
644
669
|
if (used > BUDGET_CHARS) break outer;
|