ruvnet-brain 3.9.76-dev โ 3.9.77-dev
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 +1 -1
- package/bin/install.mjs +31 -2
- package/package.json +3 -1
- package/plugin/mcp/server.mjs +173 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# ๐ง RuvNet Brain
|
|
6
6
|
|
|
7
|
-
### ๐ง RuvNet Brain โ [](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
|
|
8
8
|
|
|
9
9
|
**A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack โ delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
|
|
10
10
|
|
package/bin/install.mjs
CHANGED
|
@@ -614,6 +614,32 @@ export function codexStatus({ configPath = codexConfigPath(), codexDir = codexHo
|
|
|
614
614
|
return { host: true, wired: Boolean(serverPath) && serverExists, serverPath, serverExists };
|
|
615
615
|
}
|
|
616
616
|
|
|
617
|
+
// Atomic file replacement: produce the new bytes BESIDE the target, then rename() over it. Against
|
|
618
|
+
// process interruption (the issue #43 scenario) either the old file or the complete new file
|
|
619
|
+
// exists โ never a torn write. (Power-loss durability โ fsync of file and directory โ is
|
|
620
|
+
// deliberately out of scope for a config write.) On failure the temp is removed and the target is
|
|
621
|
+
// untouched; same-directory rename keeps it on one filesystem.
|
|
622
|
+
//
|
|
623
|
+
// rename() swaps INODES, so two properties of the old write-through path must be carried over
|
|
624
|
+
// explicitly: a symlinked target (a dotfiles-managed ~/.codex/config.toml) is resolved to its real
|
|
625
|
+
// file so the link survives and the bytes land where the user keeps them, and the target's mode is
|
|
626
|
+
// re-applied so a chmod-600 config never comes back world-readable.
|
|
627
|
+
function atomicReplace(targetPath, writeTmp) {
|
|
628
|
+
let target = targetPath;
|
|
629
|
+
try { target = fs.realpathSync(targetPath); } catch { /* target doesn't exist yet */ }
|
|
630
|
+
let mode = null;
|
|
631
|
+
try { mode = fs.statSync(target).mode & 0o777; } catch { /* first write โ default mode is fine */ }
|
|
632
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
633
|
+
try {
|
|
634
|
+
writeTmp(tmp);
|
|
635
|
+
if (mode !== null) fs.chmodSync(tmp, mode);
|
|
636
|
+
fs.renameSync(tmp, target);
|
|
637
|
+
} catch (e) {
|
|
638
|
+
try { fs.rmSync(tmp, { force: true, recursive: true }); } catch { /* best effort */ }
|
|
639
|
+
throw e;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
617
643
|
export function wireCodexHost({
|
|
618
644
|
codexDir = codexHomeDir(),
|
|
619
645
|
configPath = path.join(codexDir, 'config.toml'),
|
|
@@ -635,7 +661,10 @@ export function wireCodexHost({
|
|
|
635
661
|
}
|
|
636
662
|
const serverPath = path.join(serverDir, 'server.mjs');
|
|
637
663
|
fs.mkdirSync(serverDir, { recursive: true });
|
|
638
|
-
|
|
664
|
+
// Write-beside-then-rename, both here and for the config below (issue #43): an interrupted plain
|
|
665
|
+
// copy leaves a TORN server.mjs at the exact path a prior install's config already points at, so
|
|
666
|
+
// Codex spawns half a file. rename() over the target is atomic; a failure leaves the old bytes.
|
|
667
|
+
atomicReplace(serverPath, (tmp) => fs.copyFileSync(source, tmp));
|
|
639
668
|
|
|
640
669
|
let before = '';
|
|
641
670
|
try { before = fs.readFileSync(configPath, 'utf8'); } catch { /* first run โ no config yet */ }
|
|
@@ -649,7 +678,7 @@ export function wireCodexHost({
|
|
|
649
678
|
}
|
|
650
679
|
if (text !== before) {
|
|
651
680
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
652
|
-
fs.writeFileSync(
|
|
681
|
+
atomicReplace(configPath, (tmp) => fs.writeFileSync(tmp, text));
|
|
653
682
|
}
|
|
654
683
|
if (announce) {
|
|
655
684
|
ok(`search_ruvnet registered for Codex โ ${c.bold(configPath)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.77-dev",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain \u2014 a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
39
|
"bin/install.mjs",
|
|
40
|
+
"plugin/mcp/server.mjs",
|
|
41
|
+
"!plugin/README.md",
|
|
40
42
|
"README.md",
|
|
41
43
|
"LICENSE",
|
|
42
44
|
"config/model-router/",
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ruvnet-brain MCP server โ the Stable Spine's protocol shell (ADR-023 ยง4, v2 post-red-team).
|
|
3
|
+
//
|
|
4
|
+
// v1 was a launcher: spawn forge-mcp-all.mjs with stdio:'inherit' and get out of the way. That froze
|
|
5
|
+
// search_ruvnet's behavior at session start. v2 makes THIS process the stable protocol owner:
|
|
6
|
+
//
|
|
7
|
+
// โข The CLIENT connection (Claude Code โ this process) is never proxied, dropped, or replayed.
|
|
8
|
+
// This file answers initialize / ping / tools/list itself and owns every client id.
|
|
9
|
+
// โข The BRAIN runs in a warm CHILD (the KB's own forge-mcp-all.mjs, unchanged) that this process
|
|
10
|
+
// supervises over a PRIVATE handshake: parent sends its OWN initialize with parent-allocated
|
|
11
|
+
// ids and forwards tools/call with id remapping โ the client's handshake is never replayed to
|
|
12
|
+
// anyone (red-team findings 7/8: no external-handshake replay, ids remapped both directions).
|
|
13
|
+
// โข HOT SWAP: between requests (never mid-flight โ finding 9: swap only when pendingCount === 0),
|
|
14
|
+
// the child is respawned when the spine generation changes (active.json) or the brain's own
|
|
15
|
+
// code file changed on disk (KB-track update). The NEXT call answers from the new brain; a call
|
|
16
|
+
// in flight completes on the old one. Claude Code notices nothing.
|
|
17
|
+
// โข LEASE (finding 23): this process leases the generation it serves (leases/mcp-<pid>.json,
|
|
18
|
+
// refreshed per call) so update-apply.mjs --gc never collects a tree still being served.
|
|
19
|
+
// โข Child death: pending calls get a JSON-RPC error (never a parent exit); the child respawns
|
|
20
|
+
// lazily on the next call. If the brain is absent entirely, tools/call returns an honest
|
|
21
|
+
// soft-error tool result with install guidance โ the tool stays registered, never vanishes.
|
|
22
|
+
//
|
|
23
|
+
// Brain location resolution (unchanged from v1):
|
|
24
|
+
// 1) $RUVNET_BRAIN_KB 2) $RUVNET_BRAIN_HOME/kb 3) ~/.cache/ruvnet-brain/kb
|
|
25
|
+
// Model cache: $KB_MODEL_CACHE, else <home>/models.
|
|
26
|
+
//
|
|
27
|
+
// SHELL CONTRACT: this file is boot-frozen (CC spawns it once per session). Changing IT โ or the
|
|
28
|
+
// tool's declared name/schema โ is a shell change, honestly flagged requiresRestart by the release
|
|
29
|
+
// classifier. Everything it delegates to updates hot.
|
|
30
|
+
|
|
31
|
+
import { spawn } from 'node:child_process';
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import os from 'node:os';
|
|
35
|
+
import readline from 'node:readline';
|
|
36
|
+
|
|
37
|
+
const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
38
|
+
const KB = process.env.RUVNET_BRAIN_KB || path.join(BRAIN_HOME, 'kb');
|
|
39
|
+
const CHILD_MCP = path.join(KB, 'forge-mcp-all.mjs');
|
|
40
|
+
const ACTIVE = path.join(BRAIN_HOME, 'active.json');
|
|
41
|
+
const LEASES = path.join(BRAIN_HOME, 'leases');
|
|
42
|
+
const LEASE = path.join(LEASES, `mcp-${process.pid}.json`);
|
|
43
|
+
|
|
44
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
45
|
+
const SERVER_INFO = { name: 'ruvnet-brain', version: '2.0.0' };
|
|
46
|
+
// Static fallback tool declaration โ same name + inputSchema the brain declares (the SCHEMA is the
|
|
47
|
+
// frozen contract; the description is data and is refreshed from the live child when one is up).
|
|
48
|
+
const FALLBACK_TOOLS = [{
|
|
49
|
+
name: 'search_ruvnet',
|
|
50
|
+
description: 'Source-grounded knowledge base for the RuvNet ecosystem. (Brain bundle not installed on this machine โ calls will return install guidance.)',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
query: { type: 'string', description: 'Natural-language question or keywords about any part of RuvNet.' },
|
|
55
|
+
k: { type: 'integer', description: 'Number of documents to return (default 6).', default: 6 },
|
|
56
|
+
},
|
|
57
|
+
required: ['query'],
|
|
58
|
+
},
|
|
59
|
+
}];
|
|
60
|
+
|
|
61
|
+
const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
62
|
+
const clientOk = (id, result) => out({ jsonrpc: '2.0', id, result });
|
|
63
|
+
const clientErr = (id, code, message) => out({ jsonrpc: '2.0', id, error: { code, message } });
|
|
64
|
+
const readJSON = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
|
|
65
|
+
|
|
66
|
+
function currentGeneration() {
|
|
67
|
+
const a = readJSON(ACTIVE);
|
|
68
|
+
let brainMtime = 0;
|
|
69
|
+
try { brainMtime = fs.statSync(CHILD_MCP).mtimeMs; } catch { /* brain absent */ }
|
|
70
|
+
return `${a?.generation ?? 0}:${brainMtime}`;
|
|
71
|
+
}
|
|
72
|
+
function refreshLease() {
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(LEASES, { recursive: true });
|
|
75
|
+
fs.writeFileSync(LEASE, JSON.stringify({ pid: process.pid, version: readJSON(ACTIVE)?.version ?? null, at: new Date().toISOString() }));
|
|
76
|
+
} catch { /* lease is best-effort */ }
|
|
77
|
+
}
|
|
78
|
+
process.on('exit', () => { try { fs.rmSync(LEASE, { force: true }); } catch { /* gone */ } });
|
|
79
|
+
|
|
80
|
+
// โโ the warm child + private protocol โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
81
|
+
let child = null; // { proc, generation, nextId, pending: Map<childId, {resolve, reject}> }
|
|
82
|
+
let pendingCount = 0; // client tools/call requests currently in flight (drain gate for swaps)
|
|
83
|
+
|
|
84
|
+
function killChild(reason) {
|
|
85
|
+
if (!child) return;
|
|
86
|
+
const c = child; child = null;
|
|
87
|
+
for (const [, p] of c.pending) p.reject(new Error(`brain worker ${reason}`));
|
|
88
|
+
c.pending.clear();
|
|
89
|
+
try { c.proc.kill('SIGTERM'); } catch { /* already dead */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function ensureChild() {
|
|
93
|
+
const gen = currentGeneration();
|
|
94
|
+
if (child && child.generation !== gen && pendingCount === 0) killChild('superseded by a newer generation');
|
|
95
|
+
if (child) return child;
|
|
96
|
+
if (!fs.existsSync(CHILD_MCP)) return null;
|
|
97
|
+
|
|
98
|
+
const env = { ...process.env, KB_DIR: KB };
|
|
99
|
+
if (!env.KB_MODEL_CACHE) env.KB_MODEL_CACHE = path.join(BRAIN_HOME, 'models');
|
|
100
|
+
const proc = spawn(process.execPath, [CHILD_MCP], { stdio: ['pipe', 'pipe', 'inherit'], env });
|
|
101
|
+
const c = { proc, generation: gen, nextId: 1, pending: new Map() };
|
|
102
|
+
const rl = readline.createInterface({ input: proc.stdout });
|
|
103
|
+
rl.on('line', (line) => {
|
|
104
|
+
let msg; try { msg = JSON.parse(line); } catch { return; }
|
|
105
|
+
const waiter = c.pending.get(msg.id);
|
|
106
|
+
if (waiter) { c.pending.delete(msg.id); waiter.resolve(msg); }
|
|
107
|
+
});
|
|
108
|
+
proc.on('exit', () => { if (child === c) child = null; for (const [, p] of c.pending) p.reject(new Error('brain worker exited')); c.pending.clear(); });
|
|
109
|
+
proc.on('error', () => { if (child === c) child = null; });
|
|
110
|
+
child = c;
|
|
111
|
+
|
|
112
|
+
// PRIVATE handshake โ parent-owned id, never the client's (finding 8).
|
|
113
|
+
try { await childRequest(c, 'initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'ruvnet-brain-shell', version: SERVER_INFO.version } }, 10_000); }
|
|
114
|
+
catch { killChild('failed initialize'); return null; }
|
|
115
|
+
return c;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function childRequest(c, method, params, timeoutMs = 120_000) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const id = c.nextId++;
|
|
121
|
+
const timer = setTimeout(() => { c.pending.delete(id); reject(new Error(`brain worker timeout on ${method}`)); }, timeoutMs);
|
|
122
|
+
c.pending.set(id, { resolve: (m) => { clearTimeout(timer); resolve(m); }, reject: (e) => { clearTimeout(timer); reject(e); } });
|
|
123
|
+
try { c.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); }
|
|
124
|
+
catch (e) { clearTimeout(timer); c.pending.delete(id); reject(e); }
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// โโ client protocol โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
129
|
+
async function handleClient(msg) {
|
|
130
|
+
const { id, method, params } = msg;
|
|
131
|
+
if (id === undefined || id === null) return; // notifications need no answer
|
|
132
|
+
switch (method) {
|
|
133
|
+
case 'initialize':
|
|
134
|
+
return clientOk(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO });
|
|
135
|
+
case 'ping':
|
|
136
|
+
return clientOk(id, {});
|
|
137
|
+
case 'tools/list': {
|
|
138
|
+
const c = await ensureChild();
|
|
139
|
+
if (c) {
|
|
140
|
+
try { const r = await childRequest(c, 'tools/list', {}, 15_000); if (r.result?.tools?.length) return clientOk(id, r.result); }
|
|
141
|
+
catch { /* fall through to the static declaration */ }
|
|
142
|
+
}
|
|
143
|
+
return clientOk(id, { tools: FALLBACK_TOOLS });
|
|
144
|
+
}
|
|
145
|
+
case 'tools/call': {
|
|
146
|
+
if (params?.name !== 'search_ruvnet') return clientErr(id, -32602, `unknown tool: ${params?.name}`);
|
|
147
|
+
refreshLease();
|
|
148
|
+
const c = await ensureChild();
|
|
149
|
+
if (!c) {
|
|
150
|
+
return clientOk(id, { content: [{ type: 'text', text: `search_ruvnet error: the brain bundle is not installed at ${KB}. Install it with: npx github:stuinfla/ruvnet-brain (or set RUVNET_BRAIN_KB to your brain's kb dir).` }], isError: true });
|
|
151
|
+
}
|
|
152
|
+
pendingCount++;
|
|
153
|
+
try {
|
|
154
|
+
const r = await childRequest(c, 'tools/call', params);
|
|
155
|
+
if (r.error) return clientErr(id, r.error.code ?? -32603, r.error.message ?? 'brain worker error');
|
|
156
|
+
return clientOk(id, r.result);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return clientOk(id, { content: [{ type: 'text', text: `search_ruvnet error: ${e.message}` }], isError: true });
|
|
159
|
+
} finally {
|
|
160
|
+
pendingCount--;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
default:
|
|
164
|
+
return clientErr(id, -32601, `unknown method: ${method}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const clientRl = readline.createInterface({ input: process.stdin });
|
|
169
|
+
clientRl.on('line', (line) => {
|
|
170
|
+
let msg; try { msg = JSON.parse(line); } catch { return; } // malformed line: ignore, never crash
|
|
171
|
+
handleClient(msg).catch((e) => { if (msg.id !== undefined && msg.id !== null) clientErr(msg.id, -32603, e.message); });
|
|
172
|
+
});
|
|
173
|
+
clientRl.on('close', () => { killChild('client disconnected'); process.exit(0); });
|