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 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 — live hooks, zero effort):**
13
+ **Claude Code + Codex (native, machine-wide):**
14
14
 
15
15
  ```bash
16
16
  npx klypix-mcp install
17
17
  ```
18
18
 
19
- Every repo on your machine with a `brain.klypix` now auto-briefs each session with the project's state and auto-captures decisions as you work. Give a project a brain by dropping a `brain.klypix` in it — the [KLYPIX app](https://klypix.com) does it in one click (*Save canvas as project brain*), or `create_canvas` makes one from any agent.
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
- Extends the brain to Cursor & VS Code/Copilot (MCP config + rules) and to Cline, Windsurf, Gemini CLI, Aider, and any AGENTS.md-reading agent — always-on rules that teach the tool to read and capture the brain.
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 16 verbs
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 alignment, hooks wired, projection drift |
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. The **automatic** brain — auto-capturing decisions from your work, injecting the brief into each session, coordinating concurrent sessions (*push*) — runs in a host **hook**, which `npx klypix-mcp install` wires for Claude Code (and the [KLYPIX desktop app](https://klypix.com) ships built-in). Other tools get the always-on rules via `link`.
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`, and the emitted config runs the server **from that installed bundle** (no npx cache to go stale). Updates are then automatic: at session start the hook checks npm (≤ once/24h, fail-open, disable with `KLYPIX_AUTO_UPDATE=0`) and self-installs newer releases. 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.
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
 
@@ -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 Claude Code hooks wired, what verbs does it expose, who else
4
- // is live on this project's brain, and is the harness projection in sync? ONE fact, ONE
5
- // reconcile block. Read-only (never writes). Exits 0 = ALIGNED, 1 = DRIFTED — so it
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
@@ -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 { mcpServerEntry } from '../src/agent-rules.mjs';
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) { console.log(`• A dev deploy owns ${BRAIN_DIR} (dev:true) — leaving it untouched. Re-run with --force to override.`); process.exit(0); }
52
- if (cur.brainVersion && cmpSemver(cur.brainVersion, VERSION) > 0) { console.log(`• Installed brain v${cur.brainVersion} is newer than this package v${VERSION} — not downgrading. Re-run with --force to override.`); process.exit(0); }
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(' Every project with a ./brain.klypix now auto-reads its brief + captures decisions.');
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) {
@@ -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 the brain via hooks; `link` extends the
4
- // same automatic read+capture to Cursor, Cline, Windsurf, Copilot/VS Code, Gemini CLI,
5
- // Aider, and any AGENTS.md-reading agent, by dropping each tool's native MCP config +
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 MCP servers live in their global config; the rules file points them at the brain regardless.');
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.');
@@ -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 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.',
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 (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`.',
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.32.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, 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
+ }