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.
- package/bin/klypix-install.mjs +150 -0
- package/bin/klypix-mcp.mjs +7 -0
- package/package.json +3 -2
- package/src/brain-git-hook.mjs +123 -0
- package/src/brain-note.mjs +72 -0
- package/src/brain-semantic.mjs +94 -0
- package/src/global-brain-hook.mjs +1279 -0
|
@@ -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
|
+
}
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|