klypix-mcp 1.9.0 → 1.10.1

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.
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // klypix-install — lay the project-brain down into ~/.claude/project-brain from THIS
3
+ // package and wire the 4 Claude Code hooks into ~/.claude/settings.json. This makes
4
+ // npm the SINGLE delivery for the WHOLE brain (hook + engine + local MCP/A2A
5
+ // servers), so one `gh release create` (OIDC auto-publish) + `npx klypix-mcp install`
6
+ // updates every brain on a machine — the global ~/.claude/project-brain copy serves
7
+ // EVERY project (each project just needs a ./brain.klypix). The desktop app installs
8
+ // the SAME flat layout, so the two delivery channels converge on one install.
9
+ //
10
+ // npx klypix-mcp install # install / update the brain on this machine
11
+ // npx klypix-mcp install --force # overwrite even a newer / dev-deployed brain
12
+ //
13
+ // Never-throws-silently: it's an explicit CLI, so it reports what it did and exits
14
+ // non-zero on a real failure. Never wires a broken settings.json (refuse + restore).
15
+ import fs from 'fs';
16
+ import os from 'os';
17
+ import path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const PKG_ROOT = path.resolve(__dirname, '..');
22
+ const SRC = path.join(PKG_ROOT, 'src');
23
+ const BIN = path.join(PKG_ROOT, 'bin');
24
+ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version || ''; } catch { return ''; } })();
25
+ const FORCE = process.argv.includes('--force');
26
+
27
+ const HOME = os.homedir();
28
+ const CLAUDE_DIR = path.join(HOME, '.claude');
29
+ const BRAIN_DIR = path.join(CLAUDE_DIR, 'project-brain');
30
+ const SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
31
+ const HOOK_MARK = 'global-brain-hook';
32
+ const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } };
33
+ const fwd = (p) => p.replace(/\\/g, '/');
34
+ function copyDir(src, dest) {
35
+ fs.mkdirSync(dest, { recursive: true });
36
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
37
+ const s = path.join(src, e.name), d = path.join(dest, e.name);
38
+ if (e.isDirectory()) copyDir(s, d); else if (e.isFile()) fs.copyFileSync(s, d);
39
+ }
40
+ }
41
+
42
+ // ── Never-downgrade gate ─────────────────────────────────────────────────────
43
+ // The brain version is a UNIFIED namespace = the klypix-mcp version, stamped by BOTH
44
+ // the npm install (here) and the desktop bundle, so the two channels compare cleanly.
45
+ // A dev deploy ({dev:true}) is authoritative; a strictly-newer install is not
46
+ // downgraded. --force overrides both.
47
+ 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; };
48
+ const cur = (() => { try { return JSON.parse(fs.readFileSync(path.join(BRAIN_DIR, '.brain-version.json'), 'utf8')); } catch { return null; } })();
49
+ if (!FORCE && cur) {
50
+ 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); }
51
+ 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); }
52
+ }
53
+
54
+ // ── Flatten: the repo's bin servers import '../src/…'; the flat runtime layout needs
55
+ // './…'. Also bake the version (the flat layout has no ../package.json). Mirrors
56
+ // scripts/sync-bundled-mcp.mjs in the KLYPIX repo. ──────────────────────────────
57
+ const flatten = (code) => code
58
+ .replace(/from '\.\.\/src\/klypix-core\.mjs'/g, "from './klypix-core.mjs'")
59
+ .replace(/from '\.\.\/src\/klypix-format\.mjs'/g, "from './klypix-format.mjs'")
60
+ .replace(/\.\.\/src\/klypix-(core|format)\.mjs/g, './klypix-$1.mjs')
61
+ .replace(/const PKG_VERSION = \(\(\) => \{[\s\S]*?\}\)\(\);/, `const PKG_VERSION = '${VERSION}'; // baked at install (flat layout has no package.json)`);
62
+
63
+ try {
64
+ fs.mkdirSync(BRAIN_DIR, { recursive: true });
65
+ // 1) flat engine + hook scripts (their imports are already './…' → verbatim)
66
+ let n = 0;
67
+ for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs']) {
68
+ const s = path.join(SRC, f); if (exists(s)) { fs.writeFileSync(path.join(BRAIN_DIR, f), fs.readFileSync(s, 'utf8')); n++; }
69
+ }
70
+ // 2) the two servers, flattened to the *-server.mjs names the runtime/config expect
71
+ for (const [src, dst] of [['klypix-mcp.mjs', 'klypix-mcp-server.mjs'], ['klypix-a2a.mjs', 'klypix-a2a-server.mjs']]) {
72
+ const s = path.join(BIN, src); if (exists(s)) { fs.writeFileSync(path.join(BRAIN_DIR, dst), flatten(fs.readFileSync(s, 'utf8'))); n++; }
73
+ }
74
+ // 3) runtime dependency CLOSURE (jszip+fractional-indexing for the hook/engine,
75
+ // @modelcontextprotocol/sdk+zod for the local MCP server). Resolve each via
76
+ // createRequire so it's found wherever the package manager put it — CRITICAL
77
+ // for `npx`, which HOISTS deps to its cache root (not PKG_ROOT/node_modules).
78
+ // Walk transitive deps resolved FROM each package's own context (handles
79
+ // nesting). @huggingface/transformers (optional, huge) is intentionally
80
+ // skipped — semantic recall degrades to lexical until the host warms it.
81
+ // Resolve a package DIR by walking node_modules upward (Node-style): checks
82
+ // fromDir/node_modules/<name>, then each parent — so it finds hoisted deps under
83
+ // npx AND nested ones. fs-based on purpose: require.resolve('<name>/package.json')
84
+ // is blocked by restrictive "exports" (e.g. fractional-indexing v3) and would
85
+ // silently drop a dep the hook needs.
86
+ const destMods = path.join(BRAIN_DIR, 'node_modules');
87
+ const findPkgDir = (name, fromDir) => {
88
+ let dir = fromDir;
89
+ for (; ;) {
90
+ const cand = path.join(dir, 'node_modules', ...name.split('/'));
91
+ if (exists(path.join(cand, 'package.json'))) return cand;
92
+ const parent = path.dirname(dir);
93
+ if (parent === dir) return null;
94
+ dir = parent;
95
+ }
96
+ };
97
+ const seen = new Set();
98
+ const queue = ['jszip', 'fractional-indexing', '@modelcontextprotocol/sdk', 'zod'].map(name => ({ name, fromDir: PKG_ROOT }));
99
+ let deps = 0; const missing = [];
100
+ while (queue.length) {
101
+ const { name, fromDir } = queue.shift();
102
+ if (seen.has(name)) continue; seen.add(name);
103
+ const dir = findPkgDir(name, fromDir);
104
+ if (!dir) { missing.push(name); continue; }
105
+ if (!exists(path.join(destMods, name))) { copyDir(dir, path.join(destMods, name)); deps++; }
106
+ try { const pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); for (const d of Object.keys(pj?.dependencies || {})) queue.push({ name: d, fromDir: dir }); } catch { /* no readable package.json */ }
107
+ }
108
+ if (missing.length) { console.error(`✗ could not resolve required dep(s): ${missing.join(', ')} — aborting (the brain hook needs them).`); process.exit(1); }
109
+ // 4) mark the dir an ESM package
110
+ fs.writeFileSync(path.join(BRAIN_DIR, 'package.json'), JSON.stringify({ name: 'klypix-project-brain', private: true, type: 'module' }, null, 2));
111
+
112
+ // 5) wire the 4 hooks into settings.json (refuse on invalid JSON; back up; atomic)
113
+ const brainCmd = (arg) => `node "${fwd(path.join(BRAIN_DIR, 'global-brain-hook.mjs'))}"${arg ? ' ' + arg : ''}`;
114
+ const GROUPS = [
115
+ ['SessionStart', { matcher: 'startup|resume', hooks: [{ type: 'command', command: brainCmd('') }] }],
116
+ ['UserPromptSubmit', { hooks: [{ type: 'command', command: brainCmd('--prompt'), timeout: 10 }] }],
117
+ ['Stop', { hooks: [{ type: 'command', command: brainCmd('--capture') }] }],
118
+ ['PostToolUse', { matcher: 'Bash|PowerShell|Edit|Write', hooks: [{ type: 'command', command: brainCmd('--live'), timeout: 10 }] }],
119
+ ];
120
+ const stripOurs = (arr) => (Array.isArray(arr) ? arr : [])
121
+ .map(g => (g && Array.isArray(g.hooks)) ? { ...g, hooks: g.hooks.filter(h => !(typeof h?.command === 'string' && h.command.includes(HOOK_MARK))) } : g)
122
+ .filter(g => !g || !Array.isArray(g.hooks) || g.hooks.length > 0);
123
+ let settings = {};
124
+ let rawSettings = '';
125
+ if (exists(SETTINGS)) {
126
+ rawSettings = fs.readFileSync(SETTINGS, 'utf8');
127
+ if (rawSettings.trim()) {
128
+ try { settings = JSON.parse(rawSettings); }
129
+ catch (e) { console.error(`✗ ${SETTINGS} is invalid JSON (${e.message}). Fix it and re-run — refusing to overwrite a broken config.`); process.exit(1); }
130
+ }
131
+ }
132
+ if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) settings.hooks = {};
133
+ for (const [evt, entry] of GROUPS) { const cleaned = stripOurs(settings.hooks[evt]); cleaned.push(JSON.parse(JSON.stringify(entry))); settings.hooks[evt] = cleaned; }
134
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
135
+ if (rawSettings) { try { fs.writeFileSync(SETTINGS + '.klypix-bak', rawSettings, 'utf8'); } catch { /* best-effort backup */ } }
136
+ const tmp = SETTINGS + '.klypix-tmp';
137
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2), 'utf8');
138
+ JSON.parse(fs.readFileSync(tmp, 'utf8')); // verify before swap
139
+ fs.renameSync(tmp, SETTINGS);
140
+
141
+ // 6) stamp the install (unified brain version → never-downgrade across channels)
142
+ fs.writeFileSync(path.join(BRAIN_DIR, '.brain-version.json'), JSON.stringify({ brainVersion: VERSION, via: 'npm', dirty: false, installedAt: new Date().toISOString() }, null, 2));
143
+
144
+ console.log(`✓ installed klypix brain v${VERSION} → ${BRAIN_DIR} (${n} scripts, ${deps} dep packages)`);
145
+ console.log('✓ wired 4 hooks: SessionStart · UserPromptSubmit (--prompt) · Stop (--capture) · PostToolUse (--live) → settings.json');
146
+ console.log(' Every project with a ./brain.klypix now auto-reads its brief + captures decisions. Restart open Claude Code sessions to load the hooks.');
147
+ } catch (e) {
148
+ console.error(`✗ install failed: ${e?.message || e}`);
149
+ process.exit(1);
150
+ }
@@ -34,6 +34,13 @@ const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../pac
34
34
  // IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
