@gcunharodrigues/wrxn 0.3.0 → 0.4.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/bin/wrxn.cjs CHANGED
@@ -11,6 +11,7 @@ const worktree = require('../lib/worktree.cjs');
11
11
  const executor = require('../lib/executor.cjs');
12
12
  const onboard = require('../lib/onboard.cjs');
13
13
  const connect = require('../lib/connect.cjs');
14
+ const brain = require('../lib/brain.cjs');
14
15
  const statusline = require('../lib/statusline.cjs');
15
16
  const { convert } = require('../lib/convert.cjs');
16
17
  const { ingest } = require('../lib/ingest.cjs');
@@ -58,6 +59,10 @@ function parseArgs(argv) {
58
59
  args.flags.probe = argv[++i];
59
60
  } else if (a === '--distillation') {
60
61
  args.flags.distillation = argv[++i];
62
+ } else if (a === '--limit') {
63
+ args.flags.limit = argv[++i];
64
+ } else if (a === '--type') {
65
+ args.flags.type = argv[++i];
61
66
  } else if (a === '--check-report') {
62
67
  args.flags['check-report'] = true;
63
68
  } else if (a.startsWith('--')) {
@@ -109,6 +114,17 @@ Usage:
109
114
  list print all registered connections (agent-readable JSON)
110
115
  get <name> print one connection by name
111
116
 
117
+ wrxn brain query "<q>" [--json] [--limit <n>] [--type <prose|code|NodeType>] [--neighbors] [--root <dir>]
118
+ ask the warm Brain (recon-wrxn's code+prose graph) from the terminal.
119
+ WHOLE-BRAIN by default. Discovers the live serve door via
120
+ .recon-wrxn/serve-endpoint.json and POSTs the query; prints ranked
121
+ hits (name · type · file:line). If the Brain is not warm (no live
122
+ serve), prints a clear error and exits non-zero — no cold load.
123
+ --json emits the structured hits · --limit asks the door for top n ·
124
+ --type post-filters (prose=Page/Section, code=the rest, or an exact
125
+ NodeType) · --neighbors expands each hit to its 1-hop graph neighbors
126
+ (callers/callees/relationships via recon_explain).
127
+
112
128
  wrxn statusline [--inject [--path <script>]]
113
129
  SYNAPSE live-window writer. With no flag: report whether a statusline
114
130
  is configured (~/.claude/settings.json) + print the marker-bounded
@@ -408,6 +424,32 @@ async function main(argv) {
408
424
  }
409
425
  }
410
426
 
427
+ if (cmd === 'brain') {
428
+ const sub = args._[1];
429
+ if (sub !== 'query') {
430
+ process.stderr.write(`wrxn: unknown brain subcommand "${sub || ''}" (expected: query)\n\n${USAGE}\n`);
431
+ return 2;
432
+ }
433
+ const q = args._[2];
434
+ if (!q) { process.stderr.write('wrxn: brain query requires "<query>"\n'); return 2; }
435
+ const opts = { json: !!args.flags.json, neighbors: !!args.flags.neighbors };
436
+ if (args.flags.limit != null) {
437
+ const n = parseInt(args.flags.limit, 10);
438
+ if (!Number.isInteger(n) || n <= 0) { process.stderr.write('wrxn: --limit requires a positive integer\n'); return 2; }
439
+ opts.limit = n;
440
+ }
441
+ if (args.flags.type) opts.type = String(args.flags.type);
442
+ const root = path.resolve(args.flags.root || process.cwd());
443
+ try {
444
+ const res = await brain.query(q, opts, { root });
445
+ process.stdout.write(brain.formatHits(res.hits, opts) + '\n');
446
+ return 0;
447
+ } catch (err) {
448
+ process.stderr.write(`wrxn: ${err.message}\n`);
449
+ return 2;
450
+ }
451
+ }
452
+
411
453
  if (cmd === 'statusline') {
412
454
  const home = process.env.HOME || os.homedir();
413
455
  const detection = statusline.detectStatusLine(home);
package/lib/brain.cjs ADDED
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ // WRXN brain query (recon-brain-recall-03) — interrogate the warm Brain from the terminal.
4
+ //
5
+ // The Brain is recon-wrxn's unified code+prose knowledge graph, loaded WARM inside the `recon serve`
6
+ // process Claude Code boots for a session. This command reaches it over the loopback find door that
7
+ // serve announces via a discovery file — it is WHOLE-BRAIN (code AND prose, no scope filter by
8
+ // default), the operator's on-demand counterpart to the prose-only proactive Recall hook.
9
+ //
10
+ // Endpoint-first (v1): if no warm door is discoverable, we raise a clear, actionable error and the CLI
11
+ // exits non-zero — there is NO cold one-shot load (that would pay the index + embedder cost the warm
12
+ // serve already absorbs).
13
+ //
14
+ // The query path takes an INJECTED transport + endpoint reader (deps) so its behavior is unit-testable
15
+ // with no live serve — mirrors the injected-invoker seam in lib/connect.cjs and the recall hook's
16
+ // httpTransport. lib/brain.cjs is PACKAGE code (invoked via bin/wrxn.cjs), NOT payload — no manifest
17
+ // entry, consistent with lib/connect.cjs / lib/executor.cjs / lib/onboard.cjs.
18
+ //
19
+ // The discovery contract (serve-endpoint.json {pid,port}, pid-liveness) is duplicated here from the
20
+ // payload recall-surface hook ON PURPOSE: that hook must be node-stdlib-only and self-contained (it
21
+ // ships into installs without the kernel lib), so package code cannot import it. The contract is ~20
22
+ // stable lines — duplicating it across the install boundary is the same self-containment trade the
23
+ // payload hooks make for findInstallRoot.
24
+
25
+ const fs = require('fs');
26
+ const http = require('http');
27
+ const path = require('path');
28
+
29
+ const ENDPOINT_REL = path.join('.recon-wrxn', 'serve-endpoint.json');
30
+ const FIND_PATH = '/api/tools/recon_find';
31
+ const EXPLAIN_PATH = '/api/tools/recon_explain';
32
+ const TIMEOUT_MS = 5000; // generous: an interactive CLI, not the per-prompt 150ms recall budget
33
+ const MAX_RESPONSE_BYTES = 256 * 1024; // hard cap on an accumulated door response body (anti-flood)
34
+ const PROSE_TYPES = new Set(['Page', 'Section']);
35
+ const WALK_UP_LIMIT = 12;
36
+
37
+ // ── discovery (the cross-repo warm-door contract) ────────────────────────────────────
38
+
39
+ // A pid is alive unless process.kill(pid,0) throws. ESRCH = gone; EPERM = owned by another user but
40
+ // alive. Mirrors the cross-repo discovery contract (and the recall hook).
41
+ function pidAlive(pid) {
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ } catch (e) {
46
+ return !!e && e.code === 'EPERM';
47
+ }
48
+ }
49
+
50
+ // Refuse a discovery file another user could have planted, or that is group/world-writable — trusting
51
+ // it would let a hostile workspace point the door host/port at an exfil/injection sink. lstat (not
52
+ // stat) so a symlink's OWN ownership/mode is judged, not its target's. A platform without getuid (no
53
+ // POSIX ownership) skips the uid check but still enforces the mode check. Any fault → not trusted.
54
+ function endpointTrusted(file) {
55
+ let st;
56
+ try {
57
+ st = fs.lstatSync(file);
58
+ } catch {
59
+ return false;
60
+ }
61
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return false; // foreign owner
62
+ if ((st.mode & 0o022) !== 0) return false; // group/world-writable
63
+ return true;
64
+ }
65
+
66
+ // Walk up from startDir to the first directory carrying .recon-wrxn/serve-endpoint.json; read and
67
+ // validate {pid,port}; trust it only when it is well-owned (not planted), well-formed, and the pid is
68
+ // alive. Returns {pid,port,root} or null (the Brain is not warm: absent, untrusted, malformed, missing
69
+ // fields, or a dead process).
70
+ function discoverEndpoint(startDir) {
71
+ let dir = startDir || process.cwd();
72
+ for (let i = 0; i < WALK_UP_LIMIT; i++) {
73
+ const file = path.join(dir, ENDPOINT_REL);
74
+ if (fs.existsSync(file)) {
75
+ if (!endpointTrusted(file)) return null; // foreign-owned or loose perms → not warm
76
+ let obj;
77
+ try {
78
+ obj = JSON.parse(fs.readFileSync(file, 'utf8'));
79
+ } catch {
80
+ return null; // malformed → not warm
81
+ }
82
+ const pid = Number(obj && obj.pid);
83
+ const port = Number(obj && obj.port);
84
+ if (!Number.isInteger(pid) || pid <= 0) return null;
85
+ if (!Number.isInteger(port) || port <= 0) return null;
86
+ if (!pidAlive(pid)) return null; // dead process → not warm
87
+ return { pid, port, root: dir };
88
+ }
89
+ const up = path.dirname(dir);
90
+ if (up === dir) break;
91
+ dir = up;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ // ── transport (injectable; default = real loopback POST) ─────────────────────────────
97
+
98
+ // Default transport: a real loopback POST with a hard timeout. Injectable so unit tests never touch
99
+ // the network (mirrors lib/connect.cjs's invoke seam). Resolves {statusCode, body}; rejects on socket
100
+ // error or timeout.
101
+ function httpTransport({ port, path: reqPath, body, timeoutMs }) {
102
+ return new Promise((resolve, reject) => {
103
+ const payload = Buffer.from(JSON.stringify(body));
104
+ const deadline = timeoutMs || TIMEOUT_MS;
105
+ let settled = false;
106
+ let wall = null;
107
+ const done = (fn, arg) => {
108
+ if (settled) return;
109
+ settled = true;
110
+ if (wall) clearTimeout(wall);
111
+ fn(arg);
112
+ };
113
+ const req = http.request(
114
+ {
115
+ host: '127.0.0.1',
116
+ port,
117
+ path: reqPath,
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length },
120
+ },
121
+ (res) => {
122
+ const chunks = [];
123
+ let total = 0;
124
+ res.on('data', (c) => {
125
+ total += c.length;
126
+ if (total > MAX_RESPONSE_BYTES) { req.destroy(new Error('brain door response too large')); return; }
127
+ chunks.push(c);
128
+ });
129
+ res.on('end', () => done(resolve, { statusCode: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
130
+ res.on('error', (e) => done(reject, e));
131
+ }
132
+ );
133
+ req.on('error', (e) => done(reject, e));
134
+ // Idle timeout (no bytes for `deadline`) AND an independent wall-clock — the latter bounds a trickle
135
+ // attacker that dribbles bytes to keep the idle timer from ever firing.
136
+ req.setTimeout(deadline, () => req.destroy(new Error('brain door timeout')));
137
+ wall = setTimeout(() => req.destroy(new Error('brain door wall-clock timeout')), deadline);
138
+ req.write(payload);
139
+ req.end();
140
+ });
141
+ }
142
+
143
+ // POST a door tool and return the parsed JSON body. Raises a clean error (never a crash) on a transport
144
+ // fault, a non-200 status, or a non-JSON body.
145
+ async function postTool(transport, port, reqPath, body, timeoutMs) {
146
+ let resp;
147
+ try {
148
+ resp = await transport({ port, path: reqPath, body, timeoutMs: timeoutMs || TIMEOUT_MS });
149
+ } catch (err) {
150
+ throw new Error(`Brain door request to ${reqPath} failed: ${err.message}`);
151
+ }
152
+ if (!resp || resp.statusCode !== 200) {
153
+ throw new Error(`Brain door returned HTTP ${resp ? resp.statusCode : 'no-response'} for ${reqPath}`);
154
+ }
155
+ try {
156
+ return JSON.parse(resp.body);
157
+ } catch {
158
+ throw new Error(`Brain door returned a malformed (non-JSON) response for ${reqPath}`);
159
+ }
160
+ }
161
+
162
+ // ── pure helpers ─────────────────────────────────────────────────────────────────────
163
+
164
+ function isProse(hit) {
165
+ return !!hit && PROSE_TYPES.has(hit.type);
166
+ }
167
+
168
+ // Post-filter hits by --type (the find request can't carry a type ARRAY, so prose=Page+Section is
169
+ // always a post-filter): 'prose' → Page/Section, 'code' → everything else, else an exact NodeType.
170
+ function filterByType(hits, type) {
171
+ if (!type) return hits;
172
+ if (type === 'prose') return hits.filter(isProse);
173
+ if (type === 'code') return hits.filter((h) => !isProse(h));
174
+ const t = String(type).toLowerCase();
175
+ return hits.filter((h) => String(h && h.type).toLowerCase() === t);
176
+ }
177
+
178
+ // Extract a hit's 1-hop neighbors from the door's structured recon_explain response:
179
+ // { result, neighbors: NeighborHit[] }, NeighborHit = { name, type, file, line, relationship }
180
+ // relationship ∈ caller | callee | import | importedBy | method | implementedBy | usedBy | testRef
181
+ // Strictly 1-hop. Consumes that real shape directly — no relationship-bucket guesswork. A missing or
182
+ // non-array `neighbors` (e.g. a degraded/empty explain) yields [] so the hit simply has no neighbors.
183
+ function extractNeighbors(resp) {
184
+ if (!resp || typeof resp !== 'object' || !Array.isArray(resp.neighbors)) return [];
185
+ return resp.neighbors.map((n) => {
186
+ const r = n || {};
187
+ const out = { name: r.name, type: r.type, file: r.file };
188
+ if (r.line != null) out.line = r.line;
189
+ if (r.relationship) out.relationship = r.relationship;
190
+ return out;
191
+ });
192
+ }
193
+
194
+ // ── formatting (pure) ────────────────────────────────────────────────────────────────
195
+
196
+ function hitLine(h) {
197
+ const name = h.name || '(unnamed)';
198
+ const type = h.type || '?';
199
+ const loc = h.file ? `${h.file}${h.line != null ? ':' + h.line : ''}` : '';
200
+ return loc ? `${name} · ${type} · ${loc}` : `${name} · ${type}`;
201
+ }
202
+
203
+ function neighborLine(n) {
204
+ const rel = n.relationship ? ` [${n.relationship}]` : '';
205
+ return ` - ${hitLine(n)}${rel}`;
206
+ }
207
+
208
+ // Render results: --json re-emits the structured hits; default is a human text list. With --neighbors,
209
+ // each hit's 1-hop neighbors are listed indented beneath it.
210
+ function formatHits(hits, opts = {}) {
211
+ const list = Array.isArray(hits) ? hits : [];
212
+ if (opts.json) return JSON.stringify(list, null, 2);
213
+ if (!list.length) return 'no results';
214
+ const lines = [];
215
+ for (const h of list) {
216
+ lines.push(hitLine(h));
217
+ if (opts.neighbors) {
218
+ const ns = Array.isArray(h.neighbors) ? h.neighbors : [];
219
+ if (ns.length) for (const n of ns) lines.push(neighborLine(n));
220
+ else lines.push(' (no 1-hop neighbors)');
221
+ }
222
+ }
223
+ return lines.join('\n');
224
+ }
225
+
226
+ // ── the query (IO shell over the injected seam) ──────────────────────────────────────
227
+
228
+ const NOT_WARM =
229
+ 'Brain is not warm — no live recon serve door found (.recon-wrxn/serve-endpoint.json is absent, ' +
230
+ 'malformed, or its process is dead). Open a Claude Code session (which boots recon serve), or run ' +
231
+ '`recon serve` with the find door enabled, then retry.';
232
+
233
+ /**
234
+ * Query the warm Brain. Whole-brain (code+prose) by default.
235
+ * @param {string} q the query string
236
+ * @param {object} opts { json?, limit?, type?, neighbors? }
237
+ * @param {object} deps { root?, discover?, transport?, timeoutMs? } — injected seam for tests
238
+ * @returns {Promise<{hits: object[]}>}
239
+ * @throws a clear error when the Brain is not warm, or on a malformed/non-200 door response.
240
+ */
241
+ async function query(q, opts = {}, deps = {}) {
242
+ const term = String(q == null ? '' : q).trim();
243
+ if (!term) throw new Error('wrxn brain query requires a non-empty query string');
244
+
245
+ const startDir = deps.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
246
+ const discover = deps.discover || discoverEndpoint;
247
+ const transport = deps.transport || httpTransport;
248
+ const timeoutMs = deps.timeoutMs || TIMEOUT_MS;
249
+
250
+ const door = discover(startDir);
251
+ if (!door) throw new Error(NOT_WARM);
252
+
253
+ const findBody = { query: term };
254
+ if (Number.isInteger(opts.limit) && opts.limit > 0) findBody.limit = opts.limit;
255
+
256
+ const found = await postTool(transport, door.port, FIND_PATH, findBody, timeoutMs);
257
+ if (!Array.isArray(found.hits)) {
258
+ throw new Error(
259
+ 'Brain door returned an unexpected response shape (no structured `hits` array) — the recon-wrxn ' +
260
+ 'serve door may predate the structured find response.'
261
+ );
262
+ }
263
+
264
+ let hits = filterByType(found.hits, opts.type);
265
+
266
+ // --neighbors: 1-hop expansion per hit via recon_explain — the ONLY place 1-hop lives. A per-hit
267
+ // explain failure degrades to empty neighbors (the find already succeeded); it never crashes.
268
+ if (opts.neighbors) {
269
+ for (const h of hits) {
270
+ const explainBody = { name: h.name };
271
+ if (h.file) explainBody.file = h.file;
272
+ try {
273
+ h.neighbors = extractNeighbors(await postTool(transport, door.port, EXPLAIN_PATH, explainBody, timeoutMs));
274
+ } catch {
275
+ h.neighbors = [];
276
+ }
277
+ }
278
+ }
279
+
280
+ return { hits };
281
+ }
282
+
283
+ module.exports = {
284
+ query,
285
+ formatHits,
286
+ discoverEndpoint,
287
+ pidAlive,
288
+ httpTransport,
289
+ filterByType,
290
+ extractNeighbors,
291
+ isProse,
292
+ FIND_PATH,
293
+ EXPLAIN_PATH,
294
+ PROSE_TYPES,
295
+ };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * 003 — open the warm-brain HTTP find door on existing installs (recon-brain-recall-05).
8
+ *
9
+ * The door is the concurrent, read-only, loopback HTTP transport `recon serve` runs alongside its
10
+ * stdio MCP transport when `.recon-wrxn.json` carries `serveHttp:true` (recon-wrxn ADR 0003) — the one
11
+ * warm index the per-prompt Recall hook and `wrxn brain query` reach. A fresh install gets the door
12
+ * from the updated seed, but `.recon-wrxn.json` is SEEDED (operator-owned), so `wrxn update` never
13
+ * overwrites it — an install created before this release would keep the door shut forever unless a
14
+ * migration flips it.
15
+ *
16
+ * up() sets `serveHttp:true` in place, PRESERVING every existing operator field (projects, ignore,
17
+ * watch, …) — it touches only the door bit, it does not retrofit the rest of the new template.
18
+ * Defensive like 002/001: a missing config is a no-op (a non-recon install has nothing to migrate), an
19
+ * already-open door is an idempotent no-op, and a corrupt/unparseable (or non-object) config is left
20
+ * untouched — never clobber a hand-edited file. `version` is a frozen 0.4.0: it runs via `wrxn update`
21
+ * once the install reaches the release that carries the door (the same release that bumps the recon-wrxn
22
+ * pin to the wrxn.3 build whose serve actually honors serveHttp).
23
+ */
24
+ module.exports = {
25
+ id: '003',
26
+ version: '0.4.0',
27
+ up(ctx) {
28
+ const cfgPath = path.join(ctx.target, '.recon-wrxn.json');
29
+ if (!fs.existsSync(cfgPath)) return; // no recon config → nothing to migrate
30
+
31
+ let cfg;
32
+ try {
33
+ cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
34
+ } catch {
35
+ return; // malformed operator config → leave it untouched, never clobber
36
+ }
37
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return; // not a config object → leave it
38
+
39
+ if (cfg.serveHttp === true) return; // door already open → idempotent no-op
40
+
41
+ cfg.serveHttp = true; // open the door; every other operator field is preserved
42
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
43
+ },
44
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "WRXN Kernel — installable AI operating system. Two profiles (project | workspace), pull-based updates, managed/seeded/state file classes.",
5
5
  "bin": {
6
6
  "wrxn": "bin/wrxn.cjs"
@@ -16,7 +16,7 @@
16
16
  "test": "node --test"
17
17
  },
18
18
  "dependencies": {
19
- "recon-wrxn": "6.0.0-wrxn.2"
19
+ "recon-wrxn": "6.0.0-wrxn.3"
20
20
  },
21
21
  "engines": {
22
22
  "node": ">=20"
@@ -1,43 +1,48 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- // WRXN recall-surface hook — per-prompt recall nudge (wrxn-kernel-11).
5
- // UserPromptSubmit. The symmetric RECALL half of reference-detect's CAPTURE: on each prompt it
6
- // matches the prompt's SALIENT terms against the wiki knowledge tiers (concepts/decisions/gotchas)
7
- // and, ONLY when a page matches, injects a <recall-surface> nudge "you already have a page on X,
8
- // recall it before re-deriving / re-asking the operator". Gated → silent on non-matching prompts.
4
+ // WRXN recall-surface hook — proactive PROSE Recall via the warm Brain door (recon-brain-recall-04).
5
+ // UserPromptSubmit. Replaces the old wiki-substring engine: on each prompt it discovers recon-wrxn's
6
+ // warm serve door, POSTs a prose-scoped hybrid query, and ONLY when a hit clears the relevance gate
7
+ // injects a compact <recall-surface> block. Implements ADR 0002.
9
8
  //
10
- // Self-contained: ships into installs, MUST NOT import the kernel lib (node stdlib only). The kernel
11
- // wiki engine is substring (not BM25) recall here is a deliberately simple distinct-salient-term
12
- // count, ranked, top-N. Fail-open: any fault emits {} the hook NEVER blocks a prompt.
9
+ // The gate (per arm, NEVER the fused RRF score): a prose hit qualifies on the semantic cosine FLOOR
10
+ // (>= 0.4) OR on CONSENSUS (it surfaced in both the BM25 and the dense arm). Nothing clears Abstain.
11
+ // Prose only hits are post-filtered to Page/Section, so code symbols never surface here (they stay
12
+ // on the agent's on-demand recon_* / `wrxn brain query` path).
13
+ //
14
+ // Self-contained: ships into installs, MUST NOT import the kernel lib or recon — node stdlib ONLY
15
+ // (http / fs / path). Fail-open SILENT: a cold/missing/dead door, a slow door, a non-200, malformed
16
+ // JSON, or ANY fault emits {} — the hook NEVER blocks a prompt and never delays it past the hard
17
+ // client timeout. There is NO substring fallback (a weak fallback can itself harm — ADR 0002).
13
18
  //
14
19
  // Contract: UserPromptSubmit event JSON on stdin → envelope JSON on stdout (exit 0).
20
+ // inject → { "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "<recall-surface>…" } }
21
+ // abstain → {}
15
22
 
16
23
  const fs = require('fs');
24
+ const http = require('http');
17
25
  const path = require('path');
18
26
 
19
- const TIERS = ['concepts', 'decisions', 'gotchas']; // knowledge tiers; sessions (episodic) excluded
20
- const TOP_N = 2;
21
- const MIN_PROMPT_LEN = 8; // skip trivial prompts ("ok", "yes")
22
- // A page must share >=2 DISTINCT salient terms with the prompt to surface — the substring engine has
23
- // no BM25 score, so a single shared common word is too weak a signal (anti-noise). Tradeoff: a genuine
24
- // single-strong-term recall is silenced (fail-silent a missed nudge is safer than a false one).
25
- const MIN_DISTINCT = 2;
26
- const MAX_BLOCK_CHARS = 600;
27
-
28
- // Drop stopwords + short tokens so common words don't match common page words (anti-noise).
29
- const STOPWORDS = new Set(['about', 'after', 'again', 'against', 'because', 'before', 'being', 'between',
30
- 'could', 'does', 'doing', 'down', 'during', 'each', 'from', 'further', 'have', 'having', 'here', 'how',
31
- 'into', 'just', 'like', 'more', 'most', 'only', 'other', 'over', 'same', 'should', 'some', 'such',
32
- 'than', 'that', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those', 'through', 'under',
33
- 'until', 'very', 'want', 'what', 'when', 'where', 'which', 'while', 'with', 'would', 'your', 'today',
34
- 'tell', 'show', 'give', 'make', 'need', 'know', 'help', 'please', 'thing', 'stuff', 'really', 'going']);
27
+ const MIN_PROMPT_LEN = 8; // skip trivial prompts ("ok", "yes")
28
+ const MAX_QUERY_CHARS = 512; // trim the prompt before querying the door
29
+ const FETCH_LIMIT = 15; // ask the door WIDE — fetch is decoupled from inject so prose ranked
30
+ // below the whole-brain code hits is not truncated before we filter
31
+ const TIMEOUT_MS = 150; // hard client budget never delay a prompt past this
32
+ const MAX_RESPONSE_BYTES = 256 * 1024; // hard cap on an accumulated door response body (anti-flood)
33
+ const TOP_N = 3; // inject at most 3 hits (the wide fetch is post-filtered down to this)
34
+ const MAX_BLOCK_CHARS = 600; // injection size cap (ADR 0002: inject little, high-signal)
35
+ const SEMANTIC_FLOOR = 0.4; // dense cosine floor (reused from P1.5)
36
+ const PROSE_TYPES = new Set(['Page', 'Section']); // prose scope drop code symbols
37
+ const ENDPOINT_REL = path.join('.recon-wrxn', 'serve-endpoint.json');
38
+ const FIND_PATH = '/api/tools/recon_find';
35
39
 
36
40
  function emit(envelope) {
37
41
  process.stdout.write(JSON.stringify(envelope));
38
42
  process.exit(0);
39
43
  }
40
44
 
45
+ // Walk up from cwd / CLAUDE_PROJECT_DIR to the install root carrying wrxn.install.json.
41
46
  function findInstallRoot(startDir) {
42
47
  let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
43
48
  for (let i = 0; i < 12; i++) {
@@ -49,79 +54,254 @@ function findInstallRoot(startDir) {
49
54
  return null;
50
55
  }
51
56
 
52
- // De-duped salient content tokens (lowercased, >=4 chars, non-stopword).
53
- function salientTerms(prompt) {
54
- const seen = new Set();
55
- for (const tok of (String(prompt || '').toLowerCase().match(/[a-z][a-z0-9]{3,}/g) || [])) {
56
- if (!STOPWORDS.has(tok)) seen.add(tok);
57
+ // ── the gate (PURE) ────────────────────────────────────────────────────────────────
58
+
59
+ function isProse(hit) {
60
+ return !!hit && PROSE_TYPES.has(hit.type);
61
+ }
62
+
63
+ // Consensus = the hit surfaced in BOTH the BM25 and the dense arm (the find response's `sources`
64
+ // provenance). A consensus hit qualifies even below the cosine floor.
65
+ function hasConsensus(hit) {
66
+ const s = hit && hit.sources;
67
+ return Array.isArray(s) && s.includes('bm25') && s.includes('semantic');
68
+ }
69
+
70
+ // Qualify on the PER-ARM signal only: the semantic cosine floor OR consensus. NEVER the fused
71
+ // `score` (RRF is a rank-based consensus, not a relevance magnitude — ADR 0002). The floor clause
72
+ // requires the dense arm to actually be PRESENT in `sources` (not just a stray semanticScore) —
73
+ // today these co-occur, but this is a defense against a future producer emitting a score without the
74
+ // 'semantic' tag. An absent/NaN semanticScore can never clear the floor; only consensus rescues it.
75
+ function qualifies(hit) {
76
+ const sem = Number(hit && hit.semanticScore);
77
+ const s = hit && hit.sources;
78
+ const hasSemantic = Array.isArray(s) && s.includes('semantic');
79
+ const floorOk = Number.isFinite(sem) && sem >= SEMANTIC_FLOOR && hasSemantic;
80
+ return floorOk || hasConsensus(hit);
81
+ }
82
+
83
+ function slugify(s) {
84
+ return String(s || '')
85
+ .toLowerCase()
86
+ .replace(/[^a-z0-9]+/g, '-')
87
+ .replace(/^-+|-+$/g, '')
88
+ .slice(0, 48);
89
+ }
90
+
91
+ // A stable slug for a hit: the prose file's basename (sans extension), else a slugified name.
92
+ function slugOf(hit) {
93
+ if (hit.file) {
94
+ const base = path.basename(String(hit.file)).replace(/\.[^.]+$/, '');
95
+ if (base) return base.slice(0, 48);
57
96
  }
58
- return [...seen];
59
- }
60
-
61
- // Score each wiki page by the count of DISTINCT salient terms it contains; keep pages with >=1.
62
- function recall(root, terms) {
63
- const hits = [];
64
- for (const tier of TIERS) {
65
- const dir = path.join(root, '.wrxn', 'wiki', tier);
66
- let names;
67
- try {
68
- names = fs.readdirSync(dir).filter((n) => n.endsWith('.md'));
69
- } catch {
70
- continue; // tier absent skip
71
- }
72
- for (const name of names) {
73
- let text;
74
- try {
75
- text = fs.readFileSync(path.join(dir, name), 'utf8').toLowerCase();
76
- } catch {
77
- continue;
78
- }
79
- const matched = terms.filter((t) => text.includes(t)).length;
80
- if (matched >= MIN_DISTINCT) hits.push({ slug: name.replace(/\.md$/, ''), tier, score: matched });
81
- }
97
+ return slugify(hit.name) || 'untitled';
98
+ }
99
+
100
+ // The one-line descriptor. NOTE: a recon FindHit carries NO body text only name/file/line/scores
101
+ // so the snippet is the hit's NAME (the page title / section heading), the most descriptive line
102
+ // available. (Follow-up: if the door later surfaces a per-hit text excerpt, prefer it here.)
103
+ function snippetOf(hit) {
104
+ const s = String(hit.name || hit.file || '').replace(/\s+/g, ' ').trim();
105
+ return s.length > 100 ? s.slice(0, 99) + '…' : s;
106
+ }
107
+
108
+ // Render the qualifying hits into the <recall-surface> block, guaranteed <= MAX_BLOCK_CHARS and
109
+ // always closed. Drops trailing bullets first, then hard-truncates the last line if it still overflows.
110
+ function renderBlock(hits) {
111
+ const head = '<recall-surface>';
112
+ const intro = 'Knowledge already in your Brain, recalled for this prompt — read it before re-deriving or re-asking the operator:';
113
+ const foot = '</recall-surface>';
114
+ const bullets = hits.map((h) => `- ${slugOf(h)} — ${snippetOf(h)}`);
115
+ const assemble = (bs) => [head, intro, ...bs, foot].join('\n');
116
+ const kept = bullets.slice();
117
+ while (kept.length > 1 && assemble(kept).length > MAX_BLOCK_CHARS) kept.pop();
118
+ let block = assemble(kept);
119
+ if (block.length > MAX_BLOCK_CHARS) {
120
+ block = block.slice(0, MAX_BLOCK_CHARS - foot.length - 1).replace(/\s+\S*$/, '').trimEnd() + '\n' + foot;
121
+ }
122
+ return block;
123
+ }
124
+
125
+ // PURE: prose-filter → gate → top-N → format. Returns the block string, or null (Abstain).
126
+ function decideRecall(hits) {
127
+ const list = Array.isArray(hits) ? hits : [];
128
+ const qualified = list.filter((h) => isProse(h) && qualifies(h)).slice(0, TOP_N);
129
+ if (!qualified.length) return null;
130
+ return renderBlock(qualified);
131
+ }
132
+
133
+ // ── the door (IO shell, injectable transport) ───────────────────────────────────────
134
+
135
+ // A pid is alive unless process.kill(pid,0) throws ESRCH. EPERM means it exists (owned by another
136
+ // user) — still alive. Mirrors the cross-repo discovery contract.
137
+ function pidAlive(pid) {
138
+ try {
139
+ process.kill(pid, 0);
140
+ return true;
141
+ } catch (e) {
142
+ return !!e && e.code === 'EPERM';
143
+ }
144
+ }
145
+
146
+ // Refuse a discovery file another user could have planted, or that is group/world-writable — trusting
147
+ // it would let a hostile workspace point the door host/port at an exfil/injection sink (the hook feeds
148
+ // the door's response into the prompt context). lstat (not stat) so a symlink's OWN ownership/mode is
149
+ // judged. A platform without getuid skips the uid check but still enforces the mode check. Any fault →
150
+ // not trusted (treated as not-warm).
151
+ function endpointTrusted(file) {
152
+ let st;
153
+ try {
154
+ st = fs.lstatSync(file);
155
+ } catch {
156
+ return false;
82
157
  }
83
- // Highest distinct-term count first; ties broken by slug for determinism.
84
- hits.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug));
85
- return hits.slice(0, TOP_N);
158
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return false; // foreign owner
159
+ if ((st.mode & 0o022) !== 0) return false; // group/world-writable
160
+ return true;
86
161
  }
87
162
 
88
- function block(hits) {
89
- const lines = [
90
- '<recall-surface>',
91
- 'You already have captured page(s) on this topic — READ before answering (do not re-derive, do',
92
- 'not ask the operator to re-explain). Recall with: node .wrxn/wiki.cjs recall "<slug>"',
93
- ...hits.map((h) => `- ${h.slug} (${h.tier})`),
94
- '</recall-surface>',
95
- ].join('\n');
96
- return lines.length <= MAX_BLOCK_CHARS ? lines : `${lines.slice(0, MAX_BLOCK_CHARS - 18)}\n</recall-surface>`;
163
+ // Discover the warm serve door from <root>/.recon-wrxn/serve-endpoint.json = {pid,port}. Returns
164
+ // {pid,port} only when the file is well-owned (not planted), present, well-formed, and the pid is
165
+ // alive — else null (not warm).
166
+ function discoverEndpoint(root) {
167
+ const file = path.join(root, ENDPOINT_REL);
168
+ if (!endpointTrusted(file)) return null; // absent, foreign-owned, or loose perms → not warm
169
+ let raw;
170
+ try {
171
+ raw = fs.readFileSync(file, 'utf8');
172
+ } catch {
173
+ return null; // absent (race)
174
+ }
175
+ let obj;
176
+ try {
177
+ obj = JSON.parse(raw);
178
+ } catch {
179
+ return null; // malformed
180
+ }
181
+ const pid = Number(obj && obj.pid);
182
+ const port = Number(obj && obj.port);
183
+ if (!Number.isInteger(pid) || pid <= 0) return null;
184
+ if (!Number.isInteger(port) || port <= 0) return null;
185
+ if (!pidAlive(pid)) return null; // dead pid → not warm
186
+ return { pid, port };
187
+ }
188
+
189
+ // Default transport: a real POST over http with a hard timeout. Resolves {statusCode, body}; rejects
190
+ // on socket error or timeout. Injectable so unit tests never touch the network (mirrors connect.cjs).
191
+ function httpTransport({ port, path: reqPath, body, timeoutMs }) {
192
+ return new Promise((resolve, reject) => {
193
+ const payload = Buffer.from(JSON.stringify(body));
194
+ const deadline = timeoutMs || TIMEOUT_MS;
195
+ let settled = false;
196
+ let wall = null;
197
+ const done = (fn, arg) => {
198
+ if (settled) return;
199
+ settled = true;
200
+ if (wall) clearTimeout(wall);
201
+ fn(arg);
202
+ };
203
+ const req = http.request(
204
+ {
205
+ host: '127.0.0.1',
206
+ port,
207
+ path: reqPath,
208
+ method: 'POST',
209
+ headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length },
210
+ },
211
+ (res) => {
212
+ const chunks = [];
213
+ let total = 0;
214
+ res.on('data', (c) => {
215
+ total += c.length;
216
+ if (total > MAX_RESPONSE_BYTES) { req.destroy(new Error('recall door response too large')); return; }
217
+ chunks.push(c);
218
+ });
219
+ res.on('end', () => done(resolve, { statusCode: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
220
+ res.on('error', (e) => done(reject, e));
221
+ }
222
+ );
223
+ req.on('error', (e) => done(reject, e));
224
+ // Idle timeout (no bytes for `deadline`) AND an independent wall-clock: the latter bounds a trickle
225
+ // attacker that dribbles bytes to keep the idle timer from ever firing past the hook budget.
226
+ req.setTimeout(deadline, () => req.destroy(new Error('recall door timeout')));
227
+ wall = setTimeout(() => req.destroy(new Error('recall door wall-clock timeout')), deadline);
228
+ req.write(payload);
229
+ req.end();
230
+ });
97
231
  }
98
232
 
99
- function main() {
233
+ // IO shell: discover the door, POST the prose query, gate the hits. Returns the block string or null.
234
+ // `transport` is injected in tests; production uses httpTransport. Sends NO `type` (recon_find takes a
235
+ // single NodeType, not an array) — prose scope is enforced by decideRecall's post-filter.
236
+ async function recallFromDoor(root, prompt, { transport, timeoutMs } = {}) {
237
+ const door = discoverEndpoint(root);
238
+ if (!door) return null; // not warm → Abstain (silent)
239
+ const query = String(prompt || '').trim().slice(0, MAX_QUERY_CHARS);
240
+ if (!query) return null;
241
+ let resp;
242
+ try {
243
+ resp = await (transport || httpTransport)({
244
+ port: door.port,
245
+ path: FIND_PATH,
246
+ body: { query, limit: FETCH_LIMIT },
247
+ timeoutMs: timeoutMs || TIMEOUT_MS,
248
+ });
249
+ } catch {
250
+ return null; // timeout / connection refused / abort → silent
251
+ }
252
+ if (!resp || resp.statusCode !== 200) return null;
253
+ let parsed;
254
+ try {
255
+ parsed = JSON.parse(resp.body);
256
+ } catch {
257
+ return null; // malformed body → silent
258
+ }
259
+ return decideRecall(Array.isArray(parsed.hits) ? parsed.hits : []);
260
+ }
261
+
262
+ // ── entrypoint ──────────────────────────────────────────────────────────────────────
263
+
264
+ async function main() {
100
265
  let event = {};
101
266
  try {
102
267
  const stdin = fs.readFileSync(0, 'utf8');
103
268
  if (stdin.trim()) event = JSON.parse(stdin);
104
269
  } catch {
105
- emit({});
270
+ return emit({});
106
271
  }
107
272
 
108
- const root = findInstallRoot();
109
- if (!root) emit({});
273
+ const root = findInstallRoot(event.cwd);
274
+ if (!root) return emit({});
110
275
 
111
276
  const prompt = typeof event.prompt === 'string' ? event.prompt : '';
112
- if (prompt.trim().length < MIN_PROMPT_LEN) emit({});
113
-
114
- const terms = salientTerms(prompt);
115
- if (!terms.length) emit({});
277
+ if (prompt.trim().length < MIN_PROMPT_LEN) return emit({});
116
278
 
117
- const hits = recall(root, terms);
118
- if (!hits.length) emit({}); // no captured page matched → silent (the gate)
279
+ let block = null;
280
+ try {
281
+ block = await recallFromDoor(root, prompt.trim());
282
+ } catch {
283
+ return emit({});
284
+ }
285
+ if (!block) return emit({}); // nothing cleared the gate → Abstain
119
286
 
120
- emit({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: block(hits) } });
287
+ return emit({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: block } });
121
288
  }
122
289
 
123
- try {
124
- main();
125
- } catch {
126
- emit({});
290
+ if (require.main === module) {
291
+ main().catch(() => emit({}));
127
292
  }
293
+
294
+ module.exports = {
295
+ decideRecall,
296
+ recallFromDoor,
297
+ discoverEndpoint,
298
+ httpTransport,
299
+ pidAlive,
300
+ isProse,
301
+ hasConsensus,
302
+ qualifies,
303
+ renderBlock,
304
+ findInstallRoot,
305
+ SEMANTIC_FLOOR,
306
+ PROSE_TYPES,
307
+ };
package/payload/.mcp.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "recon-wrxn": {
4
4
  "command": "npx",
5
- "args": ["-y", "recon-wrxn@6.0.0-wrxn.2", "serve"]
5
+ "args": ["-y", "recon-wrxn@6.0.0-wrxn.3", "serve"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "projects": [],
3
3
  "embeddings": false,
4
+ "serveEmbed": true,
5
+ "serveHttp": true,
4
6
  "watch": true,
5
7
  "ignore": []
6
8
  }