klypix-mcp 1.32.0 → 1.34.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/README.md +30 -7
- package/bin/klypix-doctor.mjs +4 -3
- package/bin/klypix-install.mjs +56 -5
- package/bin/klypix-link.mjs +5 -4
- package/bin/klypix-mcp.mjs +2 -2
- package/package.json +5 -4
- package/src/agent-presence.mjs +297 -0
- package/src/agent-rules.mjs +320 -5
- package/src/brain-doctor.mjs +35 -16
- package/src/codex-brain-hook.mjs +135 -0
- package/src/codex-hooks.mjs +142 -0
- package/src/klypix-core.mjs +5 -5
package/README.md
CHANGED
|
@@ -10,13 +10,22 @@ You've seen the setup: point an AI at a folder of notes, watch the graph fill up
|
|
|
10
10
|
|
|
11
11
|
## Quick start
|
|
12
12
|
|
|
13
|
-
**Claude Code (native
|
|
13
|
+
**Claude Code + Codex (native, machine-wide):**
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
npx klypix-mcp install
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Claude Code keeps its live hooks for auto-brief + auto-capture. Codex gets a native
|
|
20
|
+
`~/.codex/config.toml` MCP registration, conditional global guidance, and lifecycle
|
|
21
|
+
hooks for truthful cross-agent presence and one-time messages. Existing MCP servers,
|
|
22
|
+
hooks, settings, and personal instructions are preserved; KLYPIX owns only its fenced
|
|
23
|
+
blocks and `codex-brain-hook.mjs` handlers, with backups before changes. Restart Codex
|
|
24
|
+
after installation and approve the KLYPIX hooks when Codex presents its trust review.
|
|
25
|
+
|
|
26
|
+
Give a project a brain by dropping a `brain.klypix` in it — the
|
|
27
|
+
[KLYPIX app](https://klypix.com) does it in one click (*Save canvas as project brain*),
|
|
28
|
+
or `create_canvas` makes one from any agent.
|
|
20
29
|
|
|
21
30
|
**Every other agent tool (one command per project):**
|
|
22
31
|
|
|
@@ -24,7 +33,9 @@ Every repo on your machine with a `brain.klypix` now auto-briefs each session wi
|
|
|
24
33
|
npx klypix-mcp link
|
|
25
34
|
```
|
|
26
35
|
|
|
27
|
-
|
|
36
|
+
Adds project-native config for Codex, Cursor, and VS Code/Copilot, plus rules for
|
|
37
|
+
Cline, Windsurf, Gemini CLI, Aider, and any AGENTS.md-reading agent. Verify all
|
|
38
|
+
managed files without changing them with `npx klypix-mcp link --check`.
|
|
28
39
|
|
|
29
40
|
## What a project brain does
|
|
30
41
|
|
|
@@ -63,7 +74,7 @@ Any MCP client gets the tools without the hooks. For **Claude Desktop**, in `cla
|
|
|
63
74
|
|
|
64
75
|
Then ask your agent things like *"what did we decide about auth?"*, *"challenge this: let's switch to polling"*, or *"turn these notes into a board."*
|
|
65
76
|
|
|
66
|
-
## The
|
|
77
|
+
## The 17 verbs
|
|
67
78
|
|
|
68
79
|
| Tool | What it does |
|
|
69
80
|
|---|---|
|
|
@@ -73,7 +84,7 @@ Then ask your agent things like *"what did we decide about auth?"*, *"challenge
|
|
|
73
84
|
| `brain_reconcile` | Find stale-vs-correction pairs + unrecorded migrations |
|
|
74
85
|
| `brain_insights` | Hubs, orphaned decisions, stale questions, area sizes |
|
|
75
86
|
| `brain_garden` | Maintenance pass over the brain |
|
|
76
|
-
| `brain_doctor` | Self-diagnosis: version
|
|
87
|
+
| `brain_doctor` | Self-diagnosis: version, host adapters, active lifecycle sessions, projection drift |
|
|
77
88
|
| `brain_message` | Session-to-session coordination notes |
|
|
78
89
|
| `brain_connect` | Find + draw related-but-unlinked cards |
|
|
79
90
|
| `canvas_view` | The board as an MCP App — Apps-capable chats get an interactive spatial view; everyone else gets clean text |
|
|
@@ -86,11 +97,23 @@ Then ask your agent things like *"what did we decide about auth?"*, *"challenge
|
|
|
86
97
|
|
|
87
98
|
### Tools vs. the *automatic* brain
|
|
88
99
|
|
|
89
|
-
This package is the **agent-neutral read/write surface** — any MCP client gets the tools above on demand (*pull*), in any project.
|
|
100
|
+
This package is the **agent-neutral read/write surface** — any MCP client gets the tools above on demand (*pull*), in any project. `install` wires Claude Code's existing capture path plus Codex's native MCP, conditional guidance, and separate presence adapter; `link` projects the repository-level MCP and instruction files used by other coding agents. Full transcript-driven auto-capture remains a Claude Code capability. Codex reports lifecycle presence and captures durable decisions explicitly through `brain_note`; hookless clients can use the MCP surface but are not reported as active.
|
|
101
|
+
|
|
102
|
+
### Agent-neutral live presence
|
|
103
|
+
|
|
104
|
+
An active session means a host lifecycle hook heartbeated within 10 minutes; a row in a
|
|
105
|
+
recent-chat list is history, not presence. Claude Code and Codex publish into the same
|
|
106
|
+
per-brain lane, while each host keeps its own adapter and capture behavior. `SessionEnd`
|
|
107
|
+
removes clean exits immediately, and the TTL covers crashes.
|
|
108
|
+
|
|
109
|
+
Future hosts can import `klypix-mcp/presence` and map their lifecycle events to
|
|
110
|
+
`upsertSession`, `removeSession`, and `receiveMessages`. The shared contract accepts
|
|
111
|
+
`id`, `client`, `surface`, `model`, `branch`, `intent`, and touched `files`; host-specific
|
|
112
|
+
transcript parsing stays outside the protocol.
|
|
90
113
|
|
|
91
114
|
### Updates — the propagation contract
|
|
92
115
|
|
|
93
|
-
`install` lays the whole brain (hooks + engine + local MCP server) into `~/.claude/project-brain`,
|
|
116
|
+
`install` lays the whole brain (hooks + engine + local MCP server) into `~/.claude/project-brain`, then points both Claude and Codex at that installed bundle so there is no npx cache to go stale. Updates are automatic through the Claude session-start hook: it checks npm (≤ once/24h, fail-open, disable with `KLYPIX_AUTO_UPDATE=0`) and self-installs newer releases, including healing the Codex registration. One honest caveat: a running stdio server can't hot-swap — a new binary loads on the next full app launch (or `/mcp` reconnect); `brain_doctor`'s RUNNING line tells you when that's needed.
|
|
94
117
|
|
|
95
118
|
## Also speaks A2A (Agent-to-Agent)
|
|
96
119
|
|
package/bin/klypix-doctor.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// klypix-doctor — `npx klypix-mcp doctor`. The brain's self-check: is THIS machine's
|
|
3
|
-
// brain current, are the
|
|
4
|
-
//
|
|
5
|
-
// reconcile block. Read-only (never writes). Exits 0 = ALIGNED,
|
|
3
|
+
// brain current, are the Claude capture and Codex presence adapters wired, what verbs
|
|
4
|
+
// does it expose, which lifecycle sessions are live, and is the harness projection in
|
|
5
|
+
// sync? ONE fact, ONE reconcile block. Read-only (never writes). Exits 0 = ALIGNED,
|
|
6
|
+
// 1 = DRIFTED — so it
|
|
6
7
|
// doubles as a pre-commit / CI readiness gate.
|
|
7
8
|
//
|
|
8
9
|
// npx klypix-mcp doctor # this project + this machine's brain
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -16,7 +16,12 @@ import fs from 'fs';
|
|
|
16
16
|
import os from 'os';
|
|
17
17
|
import path from 'path';
|
|
18
18
|
import { fileURLToPath } from 'url';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
connectCodexMcpServer,
|
|
21
|
+
mcpServerEntry,
|
|
22
|
+
mergeCodexGlobalInstructions,
|
|
23
|
+
} from '../src/agent-rules.mjs';
|
|
24
|
+
import { mergeCodexPresenceHooks } from '../src/codex-hooks.mjs';
|
|
20
25
|
|
|
21
26
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
27
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
@@ -29,6 +34,7 @@ const HOME = os.homedir();
|
|
|
29
34
|
const CLAUDE_DIR = path.join(HOME, '.claude');
|
|
30
35
|
const BRAIN_DIR = path.join(CLAUDE_DIR, 'project-brain');
|
|
31
36
|
const SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
|
|
37
|
+
const CODEX_CONFIG = path.join(HOME, '.codex', 'config.toml');
|
|
32
38
|
const HOOK_MARK = 'global-brain-hook';
|
|
33
39
|
const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } };
|
|
34
40
|
const fwd = (p) => p.replace(/\\/g, '/');
|
|
@@ -40,6 +46,39 @@ function copyDir(src, dest) {
|
|
|
40
46
|
}
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
// Codex has three independent integration layers: MCP gives it the brain tools,
|
|
50
|
+
// a conditional managed block in ~/.codex/AGENTS.md tells every brain project to
|
|
51
|
+
// use them, and lifecycle hooks publish live presence to the shared session lane.
|
|
52
|
+
function wireCodex() {
|
|
53
|
+
const mcp = connectCodexMcpServer({
|
|
54
|
+
configPath: CODEX_CONFIG,
|
|
55
|
+
entry: mcpServerEntry({ vault: '.', home: HOME }),
|
|
56
|
+
});
|
|
57
|
+
const instructions = mergeCodexGlobalInstructions(HOME);
|
|
58
|
+
const hookScript = path.join(BRAIN_DIR, 'codex-brain-hook.mjs');
|
|
59
|
+
const presence = exists(hookScript)
|
|
60
|
+
? mergeCodexPresenceHooks({
|
|
61
|
+
home: HOME,
|
|
62
|
+
command: `node "${fwd(hookScript)}"`,
|
|
63
|
+
})
|
|
64
|
+
: { ok: true, action: 'not-available' };
|
|
65
|
+
return { mcp, instructions, presence, ok: mcp.ok && instructions.ok && presence.ok };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function reportCodex(result) {
|
|
69
|
+
if (result.ok) {
|
|
70
|
+
const live = result.presence.action === 'not-available'
|
|
71
|
+
? 'live presence pending a current brain bundle'
|
|
72
|
+
: `live presence (${result.presence.action})`;
|
|
73
|
+
console.log(`✓ wired Codex: MCP tools (${result.mcp.action}) + conditional project-brain guidance (${result.instructions.action}) + ${live}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!result.mcp.ok) console.error(`⚠ Codex MCP was not changed: ${result.mcp.error}`);
|
|
77
|
+
if (!result.instructions.ok) console.error(`⚠ Codex guidance was not changed: ${result.instructions.error}`);
|
|
78
|
+
if (!result.presence.ok) console.error(`⚠ Codex live presence was not changed: ${result.presence.error}`);
|
|
79
|
+
console.error(' Claude Code installation is intact. Fix the Codex warning, then re-run this command.');
|
|
80
|
+
}
|
|
81
|
+
|
|
43
82
|
// ── Never-downgrade gate ─────────────────────────────────────────────────────
|
|
44
83
|
// The brain version is a UNIFIED namespace = the klypix-mcp version, stamped by BOTH
|
|
45
84
|
// the npm install (here) and the desktop bundle, so the two channels compare cleanly.
|
|
@@ -48,8 +87,16 @@ function copyDir(src, dest) {
|
|
|
48
87
|
const cmpSemver = (a, b) => { const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0), pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0); for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); } return 0; };
|
|
49
88
|
const cur = (() => { try { return JSON.parse(fs.readFileSync(path.join(BRAIN_DIR, '.brain-version.json'), 'utf8')); } catch { return null; } })();
|
|
50
89
|
if (!FORCE && cur) {
|
|
51
|
-
if (cur.dev === true) {
|
|
52
|
-
|
|
90
|
+
if (cur.dev === true) {
|
|
91
|
+
console.log(`• A dev deploy owns ${BRAIN_DIR} (dev:true) — leaving it untouched. Re-run with --force to override.`);
|
|
92
|
+
reportCodex(wireCodex());
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
if (cur.brainVersion && cmpSemver(cur.brainVersion, VERSION) > 0) {
|
|
96
|
+
console.log(`• Installed brain v${cur.brainVersion} is newer than this package v${VERSION} — not downgrading. Re-run with --force to override.`);
|
|
97
|
+
reportCodex(wireCodex());
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
53
100
|
}
|
|
54
101
|
|
|
55
102
|
// ── Install lock (auto-propagation, part D — concurrency) ─────────────────────
|
|
@@ -164,7 +211,7 @@ try {
|
|
|
164
211
|
// canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
|
|
165
212
|
// file must never get a JS-comment banner) beside the flat server, which
|
|
166
213
|
// resolves it via its ./canvas-view-app.html candidate path.
|
|
167
|
-
for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'canvas-view-app.html']) {
|
|
214
|
+
for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
|
|
168
215
|
const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
|
|
169
216
|
}
|
|
170
217
|
for (const [src, dst] of [['klypix-mcp.mjs', 'klypix-mcp-server.mjs'], ['klypix-a2a.mjs', 'klypix-a2a-server.mjs']]) {
|
|
@@ -218,6 +265,9 @@ try {
|
|
|
218
265
|
// (heals an existing stale config so the next MCP server spawn runs current).
|
|
219
266
|
const migrated = migrateProjectMcpConfig();
|
|
220
267
|
|
|
268
|
+
// Codex needs native MCP tools, conditional guidance, and lifecycle presence.
|
|
269
|
+
const codex = wireCodex();
|
|
270
|
+
|
|
221
271
|
// 8) READINESS check — re-read what we just wrote and confirm all 4 hooks actually
|
|
222
272
|
// took (a malformed pre-existing group, a partial merge, or a later hand-edit can
|
|
223
273
|
// leave the brain LIVE but not LEARNING — liveness ≠ readiness). Warn, don't fail.
|
|
@@ -226,12 +276,13 @@ try {
|
|
|
226
276
|
const notWired = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse'].filter(e => !wiredFor(e));
|
|
227
277
|
|
|
228
278
|
if (gotLock) releaseLock();
|
|
279
|
+
reportCodex(codex);
|
|
229
280
|
console.log(`✓ installed klypix brain v${VERSION} → ${BRAIN_DIR} (${n} scripts, ${deps} dep packages)`);
|
|
230
281
|
if (!notWired.length) console.log('✓ wired 4 hooks: SessionStart · UserPromptSubmit (--prompt) · Stop (--capture) · PostToolUse (--live) → settings.json');
|
|
231
282
|
else console.error(`⚠ readiness: ${notWired.length} hook(s) did NOT take (${notWired.join(', ')}) — the brain will read but not capture/sync. Re-run \`npx klypix-mcp install --force\` or check ${SETTINGS}.`);
|
|
232
283
|
console.log(`✓ MCP server runs from the local bundle (node ${fwd(path.join(BRAIN_DIR, 'klypix-mcp-server.mjs'))}) — no npx cache, works offline, always the installed version.`);
|
|
233
284
|
if (migrated) console.log(`✓ migrated ${migrated.file} klypix-canvas server: ${migrated.from} → ${migrated.to} (backup: .mcp.json.klypix-bak). Reconnect (/mcp) or restart to pick it up.`);
|
|
234
|
-
console.log('
|
|
285
|
+
console.log(' Claude Code keeps its existing auto-brief/capture hooks; Codex uses separate lifecycle hooks for live presence and native MCP for durable memory.');
|
|
235
286
|
console.log(' ⚠ Open sessions keep their OLD server until relaunched: fully quit & reopen the app (a session resume / new chat does NOT respawn the MCP server). `brain_doctor`\'s RUNNING line confirms when you\'re current.');
|
|
236
287
|
console.log(' Verify anytime: `npx klypix-mcp doctor` (is the brain current + wired + in sync, who else is live).');
|
|
237
288
|
} catch (e) {
|
package/bin/klypix-link.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// klypix-link — make THIS project's brain automatic for EVERY agent tool.
|
|
3
|
-
// `npx klypix-mcp install` gives Claude Code
|
|
4
|
-
// same automatic read+capture
|
|
5
|
-
// Aider, and any AGENTS.md-reading agent,
|
|
3
|
+
// `npx klypix-mcp install` gives Claude Code hooks and wires Codex globally; `link`
|
|
4
|
+
// extends the same automatic read+capture per project to Codex, Cursor, Cline,
|
|
5
|
+
// Windsurf, Copilot/VS Code, Gemini CLI, Aider, and any AGENTS.md-reading agent,
|
|
6
|
+
// by dropping each tool's native MCP config +
|
|
6
7
|
// rules file in the current project. Idempotent — re-run anytime to refresh. The managed
|
|
7
8
|
// block carries the brain version + a content hash, so:
|
|
8
9
|
//
|
|
@@ -48,7 +49,7 @@ try {
|
|
|
48
49
|
|
|
49
50
|
const changed = [...rules, ...mcp].filter(r => r.action && !['unchanged', 'skipped'].includes(r.action)).length;
|
|
50
51
|
console.log(`\n✓ ${changed} file(s) written/updated — every agent opened in this project now reads + captures ./brain.klypix.`);
|
|
51
|
-
console.log(' Cline & Windsurf
|
|
52
|
+
console.log(' Codex gets .codex/config.toml + AGENTS.md; Cline & Windsurf use their global MCP config plus project rules.');
|
|
52
53
|
console.log(' Verify anytime with `npx klypix-mcp link --check` (or `npx klypix-mcp doctor`).');
|
|
53
54
|
if (!hasBrain) {
|
|
54
55
|
console.log('\n⚠ No ./brain.klypix here yet — the rules reference it for when you create one.');
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -265,7 +265,7 @@ server.registerTool('brain_note', {
|
|
|
265
265
|
|
|
266
266
|
server.registerTool('brain_message', {
|
|
267
267
|
title: 'Message the other live agent sessions on this project (one-time note, not a brain card)',
|
|
268
|
-
description: 'Leave a DELIBERATE, targeted note for the OTHER
|
|
268
|
+
description: 'Leave a DELIBERATE, targeted note for the OTHER active 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 presence-wired sessions (Claude Code and Codex, with more host adapters supported by the shared protocol). Each receives it once through its lifecycle hook. A hookless peer can send but will NOT receive. Ephemeral (expires in 24h) and NOT persisted to the brain — for a durable decision use brain_note instead.',
|
|
269
269
|
inputSchema: {
|
|
270
270
|
text: z.string().describe('The note to deliver (kept to 400 chars).'),
|
|
271
271
|
to: z.string().optional().describe('Target hint — a peer session id-prefix or branch name; omit or "all" for every live session.'),
|
|
@@ -278,7 +278,7 @@ server.registerTool('brain_message', {
|
|
|
278
278
|
|
|
279
279
|
server.registerTool('brain_doctor', {
|
|
280
280
|
title: 'Brain doctor — is this brain current, wired, and in sync?',
|
|
281
|
-
description: 'Read-only self-check of the installed klypix brain, as ONE verdict: VERSION (
|
|
281
|
+
description: 'Read-only self-check of the installed klypix brain, as ONE verdict: VERSION (deployed brain-core + optional npm currency), CLAUDE (existing 4-hook capture readiness), CODEX (5-hook live-presence readiness), TOOLS (discoverable MCP verbs), SESSIONS (all active lifecycle sessions across hosts, never recent-chat history), and HARNESS (projection drift). Use to answer "is my brain current, correctly installed, in sync, and who is actually live?" without file-spelunking. Never writes. The MCP-callable twin of `npx klypix-mcp doctor`.',
|
|
282
282
|
inputSchema: {
|
|
283
283
|
project: z.string().optional().describe('Project dir to audit harness + peers for. Defaults to the server\'s working directory.'),
|
|
284
284
|
check_npm: z.boolean().optional().describe('Also fetch npm latest to flag a stale brain (default false — this one does a network `npm view`).'),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Cursor, Cline, any model.",
|
|
3
|
+
"version": "1.34.0",
|
|
4
|
+
"description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Codex, Cursor, Cline, any model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"keywords": [
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"exports": {
|
|
39
39
|
".": "./index.mjs",
|
|
40
40
|
"./format": "./src/klypix-format.mjs",
|
|
41
|
-
"./core": "./src/klypix-core.mjs"
|
|
41
|
+
"./core": "./src/klypix-core.mjs",
|
|
42
|
+
"./presence": "./src/agent-presence.mjs"
|
|
42
43
|
},
|
|
43
44
|
"files": [
|
|
44
45
|
"src",
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
"node": ">=18"
|
|
54
55
|
},
|
|
55
56
|
"scripts": {
|
|
56
|
-
"test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs"
|
|
57
|
+
"test": "node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs"
|
|
57
58
|
},
|
|
58
59
|
"dependencies": {
|
|
59
60
|
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Agent-neutral live-session registry.
|
|
2
|
+
//
|
|
3
|
+
// Every host adapter writes the same per-brain lane used by Claude Code's
|
|
4
|
+
// existing global-brain-hook. The schema is deliberately additive: old Claude
|
|
5
|
+
// entries remain valid, while newer adapters can identify their client, model,
|
|
6
|
+
// surface, current intent, and touched files.
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
export const SESSION_FRESH_MS = 10 * 60 * 1000;
|
|
13
|
+
export const MESSAGE_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
const sha16 = (value) => crypto.createHash('sha1').update(String(value)).digest('hex').slice(0, 16);
|
|
16
|
+
const normBrainPath = (value) => String(value).replace(/\\/g, '/').replace(/^[a-zA-Z]:/, (m) => m.toLowerCase());
|
|
17
|
+
const canonicalPath = (value) => {
|
|
18
|
+
try { return fs.realpathSync.native(value); }
|
|
19
|
+
catch { return path.resolve(value); }
|
|
20
|
+
};
|
|
21
|
+
const sleepSync = (ms) => {
|
|
22
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
23
|
+
catch { /* best effort */ }
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function findProjectBrain(cwd = process.cwd()) {
|
|
27
|
+
let dir = path.resolve(cwd);
|
|
28
|
+
for (;;) {
|
|
29
|
+
for (const name of ['brain.klypix', 'brain.any']) {
|
|
30
|
+
const candidate = path.join(dir, name);
|
|
31
|
+
try { if (fs.statSync(candidate).isFile()) return candidate; }
|
|
32
|
+
catch { /* keep walking */ }
|
|
33
|
+
}
|
|
34
|
+
const parent = path.dirname(dir);
|
|
35
|
+
if (parent === dir) return null;
|
|
36
|
+
dir = parent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function laneFileFor(brainPath, home = os.homedir()) {
|
|
41
|
+
const key = sha16(normBrainPath(canonicalPath(brainPath)));
|
|
42
|
+
return path.join(home, '.claude', 'project-brain', 'sessions', `${key}.json`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readLane(file) {
|
|
46
|
+
try {
|
|
47
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
48
|
+
return data && typeof data === 'object' ? data : {};
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function acquireLock(lockFile, { tries = 30, waitMs = 20, staleMs = 30_000 } = {}) {
|
|
55
|
+
try { fs.mkdirSync(path.dirname(lockFile), { recursive: true }); }
|
|
56
|
+
catch { /* write below will report failure */ }
|
|
57
|
+
for (let i = 0; i < tries; i++) {
|
|
58
|
+
try {
|
|
59
|
+
const fd = fs.openSync(lockFile, 'wx');
|
|
60
|
+
fs.writeSync(fd, String(process.pid));
|
|
61
|
+
fs.closeSync(fd);
|
|
62
|
+
return true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error?.code !== 'EEXIST') return false;
|
|
65
|
+
try {
|
|
66
|
+
if (Date.now() - fs.statSync(lockFile).mtimeMs > staleMs) {
|
|
67
|
+
fs.unlinkSync(lockFile);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
} catch { /* raced with the lock owner */ }
|
|
71
|
+
sleepSync(waitMs);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function releaseLock(lockFile) {
|
|
78
|
+
try { fs.unlinkSync(lockFile); }
|
|
79
|
+
catch { /* best effort */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pruneSessions(sessions, now) {
|
|
83
|
+
return (Array.isArray(sessions) ? sessions : [])
|
|
84
|
+
.filter((session) => session?.id && now - Number(session.lastSeen || 0) < SESSION_FRESH_MS);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pruneMessages(messages, now) {
|
|
88
|
+
return (Array.isArray(messages) ? messages : [])
|
|
89
|
+
.filter((message) => message?.id && now - Number(message.ts || 0) < MESSAGE_FRESH_MS);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeFiles(files) {
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const file of Array.isArray(files) ? files : []) {
|
|
96
|
+
const value = String(file || '').replace(/\\/g, '/').trim();
|
|
97
|
+
if (!value || seen.has(value)) continue;
|
|
98
|
+
seen.add(value);
|
|
99
|
+
out.push(value);
|
|
100
|
+
}
|
|
101
|
+
return out.slice(-20);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function listActiveSessions({ brainPath, home, now = Date.now() }) {
|
|
105
|
+
if (!brainPath) return [];
|
|
106
|
+
const lane = readLane(laneFileFor(brainPath, home));
|
|
107
|
+
return pruneSessions(lane.sessions, now).sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function upsertSession({
|
|
111
|
+
brainPath,
|
|
112
|
+
id,
|
|
113
|
+
client = 'unknown',
|
|
114
|
+
surface = null,
|
|
115
|
+
model = null,
|
|
116
|
+
permissionMode = null,
|
|
117
|
+
branch = null,
|
|
118
|
+
intent,
|
|
119
|
+
files,
|
|
120
|
+
event = null,
|
|
121
|
+
cwd = null,
|
|
122
|
+
home,
|
|
123
|
+
now = Date.now(),
|
|
124
|
+
}) {
|
|
125
|
+
if (!brainPath || !id) return [];
|
|
126
|
+
const laneFile = laneFileFor(brainPath, home);
|
|
127
|
+
const lockFile = laneFile + '.lock';
|
|
128
|
+
const gotLock = acquireLock(lockFile);
|
|
129
|
+
if (!gotLock) return listActiveSessions({ brainPath, home, now });
|
|
130
|
+
try {
|
|
131
|
+
const data = readLane(laneFile);
|
|
132
|
+
const sessions = pruneSessions(data.sessions, now);
|
|
133
|
+
const previous = sessions.find((session) => session.id === id) || {};
|
|
134
|
+
const mergedFiles = files === undefined
|
|
135
|
+
? normalizeFiles(previous.files)
|
|
136
|
+
: normalizeFiles([...(previous.files || []), ...(files || [])]);
|
|
137
|
+
const next = {
|
|
138
|
+
...previous,
|
|
139
|
+
id: String(id),
|
|
140
|
+
pid: process.pid,
|
|
141
|
+
project: path.basename(path.dirname(brainPath)),
|
|
142
|
+
client: String(client || previous.client || 'unknown'),
|
|
143
|
+
surface: surface ?? previous.surface ?? null,
|
|
144
|
+
model: model ?? previous.model ?? null,
|
|
145
|
+
permissionMode: permissionMode ?? previous.permissionMode ?? null,
|
|
146
|
+
branch: branch ?? previous.branch ?? null,
|
|
147
|
+
intent: intent !== undefined ? String(intent || '').replace(/\s+/g, ' ').trim().slice(0, 160) : (previous.intent || ''),
|
|
148
|
+
files: mergedFiles,
|
|
149
|
+
event: event ?? previous.event ?? null,
|
|
150
|
+
cwd: cwd ? path.resolve(cwd) : (previous.cwd || path.dirname(brainPath)),
|
|
151
|
+
startedAt: previous.startedAt || now,
|
|
152
|
+
lastSeen: now,
|
|
153
|
+
};
|
|
154
|
+
const kept = sessions.filter((session) => session.id !== id);
|
|
155
|
+
kept.push(next);
|
|
156
|
+
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
157
|
+
fs.writeFileSync(laneFile, JSON.stringify({
|
|
158
|
+
...data,
|
|
159
|
+
sessions: kept.slice(-40),
|
|
160
|
+
messages: pruneMessages(data.messages, now).slice(-30),
|
|
161
|
+
}));
|
|
162
|
+
return kept.sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0));
|
|
163
|
+
} finally {
|
|
164
|
+
if (gotLock) releaseLock(lockFile);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function removeSession({ brainPath, id, home, now = Date.now() }) {
|
|
169
|
+
if (!brainPath || !id) return [];
|
|
170
|
+
const laneFile = laneFileFor(brainPath, home);
|
|
171
|
+
const lockFile = laneFile + '.lock';
|
|
172
|
+
const gotLock = acquireLock(lockFile);
|
|
173
|
+
if (!gotLock) return listActiveSessions({ brainPath, home, now });
|
|
174
|
+
try {
|
|
175
|
+
const data = readLane(laneFile);
|
|
176
|
+
const sessions = pruneSessions(data.sessions, now).filter((session) => session.id !== id);
|
|
177
|
+
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
178
|
+
fs.writeFileSync(laneFile, JSON.stringify({
|
|
179
|
+
...data,
|
|
180
|
+
sessions,
|
|
181
|
+
messages: pruneMessages(data.messages, now).slice(-30),
|
|
182
|
+
}));
|
|
183
|
+
return sessions;
|
|
184
|
+
} finally {
|
|
185
|
+
if (gotLock) releaseLock(lockFile);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function messageTargetsSession(message, session, sessionId) {
|
|
190
|
+
const target = String(message?.to || '').trim().toLowerCase();
|
|
191
|
+
if (!target || target === 'all' || target === '*') return true;
|
|
192
|
+
const searchable = [
|
|
193
|
+
String(sessionId || '').slice(0, 8),
|
|
194
|
+
session?.branch,
|
|
195
|
+
session?.intent,
|
|
196
|
+
session?.client,
|
|
197
|
+
session?.surface,
|
|
198
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
199
|
+
return searchable.includes(target);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function receiveMessages({
|
|
203
|
+
brainPath,
|
|
204
|
+
sessionId,
|
|
205
|
+
ignoreTexts = [],
|
|
206
|
+
home,
|
|
207
|
+
now = Date.now(),
|
|
208
|
+
}) {
|
|
209
|
+
if (!brainPath || !sessionId) return [];
|
|
210
|
+
const laneFile = laneFileFor(brainPath, home);
|
|
211
|
+
const lockFile = laneFile + '.lock';
|
|
212
|
+
const gotLock = acquireLock(lockFile);
|
|
213
|
+
if (!gotLock) return [];
|
|
214
|
+
try {
|
|
215
|
+
const data = readLane(laneFile);
|
|
216
|
+
const sessions = pruneSessions(data.sessions, now);
|
|
217
|
+
const messages = pruneMessages(data.messages, now);
|
|
218
|
+
const me = sessions.find((session) => session.id === sessionId);
|
|
219
|
+
const unseen = messages.filter((message) =>
|
|
220
|
+
message.from !== sessionId
|
|
221
|
+
&& !(Array.isArray(message.seen) && message.seen.includes(sessionId))
|
|
222
|
+
&& messageTargetsSession(message, me, sessionId));
|
|
223
|
+
if (!unseen.length) return [];
|
|
224
|
+
|
|
225
|
+
const unseenIds = new Set(unseen.map((message) => message.id));
|
|
226
|
+
for (const message of messages) {
|
|
227
|
+
if (!unseenIds.has(message.id)) continue;
|
|
228
|
+
if (!Array.isArray(message.seen)) message.seen = [];
|
|
229
|
+
if (!message.seen.includes(sessionId)) message.seen.push(sessionId);
|
|
230
|
+
}
|
|
231
|
+
fs.writeFileSync(laneFile, JSON.stringify({ ...data, sessions, messages }));
|
|
232
|
+
|
|
233
|
+
const ignored = new Set(ignoreTexts.map((text) => String(text || '').replace(/\s+/g, ' ').trim().toLowerCase()));
|
|
234
|
+
const shown = [];
|
|
235
|
+
const seenText = new Set();
|
|
236
|
+
for (const message of unseen) {
|
|
237
|
+
const key = String(message.text || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
238
|
+
if (!key || ignored.has(key) || seenText.has(key)) continue;
|
|
239
|
+
seenText.add(key);
|
|
240
|
+
shown.push(message);
|
|
241
|
+
}
|
|
242
|
+
return shown.slice(0, 6);
|
|
243
|
+
} finally {
|
|
244
|
+
if (gotLock) releaseLock(lockFile);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const clientLabel = (session) => {
|
|
249
|
+
const client = String(session?.client || '').toLowerCase();
|
|
250
|
+
if (!client) return 'Claude Code';
|
|
251
|
+
if (client === 'codex') return 'Codex';
|
|
252
|
+
if (client === 'claude-code' || client === 'claude') return 'Claude Code';
|
|
253
|
+
return client.replace(/(^|[-_ ])([a-z])/g, (_m, prefix, letter) => `${prefix}${letter.toUpperCase()}`);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export function formatPresenceMessage(sessions, selfId, { includeSolo = false, now = Date.now() } = {}) {
|
|
257
|
+
const active = Array.isArray(sessions) ? sessions : [];
|
|
258
|
+
const others = active.filter((session) => session.id !== selfId);
|
|
259
|
+
if (!includeSolo && !others.length) return '';
|
|
260
|
+
|
|
261
|
+
const counts = new Map();
|
|
262
|
+
for (const session of active) {
|
|
263
|
+
const label = clientLabel(session);
|
|
264
|
+
counts.set(label, (counts.get(label) || 0) + 1);
|
|
265
|
+
}
|
|
266
|
+
const mix = [...counts.entries()].map(([label, count]) => `${label} ${count}`).join(', ');
|
|
267
|
+
const lines = [
|
|
268
|
+
`KLYPIX session awareness: ${active.length} active session${active.length === 1 ? '' : 's'} on this project (${mix || 'none'}); ${others.length} other${others.length === 1 ? '' : 's'} besides this chat.`,
|
|
269
|
+
];
|
|
270
|
+
if (!others.length) {
|
|
271
|
+
lines.push('Saved/recent chat rows are history, not active sessions; a session counts only while its lifecycle hook has heartbeated in the last 10 minutes.');
|
|
272
|
+
return lines.join('\n');
|
|
273
|
+
}
|
|
274
|
+
lines.push('Other active sessions:');
|
|
275
|
+
for (const session of others.slice(0, 8)) {
|
|
276
|
+
const ageMin = Math.max(0, Math.round((now - Number(session.lastSeen || now)) / 60_000));
|
|
277
|
+
const details = [
|
|
278
|
+
clientLabel(session),
|
|
279
|
+
session.branch ? `branch ${session.branch}` : null,
|
|
280
|
+
session.intent ? `"${String(session.intent).slice(0, 90)}"` : null,
|
|
281
|
+
`${ageMin}m ago`,
|
|
282
|
+
].filter(Boolean);
|
|
283
|
+
lines.push(`- ${String(session.id).slice(0, 8)}: ${details.join(' | ')}`);
|
|
284
|
+
}
|
|
285
|
+
lines.push('Coordinate before touching shared files; use brain_message for a targeted note.');
|
|
286
|
+
return lines.join('\n');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function formatReceivedMessages(messages, now = Date.now()) {
|
|
290
|
+
if (!Array.isArray(messages) || !messages.length) return '';
|
|
291
|
+
const lines = ['KLYPIX message(s) from another active session:'];
|
|
292
|
+
for (const message of messages) {
|
|
293
|
+
const ageMin = Math.max(0, Math.round((now - Number(message.ts || now)) / 60_000));
|
|
294
|
+
lines.push(`- from ${String(message.from || '?').slice(0, 12)} (${ageMin}m ago): ${String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}`);
|
|
295
|
+
}
|
|
296
|
+
return lines.join('\n');
|
|
297
|
+
}
|