35
35
  const log = (...a) => console.error('[klypix-mcp]', ...a);
36
36
 
37
+ // `npx klypix-mcp install` — lay the WHOLE brain (hook + engine + local servers)
38
+ // into ~/.claude/project-brain and wire the Claude Code hooks. This is the single
39
+ // agent-neutral installer, so a brain release reaches every machine via one npm
40
+ // publish + this command (the global brain serves every project). Runs before any
41
+ // server setup; delegates to the dedicated bin so `npx klypix-install` also works.
42
+ if (process.argv[2] === 'install') { await import('./klypix-install.mjs'); process.exit(0); }
43
+
37
44
  // `npx klypix-mcp init` — 60-second onboarding: seed a starter project brain in
38
45
  // the current folder so a new user's FIRST contact isn't an empty vault, then
39
46
  // 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.9.0",
3
+ "version": "1.10.1",
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",
@@ -29,7 +29,8 @@
29
29
  "klypix-a2a": "bin/klypix-a2a.mjs",
30
30
  "klypix-read": "bin/klypix-read.mjs",
31
31
  "klypix-write": "bin/klypix-write.mjs",
32
- "klypix-append": "bin/klypix-append.mjs"
32
+ "klypix-append": "bin/klypix-append.mjs",
33
+ "klypix-install": "bin/klypix-install.mjs"
33
34
  },
