klypix-mcp 1.10.1 → 1.12.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-link.mjs +38 -0
- package/bin/klypix-mcp.mjs +6 -0
- package/package.json +2 -1
- package/src/agent-rules.mjs +126 -0
- package/src/global-brain-hook.mjs +82 -6
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// klypix-link — make THIS project's brain automatic for EVERY agent tool.
|
|
3
|
+
// `npx klypix-mcp install` gives Claude Code the brain via hooks; `link` extends the
|
|
4
|
+
// same automatic read+capture to Cursor, Cline, Windsurf, Copilot/VS Code, and any
|
|
5
|
+
// AGENTS.md-reading agent, by dropping each tool's native MCP config + rules file in
|
|
6
|
+
// the current project. Idempotent — re-run anytime to refresh. Project-scoped (cwd);
|
|
7
|
+
// touches only files inside the project. Reports what it did; exits non-zero on hard fail.
|
|
8
|
+
//
|
|
9
|
+
// cd your-project && npx klypix-mcp link
|
|
10
|
+
//
|
|
11
|
+
// Synchronous top-level by design: the dispatcher does `await import(this); process.exit(0)`,
|
|
12
|
+
// so all work (and its logs) must complete during module evaluation, before exit.
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { linkProject } from '../src/agent-rules.mjs';
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const dirArg = process.argv.slice(3).find(a => !a.startsWith('-'));
|
|
18
|
+
const projectDir = path.resolve(dirArg || process.cwd());
|
|
19
|
+
const { rules, mcp, hasBrain } = linkProject(projectDir);
|
|
20
|
+
|
|
21
|
+
const mark = (a) => a === 'unchanged' ? '·' : a === 'skipped' ? '⚠' : '✓';
|
|
22
|
+
console.log(`klypix — linking the brain to every agent tool in ${projectDir}\n`);
|
|
23
|
+
console.log(' MCP server (so each tool can reach the brain):');
|
|
24
|
+
for (const r of mcp) console.log(` ${mark(r.action)} ${r.tool.padEnd(26)} ${r.file}${r.why ? ' (' + r.why + ')' : ''}`);
|
|
25
|
+
console.log('\n Rules (so each tool auto-reads + captures the brain):');
|
|
26
|
+
for (const r of rules) console.log(` ${mark(r.action)} ${r.tool.padEnd(26)} ${r.file}${r.why ? ' (' + r.why + ')' : ''}`);
|
|
27
|
+
|
|
28
|
+
const changed = [...rules, ...mcp].filter(r => r.action && !['unchanged', 'skipped'].includes(r.action)).length;
|
|
29
|
+
console.log(`\n✓ ${changed} file(s) written/updated — every agent opened in this project now reads + captures ./brain.klypix.`);
|
|
30
|
+
console.log(' Cline & Windsurf MCP servers live in their global config; the rules file points them at the brain regardless.');
|
|
31
|
+
if (!hasBrain) {
|
|
32
|
+
console.log('\n⚠ No ./brain.klypix here yet — the rules reference it for when you create one.');
|
|
33
|
+
console.log(' Make one in the KLYPIX app (Canvas → Save as brain) or with the create_canvas MCP tool.');
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error(`✗ link failed: ${e?.message || e}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -41,6 +41,12 @@ const log = (...a) => console.error('[klypix-mcp]', ...a);
|
|
|
41
41
|
// server setup; delegates to the dedicated bin so `npx klypix-install` also works.
|
|
42
42
|
if (process.argv[2] === 'install') { await import('./klypix-install.mjs'); process.exit(0); }
|
|
43
43
|
|
|
44
|
+
// `npx klypix-mcp link` — make THIS project's brain automatic for EVERY agent tool,
|
|
45
|
+
// not just Claude Code: drop each tool's native MCP config + rules file (Cursor, Cline,
|
|
46
|
+
// Windsurf, Copilot/VS Code, AGENTS.md) so any agent opened here reads + captures the
|
|
47
|
+
// brain on its own. Project-scoped (cwd); idempotent. Runs before any server setup.
|
|
48
|
+
if (process.argv[2] === 'link') { await import('./klypix-link.mjs'); process.exit(0); }
|
|
49
|
+
|
|
44
50
|
// `npx klypix-mcp init` — 60-second onboarding: seed a starter project brain in
|
|
45
51
|
// the current folder so a new user's FIRST contact isn't an empty vault, then
|
|
46
52
|
// print a paste-ready MCP config. Runs before any server setup.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.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",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"bin": {
|
|
28
28
|
"klypix-mcp": "bin/klypix-mcp.mjs",
|
|
29
|
+
"klypix-link": "bin/klypix-link.mjs",
|
|
29
30
|
"klypix-a2a": "bin/klypix-a2a.mjs",
|
|
30
31
|
"klypix-read": "bin/klypix-read.mjs",
|
|
31
32
|
"klypix-write": "bin/klypix-write.mjs",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// agent-rules — make a project's brain.klypix AUTOMATIC for EVERY agent tool, not
|
|
2
|
+
// just Claude Code. Claude Code gets the brain via hooks (settings.json, see
|
|
3
|
+
// klypix-install). Other agents (Cursor, Cline, Windsurf, Copilot/VS Code, and the
|
|
4
|
+
// AGENTS.md cross-tool standard) have no hook system — so we drop their NATIVE files:
|
|
5
|
+
//
|
|
6
|
+
// • an MCP server config → the agent CAN reach the brain's tools
|
|
7
|
+
// • a rules / instructions → the agent is TOLD to read the brain at task start and
|
|
8
|
+
// capture decisions, on every session, automatically
|
|
9
|
+
//
|
|
10
|
+
// Agent-neutral by construction: the brain DATA is one shared brain.klypix; this just
|
|
11
|
+
// teaches each tool to use it. Idempotent — owned files are rewritten wholesale; shared
|
|
12
|
+
// files (AGENTS.md, copilot-instructions.md) get a fenced block merged in place, never
|
|
13
|
+
// clobbering the user's own content. Pure fs/path; never throws for one bad target.
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
|
|
17
|
+
const FENCE_START = '<!-- klypix-brain:start (managed by klypix-mcp — re-run `npx klypix-mcp link`) -->';
|
|
18
|
+
const FENCE_END = '<!-- klypix-brain:end -->';
|
|
19
|
+
const FENCE_RE = /<!--\s*klypix-brain:start[\s\S]*?klypix-brain:end\s*-->/;
|
|
20
|
+
|
|
21
|
+
// The one canonical instruction every agent gets. Tool-and-CLI dual so it works whether
|
|
22
|
+
// or not the agent has the klypix-canvas MCP wired.
|
|
23
|
+
const BRAIN_INSTRUCTIONS = `## KLYPIX project brain
|
|
24
|
+
|
|
25
|
+
This project has a **spatial brain** at \`./brain.klypix\` — the living memory of its
|
|
26
|
+
decisions, open questions, and findings (the shared human↔agent memory for this repo).
|
|
27
|
+
Treat it as authoritative project context.
|
|
28
|
+
|
|
29
|
+
**At the start of a task — read it** so you know the project's state and past decisions:
|
|
30
|
+
- with the \`klypix-canvas\` MCP server: call \`search_canvases\` / \`read_canvas\` (canvas: \`"brain"\`), or \`brain_insights\` for the load-bearing cards.
|
|
31
|
+
- or via CLI: \`npx klypix-read brain.klypix\`
|
|
32
|
+
|
|
33
|
+
**When you make a real decision, finding, or milestone — capture it** so it persists for the next session/agent:
|
|
34
|
+
- with MCP: call \`brain_note\` with a one-line decision, or
|
|
35
|
+
- emit a marker line in your output: \`🧠 BRAIN [Area]: <one-line decision>\`, or
|
|
36
|
+
- via CLI: \`echo "🧠 BRAIN [Area]: <decision>" | npx klypix-append brain.klypix\`
|
|
37
|
+
|
|
38
|
+
Capture **sparingly** — real decisions/milestones, not routine steps. Link related cards with \`[[other-card]]\`.
|
|
39
|
+
|
|
40
|
+
**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.`;
|
|
41
|
+
|
|
42
|
+
const fencedBlock = () => `${FENCE_START}\n${BRAIN_INSTRUCTIONS}\n${FENCE_END}`;
|
|
43
|
+
|
|
44
|
+
const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } };
|
|
45
|
+
const ensureDir = (p) => fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
46
|
+
|
|
47
|
+
// Shared markdown (AGENTS.md, copilot-instructions.md): merge our fenced block in place.
|
|
48
|
+
function fenceMerge(file) {
|
|
49
|
+
let cur = '';
|
|
50
|
+
let had = false;
|
|
51
|
+
if (exists(file)) { cur = fs.readFileSync(file, 'utf8'); had = true; }
|
|
52
|
+
const block = fencedBlock();
|
|
53
|
+
let next;
|
|
54
|
+
let action;
|
|
55
|
+
if (FENCE_RE.test(cur)) { next = cur.replace(FENCE_RE, block); action = 'updated'; }
|
|
56
|
+
else if (cur.trim()) { next = cur.replace(/\s*$/, '') + '\n\n' + block + '\n'; action = 'merged'; }
|
|
57
|
+
else { next = block + '\n'; action = had ? 'merged' : 'created'; }
|
|
58
|
+
if (next === cur) return { action: 'unchanged' };
|
|
59
|
+
ensureDir(file); fs.writeFileSync(file, next, 'utf8');
|
|
60
|
+
return { action };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Owned dedicated rules file: rewrite wholesale (optional frontmatter for always-apply).
|
|
64
|
+
function writeDedicated(file, frontmatter) {
|
|
65
|
+
const body = (frontmatter ? frontmatter + '\n' : '') + fencedBlock() + '\n';
|
|
66
|
+
if (exists(file) && fs.readFileSync(file, 'utf8') === body) return { action: 'unchanged' };
|
|
67
|
+
ensureDir(file); fs.writeFileSync(file, body, 'utf8');
|
|
68
|
+
return { action: exists(file) ? 'updated' : 'created' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Project-level MCP config: add the klypix-canvas server, preserving any sibling servers.
|
|
72
|
+
// wrapKey differs by tool: Cursor/Claude use "mcpServers"; VS Code uses "servers".
|
|
73
|
+
function mergeMcpJson(file, wrapKey, withType) {
|
|
74
|
+
const entry = withType
|
|
75
|
+
? { type: 'stdio', command: 'npx', args: ['-y', 'klypix-mcp', '--vault', '.'] }
|
|
76
|
+
: { command: 'npx', args: ['-y', 'klypix-mcp', '--vault', '.'] };
|
|
77
|
+
let cfg = {};
|
|
78
|
+
if (exists(file)) {
|
|
79
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
80
|
+
if (raw.trim()) { try { cfg = JSON.parse(raw); } catch { return { action: 'skipped', why: 'invalid JSON — left untouched' }; } }
|
|
81
|
+
}
|
|
82
|
+
if (!cfg[wrapKey] || typeof cfg[wrapKey] !== 'object' || Array.isArray(cfg[wrapKey])) cfg[wrapKey] = {};
|
|
83
|
+
const before = JSON.stringify(cfg[wrapKey]['klypix-canvas']);
|
|
84
|
+
cfg[wrapKey]['klypix-canvas'] = entry;
|
|
85
|
+
if (JSON.stringify(cfg[wrapKey]['klypix-canvas']) === before) return { action: 'unchanged' };
|
|
86
|
+
ensureDir(file); fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
87
|
+
return { action: before === undefined ? 'created' : 'updated' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wire a project so EVERY agent tool reads + captures its brain automatically.
|
|
92
|
+
* @param {string} projectDir absolute project root (holds ./brain.klypix)
|
|
93
|
+
* @returns {{ rules: Array, mcp: Array, hasBrain: boolean }}
|
|
94
|
+
*/
|
|
95
|
+
export function linkProject(projectDir) {
|
|
96
|
+
const j = (...p) => path.join(projectDir, ...p);
|
|
97
|
+
const hasBrain = exists(j('brain.klypix')) || exists(j('brain.any'));
|
|
98
|
+
|
|
99
|
+
// ── Rules / instructions (the "automatically use the brain" half) ──────────────
|
|
100
|
+
const rules = [
|
|
101
|
+
// AGENTS.md — the emerging cross-tool standard (Codex, Jules, Zed, Cursor-also-reads…)
|
|
102
|
+
{ tool: 'AGENTS.md (cross-tool standard)', file: 'AGENTS.md', ...fenceMerge(j('AGENTS.md')) },
|
|
103
|
+
// Cursor — dedicated always-applied rule
|
|
104
|
+
{ tool: 'Cursor', file: '.cursor/rules/klypix-brain.mdc', ...writeDedicated(j('.cursor', 'rules', 'klypix-brain.mdc'),
|
|
105
|
+
'---\ndescription: KLYPIX project brain — read at task start, capture decisions\nalwaysApply: true\n---') },
|
|
106
|
+
// Windsurf — dedicated always-on rule
|
|
107
|
+
{ tool: 'Windsurf', file: '.windsurf/rules/klypix-brain.md', ...writeDedicated(j('.windsurf', 'rules', 'klypix-brain.md'),
|
|
108
|
+
'---\ntrigger: always_on\n---') },
|
|
109
|
+
// Cline — drops a file in .clinerules/ (Cline reads every file there)
|
|
110
|
+
{ tool: 'Cline', file: '.clinerules/klypix-brain.md', ...writeDedicated(j('.clinerules', 'klypix-brain.md'), '') },
|
|
111
|
+
// GitHub Copilot — repo-wide custom instructions
|
|
112
|
+
{ tool: 'GitHub Copilot', file: '.github/copilot-instructions.md', ...fenceMerge(j('.github', 'copilot-instructions.md')) },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
// ── MCP server config (the "can reach the brain's tools" half) ─────────────────
|
|
116
|
+
// Project-level files only; Claude Code is covered by `install` (hooks + bundled MCP),
|
|
117
|
+
// so we skip .mcp.json to avoid double-registering its server.
|
|
118
|
+
const mcp = [
|
|
119
|
+
{ tool: 'Cursor', file: '.cursor/mcp.json', ...mergeMcpJson(j('.cursor', 'mcp.json'), 'mcpServers', false) },
|
|
120
|
+
{ tool: 'VS Code (Copilot/Continue)', file: '.vscode/mcp.json', ...mergeMcpJson(j('.vscode', 'mcp.json'), 'servers', true) },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
return { rules, mcp, hasBrain };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { BRAIN_INSTRUCTIONS };
|
|
@@ -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);
|