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.
@@ -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}"`);
@@ -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`.',
@@ -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.15.0",
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",
@@ -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
- // stale — block intact but stamped from an older brain (version < current),
108
- // or a legacy unstamped block (no version) → a re-link refreshes it
109
- // ok — block present, current version, body matches its hash
110
- function classifyFenced(file, version) {
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 fence = parseFence(fs.readFileSync(file, 'utf8'));
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
- const body = (frontmatter ? frontmatter + '\n' : '') + fencedBlock(version) + '\n';
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
- if (had && fs.readFileSync(file, 'utf8') === body) return { action: 'unchanged' };
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
- const SESSIONS_FILE = path.join(SESSIONS_DIR, `${sha(normBrainPath(BRAIN))}.json`); // one lane-file per PROJECT (shared by its concurrent sessions)
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 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)}`);
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
- const brief = lib.structToBrief(struct, { recentDays: 7, maxRecent: 10, maxMilestones: 3, maxConnections: 5 }).trim();
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);
@@ -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 };
@@ -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.slice(0, max - 1).trimEnd() + '…' : 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 = 11_000; // ≈ 2.7k tokens
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 recent) { const a = flat(c.area) || 'Notes'; if (!byArea.has(a)) byArea.set(a, []); byArea.get(a).push(c); }
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;