34
35
  "main": "index.mjs",
35
36
  "exports": {
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ // brain-git-hook — agent-NEUTRAL commit capture for ./brain.klypix.
3
+ //
4
+ // Installed by KLYPIX as a git post-commit / post-merge hook (Settings → connect
5
+ // a project's brain). It captures rationale-bearing feat/fix/perf commit BODIES
6
+ // into the project brain regardless of WHICH agent (Cursor, Cline, Codex, or a
7
+ // human) made the commit — the universal half of the Claude-Code Stop hook,
8
+ // which keys off the COMMIT rather than an agent-specific transcript.
9
+ //
10
+ // Bulletproof by contract (it runs on EVERY commit in a connected repo): instant
11
+ // no-op when there's no ./brain.klypix, NEVER throws, ALWAYS exits 0. It is the
12
+ // SAME capture rule the Claude-Code hook uses (CC_RE + body>=12 rationale gate).
13
+ //
14
+ // Two safety properties for the case where a repo ALSO runs the Claude-Code Stop
15
+ // hook (the dogfood scenario — both would otherwise capture the same commit):
16
+ // • No double-record — captureIntoBrain does NOT dedup, so this hook reads the
17
+ // brain inside the lock and SKIPS any commit whose `#commit-<7>` tag is
18
+ // already present (catches both a re-run and the Claude-Code hook's copy).
19
+ // • No lost write — it takes the SAME advisory `.claude/brain-capture.lock` the
20
+ // Stop hook uses, so a concurrent post-commit + Stop serialize (atomicWrite
21
+ // stops corruption, the lock stops last-writer-wins update loss).
22
+ //
23
+ // Lives in the project-brain bundle beside klypix-format.mjs (which it imports
24
+ // for parse/capture/tidy/atomic-write); a tiny per-repo last-commit file keeps it
25
+ // incremental + flood-proof (baselines to HEAD on first run, re-baselines on a
26
+ // rewritten history).
27
+ import fs from 'fs';
28
+ import path from 'path';
29
+ import { execSync } from 'child_process';
30
+
31
+ const CWD = process.argv[2] || process.cwd();
32
+ const BRAIN = path.resolve(CWD, 'brain.klypix');
33
+ const STATE = path.resolve(CWD, '.claude', 'brain-last-commit-git');
34
+ const LOCK = path.resolve(CWD, '.claude', 'brain-capture.lock'); // SAME lock the Claude-Code Stop hook uses
35
+
36
+ // Advisory lockfile (mirrors global-brain-hook.mjs): O_EXCL create wins; a held
37
+ // lock is waited on (sync sleep, no busy-spin); a STALE lock is stolen so a
38
+ // crashed writer can't wedge the brain. Best-effort — write anyway past budget.
39
+ const LOCK_STALE_MS = 15000;
40
+ const sleepSync = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* */ } };
41
+ function acquireLock(lockPath, { tries = 60, waitMs = 60 } = {}) {
42
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch { /* */ }
43
+ for (let i = 0; i < tries; i++) {
44
+ try { const fd = fs.openSync(lockPath, 'wx'); fs.writeSync(fd, String(process.pid)); fs.closeSync(fd); return true; }
45
+ catch (e) {
46
+ if (e && e.code !== 'EEXIST') return false;
47
+ try { if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) { fs.unlinkSync(lockPath); continue; } } catch { /* lost a race on the stale file — retry */ }
48
+ sleepSync(waitMs);
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+ function releaseLock(lockPath) { try { fs.unlinkSync(lockPath); } catch { /* */ } }
54
+
55
+ const git = (a) => execSync(`git ${a}`, { cwd: CWD, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim();
56
+ const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
57
+ const CC_RE = /^(feat|fix|perf)(?:\(([^)]+)\))?!?:\s*(.+)$/i;
58
+
59
+ function commitToCard(c) {
60
+ const m = CC_RE.exec(c.subject);
61
+ if (!m) return null; // only feat / fix / perf
62
+ const body = c.body.replace(/\s+/g, ' ').trim();
63
+ if (body.length < 12) return null; // rationale-bearing only — not a log dump
64
+ const type = m[1].toLowerCase(), scope = (m[2] || '').trim(), desc = m[3].trim();
65
+ const area = scope || (type === 'feat' ? 'Milestones' : 'Fixes');
66
+ const prefix = type === 'feat' ? '🏁 ' : '';
67
+ return {
68
+ text: `${area}: ${prefix}${desc}\n\n${body.slice(0, 400)}\n#${slug(area)} #commit-${c.hash.slice(0, 7)}`,
69
+ area, borderColor: type === 'feat' ? 'rgba(59,130,246,0.8)' : 'rgba(16,185,129,0.6)', createdVia: 'git',
70
+ };
71
+ }
72
+ function parseLog(raw) {
73
+ return String(raw).split('\x1e').map(s => s.trim()).filter(Boolean).map(rec => {
74
+ const p = rec.split('\x1f');
75
+ return { hash: (p[0] || '').trim(), subject: (p[1] || '').trim(), body: (p[2] || '').trim() };
76
+ }).filter(c => c.hash && c.subject);
77
+ }
78
+ const readPrev = () => { try { return fs.readFileSync(STATE, 'utf8').trim() || null; } catch { return null; } };
79
+ const writePrev = (s) => { try { fs.mkdirSync(path.dirname(STATE), { recursive: true }); fs.writeFileSync(STATE, String(s || '')); } catch { /* best-effort */ } };
80
+
81
+ async function main() {
82
+ if (!fs.existsSync(BRAIN)) return; // not a brain project → instant no-op
83
+ let head = ''; try { head = git('rev-parse HEAD'); } catch { return; }
84
+ if (!head) return;
85
+ const prev = readPrev();
86
+ if (!prev) { writePrev(head); return; } // BASELINE: record HEAD, capture nothing
87
+ if (prev === head) return; // nothing new
88
+ try { execSync(`git merge-base --is-ancestor ${prev} HEAD`, { cwd: CWD, stdio: 'ignore', timeout: 2000 }); }
89
+ catch { writePrev(head); return; } // history rewritten → re-baseline, don't dump
90
+ let raw = ''; try { raw = git(`log ${prev}..HEAD --no-merges --format=%x1e%H%x1f%s%x1f%b`); } catch { writePrev(head); return; }
91
+ const candidates = parseLog(raw).slice(0, 15)
92
+ .map(c => ({ card: commitToCard(c), hash7: c.hash.slice(0, 7).toLowerCase() }))
93
+ .filter(x => x.card);
94
+ if (!candidates.length) { writePrev(head); return; }
95
+
96
+ const lib = await import('./klypix-format.mjs'); // lazy: only when there's something to write
97
+ // Read-modify-write UNDER the shared lock so a concurrent Claude-Code Stop
98
+ // hook can't clobber this batch (or vice-versa). Inside the lock, dedup
99
+ // against commits ALREADY in the brain so neither a re-run nor the Stop hook
100
+ // double-records the same commit.
101
+ const gotLock = acquireLock(LOCK);
102
+ try {
103
+ const buf = fs.readFileSync(BRAIN);
104
+ const already = new Set();
105
+ try {
106
+ const { struct } = await lib.parseKlypix(buf);
107
+ for (const c of (struct.cards || []))
108
+ for (const m of String(c.text || '').matchAll(/#commit-([0-9a-f]{7})/gi)) already.add(m[1].toLowerCase());
109
+ } catch { /* parse failed → don't dedup; atomicWrite re-verifies the result anyway */ }
110
+ const cards = candidates.filter(x => !already.has(x.hash7)).map(x => x.card);
111
+ if (!cards.length) { writePrev(head); return; } // every new commit already recorded (e.g. by the Stop hook)
112
+ const res = await lib.captureIntoBrain(buf, {
113
+ cards: cards.map(c => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia })),
114
+ });
115
+ let out = res.buffer; try { out = (await lib.tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
116
+ await lib.atomicWrite(BRAIN, out);
117
+ writePrev(head);
118
+ try { process.stderr.write(`[klypix-brain] git capture: ${res.stats?.added ?? cards.length} commit card(s) → brain.klypix\n`); } catch { /* */ }
119
+ } finally {
120
+ if (gotLock) releaseLock(LOCK);
121
+ }
122
+ }
123
+ main().catch(() => { /* never break a commit */ }).finally(() => process.exit(0));
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // brain-note — a DELIBERATE, marker-aware write to a .klypix brain (cwd-keyed).
3
+ //
4
+ // The Claude-Code / Codex-via-bash twin of the brain_note MCP tool: the SAME
5
+ // capture engine (supersede / resolve / close-link / update / dedup) as the Stop-
6
+ // hook harvest, but ON DEMAND — so a decision / lock-note / open question lands the
7
+ // instant you run it (and a concurrent session's next prompt can see it) instead of
8
+ // waiting for Stop. cwd-keyed: it writes ./brain.klypix in the current project, the
9
+ // SAME brain the global hook reads — no MCP vault mismatch, no session-id needed.
10
+ //
11
+ // Usage:
12
+ // node brain-note.mjs "Auth: switch to refresh tokens — sessions don't scale"
13
+ // node brain-note.mjs "Lock: refactoring src/auth/** for ~1h, hands off" --marker !
14
+ // node brain-note.mjs "shipped v1.6.0" --marker resolve --closes "v1.6.0 release"
15
+ // echo '{"text":"...","area":"Auth","marker":"?"}' | node brain-note.mjs
16
+ //
17
+ // --marker: (none)=decision · ?/question · !/milestone · ✓/resolve · ~/update
18
+ // --area X route into the [Area] container (also a #tag) · --closes "<title>"
19
+ // Optional trailing path picks a different .klypix (default ./brain.klypix).
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import { captureIntoBrain, tidyBrain, atomicWrite, noteToCaptureInput } from './klypix-format.mjs';
23
+
24
+ const MARKERS = { '': '', '?': '?', '!': '!', '✓': '✓', '~': '~', question: '?', milestone: '!', resolve: '✓', update: '~', decision: '', done: '✓' };
25
+ const normMarker = (m) => MARKERS[String(m || '').toLowerCase()] ?? '';
26
+
27
+ function parseArgs(argv) {
28
+ const out = { text: '', area: '', marker: '', closes: '', file: '' };
29
+ const rest = [];
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === '--area') out.area = argv[++i] || '';
33
+ else if (a === '--marker' || a === '-m') out.marker = normMarker(argv[++i]);
34
+ else if (a === '--closes') out.closes = argv[++i] || '';
35
+ else rest.push(a);
36
+ }
37
+ // A trailing .klypix/.any token is the target file; the rest is the note text.
38
+ if (rest.length && /\.(klypix|any)$/i.test(rest[rest.length - 1])) out.file = rest.pop();
39
+ out.text = rest.join(' ').trim();
40
+ return out;
41
+ }
42
+ // Read JSON or raw text from a PIPED stdin only (a TTY would block).
43
+ function readStdin() {
44
+ try { if (process.stdin.isTTY) return ''; return fs.readFileSync(0, 'utf8'); } catch { return ''; }
45
+ }
46
+
47
+ let opts = parseArgs(process.argv.slice(2));
48
+ if (!opts.text) {
49
+ const raw = readStdin().trim();
50
+ if (raw) {
51
+ try { const j = JSON.parse(raw); opts = { ...opts, ...j, marker: normMarker(j.marker ?? opts.marker) }; }
52
+ catch { opts.text = raw; }
53
+ }
54
+ }
55
+ const file = path.resolve(opts.file || 'brain.klypix');
56
+ if (!opts.text) { console.error('brain-note: nothing to write — pass a note as an arg, or JSON/text on stdin.'); process.exit(1); }
57
+ if (!fs.existsSync(file)) { console.error(`brain-note: no brain at ${file} (run from a project with ./brain.klypix, or pass a path).`); process.exit(1); }
58
+
59
+ const input = noteToCaptureInput({ text: opts.text, area: opts.area, marker: opts.marker, closes: opts.closes, createdVia: 'cli' });
60
+ try {
61
+ const res = await captureIntoBrain(fs.readFileSync(file), input);
62
+ let out = res.buffer;
63
+ try { out = (await tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
64
+ await atomicWrite(file, out);
65
+ const s = res.stats || {};
66
+ const bits = [`${s.added || 0} added`];
67
+ for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
68
+ console.error(`✓ brain-note → ${path.basename(file)} (${bits.join(' · ')})`);
69
+ } catch (e) {
70
+ console.error(`brain-note failed (brain unchanged): ${e?.message || e}`);
71
+ process.exit(1);
72
+ }
@@ -0,0 +1,94 @@
1
+ // brain-semantic — OPTIONAL on-device semantic lane for the project-brain hook.
2
+ //
3
+ // WHY a separate module: the hook's per-prompt recall is LEXICAL (keyword) — fast
4
+ // and bulletproof. Semantic (embedding) recall would understand paraphrase, but the
5
+ // hook is a ONE-SHOT process (no daemon allowed), so loading the 23MB MiniLM model
6
+ // costs ~550ms EVERY prompt — unacceptable on the common path. The measured-and-
7
+ // verified design is therefore: run semantic ONLY when lexical found nothing (the
8
+ // paraphrase / keyword-miss case, where you'd otherwise get zero recall), pay the
9
+ // cost just there, timeout-bounded, and read-only.
10
+ //
11
+ // CONTRACT (mirrors the hook): never throw → returns null on ANY failure; never
12
+ // blocks beyond `timeoutMs`; NEVER embeds cards in-process (embedding all cards is
13
+ // a 10s–195s stall — we read whatever the MCP host already warmed and embed ONLY
14
+ // the short query). Transformers is a DYNAMIC, OPTIONAL import — this module's only
15
+ // static deps are node builtins, so the hook gains zero new static deps and stays a
16
+ // no-op until the user runs the one-click semantic install.
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import os from 'os';
20
+ import crypto from 'crypto';
21
+
22
+ const PB_DIR = path.join(os.homedir(), '.claude', 'project-brain');
23
+ const EMB_DIR = path.join(PB_DIR, 'embeddings');
24
+ const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
25
+
26
+ // Memoized within THIS process only (a one-shot hook run) — across prompts it
27
+ // reloads cold, which is exactly why we only pay it on a lexical miss.
28
+ let _embedder;
29
+ function getEmbedder() {
30
+ if (_embedder !== undefined) return _embedder;
31
+ _embedder = (async () => {
32
+ let t;
33
+ try { t = await import('@huggingface/transformers'); }
34
+ catch {
35
+ const base = path.join(PB_DIR, 'semantic', 'node_modules', '@huggingface', 'transformers', 'dist');
36
+ let last;
37
+ for (const f of ['transformers.node.mjs', 'transformers.mjs']) {
38
+ try { t = await import(new URL('file:///' + path.join(base, f).replace(/\\/g, '/')).href); last = null; break; }
39
+ catch (e) { last = e; }
40
+ }
41
+ if (!t) throw last;
42
+ }
43
+ t.env.cacheDir = path.join(PB_DIR, 'hf-cache');
44
+ return await t.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
45
+ })().catch(() => null); // not installed / load fail → null → lexical fallback
46
+ return _embedder;
47
+ }
48
+
49
+ async function embedTexts(pipe, texts) {
50
+ const out = await pipe(texts, { pooling: 'mean', normalize: true });
51
+ const [n, d] = out.dims;
52
+ const vecs = [];
53
+ for (let i = 0; i < n; i++) vecs.push(Array.from(out.data.slice(i * d, (i + 1) * d)));
54
+ return vecs;
55
+ }
56
+ // Vectors are unit-normalized, so dot == cosine.
57
+ export const dot = (a, b) => { let s = 0; const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) s += a[i] * b[i]; return s; };
58
+
59
+ // READ-ONLY card-vector cache — NEVER embeds or writes (embedding all cards in the
60
+ // per-prompt process is the multi-second stall we forbid). Reuses the SAME warm
61
+ // cache the MCP host fills, keyed sha1(path, slashes-only). The MCP keys WITHOUT
62
+ // lowercasing the drive letter, so a brain can have an `e:`-keyed and an `E:`-keyed
63
+ // cache file — try the drive-case variants so we hit whichever exists.
64
+ function readCachedVecs(brainPath, cards) {
65
+ const slashed = String(brainPath).replace(/\\/g, '/');
66
+ const variants = [...new Set([
67
+ slashed,
68
+ slashed.replace(/^([a-zA-Z]):/, (_m, d) => d.toLowerCase() + ':'),
69
+ slashed.replace(/^([a-zA-Z]):/, (_m, d) => d.toUpperCase() + ':'),
70
+ ])];
71
+ let cache = null;
72
+ for (const v of variants) {
73
+ try { const c = JSON.parse(fs.readFileSync(path.join(EMB_DIR, sha1(v) + '.json'), 'utf8')); if (c && c.cards) { cache = c; break; } } catch { /* try next variant */ }
74
+ }
75
+ const map = new Map();
76
+ if (cache && cache.cards) for (const c of cards) { const e = cache.cards[c.id]; if (e && e.v) map.set(c.id, e.v); }
77
+ return map;
78
+ }
79
+
80
+ // Entry point: embed the QUERY (needs the model) + read cached card vectors.
81
+ // Returns { qv, vecsMap, dot } or null (not installed / timeout / any failure →
82
+ // the hook keeps its exact lexical behavior). NEVER throws, NEVER embeds cards.
83
+ export async function semanticVecs(brainPath, struct, query, { timeoutMs = 1200 } = {}) {
84
+ try {
85
+ const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), timeoutMs))]);
86
+ if (!pipe) return null;
87
+ const q = String(query || '').toLowerCase().trim();
88
+ if (!q) return null;
89
+ const [qv] = await embedTexts(pipe, [q]);
90
+ if (!qv) return null;
91
+ const vecsMap = readCachedVecs(brainPath, (struct && struct.cards) || []);
92
+ return { qv, vecsMap, dot };
93
+ } catch { return null; }
94
+ }