@gcunharodrigues/wrxn 0.13.2 → 0.14.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.
@@ -23,6 +23,8 @@
23
23
  const fs = require('fs');
24
24
  const http = require('http');
25
25
  const path = require('path');
26
+ const { coalesceSidecar } = require('./sidecar.cjs'); // shared coalesced read/rewrite/fail-open/secret-scan
27
+ const { rewardFactor, selectRewardMode, RECORDED_REWARD_VERDICT } = require('./reward.cjs'); // S3 re-rank + S5 verdict-derived mode
26
28
 
27
29
  const MIN_PROMPT_LEN = 8; // skip trivial prompts ("ok", "yes")
28
30
  const MAX_QUERY_CHARS = 512; // trim the prompt before querying the door
@@ -37,7 +39,19 @@ const PROSE_TYPES = new Set(['Page', 'Section']); // prose scope — drop code s
37
39
  const ENDPOINT_REL = path.join('.recon-wrxn', 'serve-endpoint.json');
38
40
  const FIND_PATH = '/api/tools/recon_find';
39
41
  const REINFORCE_REL = path.join('.wrxn', 'reinforce.json'); // coalesced access-recency sidecar (STATE)
42
+ const SURFACED_REL = path.join('.wrxn', 'surfaced.json'); // per-session surfaced-log sidecar (STATE)
40
43
  const WIKI_PREFIX = '.wrxn/wiki/'; // the wiki root — stripped to form the D1 join key
44
+ const REWARD_REL = path.join('.wrxn', 'reward.json'); // per-page Beta-Bernoulli store (STATE) — read-only here
45
+ const HISTORY_REL = path.join('.wrxn', 'history'); // per-session edit trail dir (STATE) — .touched lives here (S4)
46
+
47
+ // The single shipped mode gating the reward re-rank (mirrors recon's SHIPPED_DECAY_MODE). It is DERIVED
48
+ // from the recorded lift-gate verdict via selectRewardMode — never hard-coded — so the live state is never
49
+ // a silent default. Today RECORDED_REWARD_VERDICT is NOT passing (no real session corpus accrued yet), so
50
+ // this resolves to 'shadow': reward counts accrue at session-end but the factor NEVER moves a recall rank
51
+ // — recall output is byte-identical to pre-reward behaviour. It flips to 'live' ONLY when the recorded
52
+ // verdict passes (the offline lift gate proves lift on real data AND the operator ratifies the git-only
53
+ // signal — docs/eval/0001-reward-lift-gate.md). Tests force 'live' via the recallFromDoor option.
54
+ const SHIPPED_REWARD_MODE = selectRewardMode(RECORDED_REWARD_VERDICT);
41
55
 
42
56
  function emit(envelope) {
43
57
  process.stdout.write(JSON.stringify(envelope));
@@ -82,6 +96,118 @@ function qualifies(hit) {
82
96
  return floorOk || hasConsensus(hit);
83
97
  }
84
98
 
99
+ // ── S1 exclusion: drop curation-retired pages (stale / superseded) before the gate (#20) ─────────────
100
+ //
101
+ // Harvest stamps retirement into a page's FRONTMATTER — `stale: <missing-source>` (orphaned) or
102
+ // `superseded_by: <path>` (replaced). A recon FindHit carries no frontmatter, so the exclusion reads it
103
+ // off a { hit.file → frontmatter } VIEW supplied by the caller (the IO shell reads the page files; tests
104
+ // pass a literal map) — buildExclusion stays a PURE, deterministic black box. A page is RETIRED when its
105
+ // frontmatter sets a truthy `stale:` OR it is not its own live head (it carries a `superseded_by:` that
106
+ // resolves forward). resolveHead walks the supersession chain to the live head and fails SAFE on a cycle
107
+ // or a dangling successor: the affected page is excluded and recall NEVER throws.
108
+
109
+ const MAX_SUPERSESSION_HOPS = 64; // a belt-and-braces bound; the visited-set already stops a cycle
110
+
111
+ // Walk `superseded_by` from `start` to the live head (a page with no successor). Returns the head path,
112
+ // or null when the chain is malformed — a cycle (a path seen twice) or a dangling successor (a pointer
113
+ // to a page absent from the view). PURE over the frontmatter view; a null head ⇒ fail-safe exclude.
114
+ function resolveHead(start, view) {
115
+ let cur = start;
116
+ const seen = new Set();
117
+ for (let i = 0; i < MAX_SUPERSESSION_HOPS; i++) {
118
+ if (seen.has(cur)) return null; // cycle → fail safe
119
+ seen.add(cur);
120
+ const fm = view && view[cur];
121
+ if (!fm) return null; // dangling: a successor points at a page not in the view → fail safe
122
+ const next = fm.superseded_by;
123
+ if (!next || typeof next !== 'string') return cur; // no (usable) successor → cur is the live head
124
+ cur = next;
125
+ }
126
+ return null; // over the hop bound (pathological chain) → fail safe
127
+ }
128
+
129
+ // Build the exclude predicate (true ⇒ drop the hit) from the frontmatter view. A page is excluded when it
130
+ // is truthy-`stale:`, OR it is superseded (its resolved live head is not itself — including the fail-safe
131
+ // null head from a cycle / dangling chain). A page absent from the view, or with empty frontmatter, is
132
+ // kept. TOTAL: a non-object view yields a never-exclude predicate (fail-open → recall unchanged).
133
+ function buildExclusion(view) {
134
+ const v = view && typeof view === 'object' ? view : {};
135
+ return (hit) => {
136
+ const file = hit && hit.file;
137
+ if (typeof file !== 'string' || !file) return false; // no path to judge → keep
138
+ const fm = v[file];
139
+ if (!fm || typeof fm !== 'object') return false; // unflagged / absent → keep
140
+ if (fm.stale) return true; // truthy stale (a missing-source orphan) → retired
141
+ if (fm.superseded_by) return resolveHead(file, v) !== file; // not its own head → retired (incl. fail-safe)
142
+ return false;
143
+ };
144
+ }
145
+
146
+ // ── S4 structural arm: edited paths → a recon_find seed, and reciprocal-rank fusion (#23) ────────────
147
+ //
148
+ // Adapted (no-invention): recon-wrxn exposes no DOCUMENTED_BY code→wiki graph edge, so the structural
149
+ // arm is kernel-only — it REUSES the .touched per-session edited-paths list and issues a SECOND
150
+ // recon_find seeded by those edits, then RRF-fuses that ranked list with the prompt-semantic one BEFORE
151
+ // the exclusion / gate / reward / top-N. Both helpers here are PURE black boxes (the fetch + the .touched
152
+ // read are the injectable IO shell below).
153
+
154
+ // Seed a recon_find query from the session's edited paths: take each path's basename (sans extension),
155
+ // tokenize on non-alphanumerics, dedup case-insensitively, join with spaces, cap at the query budget. A
156
+ // dir-segment carries little semantic signal (and a code-seeded arm fetches mostly code that the prose
157
+ // filter drops anyway), so only basenames are seeded — they are what a wiki page about the file is titled
158
+ // after. Empty / non-array / all-junk input → '' (the no-op seed: the structural arm then never fires).
159
+ function buildStructuralQuery(paths) {
160
+ const list = Array.isArray(paths) ? paths : [];
161
+ const seen = new Set();
162
+ const tokens = [];
163
+ for (const p of list) {
164
+ if (typeof p !== 'string' || !p) continue;
165
+ const base = path.basename(p).replace(/\.[^.]+$/, '');
166
+ for (const raw of base.split(/[^A-Za-z0-9]+/)) {
167
+ const tok = raw.trim();
168
+ if (!tok) continue;
169
+ const key = tok.toLowerCase();
170
+ if (seen.has(key)) continue;
171
+ seen.add(key);
172
+ tokens.push(tok);
173
+ }
174
+ }
175
+ return tokens.join(' ').slice(0, MAX_QUERY_CHARS);
176
+ }
177
+
178
+ // Reciprocal-rank fusion of two ranked hit lists into one, deduped by page key. Each hit contributes
179
+ // 1/(k + rank) (rank 1-based) to its key's score; a page in BOTH arms accrues both contributions and so
180
+ // outranks a single-arm page (rank-based consensus, never a score magnitude — same posture as the gate).
181
+ // IDENTITY when one list is empty: a single list's per-rank scores strictly decrease, so the sort returns
182
+ // it in its original order with its original hit objects — this is what makes the empty-.touched path a
183
+ // byte-identical no-op. On a key collision listA's hit object is the kept representative (the semantic arm
184
+ // is primary; the structural arm only boosts an already-present page's rank). Ties break by first-seen
185
+ // order (stable). A hit with no usable key (no file/id/name) is skipped.
186
+ const RRF_K = 60; // the standard RRF damping constant (Cormack et al.) — large k softens top-rank dominance
187
+
188
+ function hitKey(h) {
189
+ return (h && (h.file || h.id || h.name)) || null;
190
+ }
191
+
192
+ function rrfFuse(listA, listB, k) {
193
+ const K = Number.isFinite(k) && k > 0 ? k : RRF_K;
194
+ const acc = new Map(); // key → { hit, score, order }
195
+ let order = 0;
196
+ const fold = (list) => {
197
+ (Array.isArray(list) ? list : []).forEach((h, i) => {
198
+ const key = hitKey(h);
199
+ if (key == null) return;
200
+ const contrib = 1 / (K + i + 1); // rank is 1-based
201
+ const cur = acc.get(key);
202
+ if (cur) cur.score += contrib; // page in both arms → summed reciprocal ranks (consensus boost)
203
+ else acc.set(key, { hit: h, score: contrib, order: order++ }); // first arm to see it owns the object
204
+ });
205
+ };
206
+ fold(listA); // listA (semantic) folded first → it owns the representative on a collision
207
+ fold(listB);
208
+ return [...acc.values()].sort((x, y) => y.score - x.score || x.order - y.order).map((x) => x.hit);
209
+ }
210
+
85
211
  function slugify(s) {
86
212
  return String(s || '')
87
213
  .toLowerCase()
@@ -124,16 +250,64 @@ function renderBlock(hits) {
124
250
  return block;
125
251
  }
126
252
 
127
- // PURE: the prose hits that clear the gate, capped at TOP_N — exactly the hits decideRecall renders
128
- // (and the pages reinforce stamps). Factored out so the IO shell can stamp the surfaced pages by path.
129
- function qualifyingHits(hits) {
253
+ // ── S3 reward re-rank (PURE) ─────────────────────────────────────────────────────────
254
+ //
255
+ // The learning-moat's value axis applied to recall: a per-page reward factor (∈(0,2), neutral 1) from
256
+ // the Beta-Bernoulli store re-ranks the qualifying prose candidates by `base relevance × factor` BEFORE
257
+ // the top-N cut, so a page that has preceded good sessions rises and one that preceded nothing fades.
258
+ // The factor is INJECTED as a lookup { <wiki-rel-path>: factor } (the impure shell reads .wrxn/reward.json
259
+ // and pre-computes factors via reward.cjs's rewardFactor) — decideRecall stays pure and reward-math-free.
260
+ //
261
+ // SHADOW BY DEFAULT: the re-rank only fires when a NON-EMPTY lookup is passed. An absent / empty lookup
262
+ // is the IDENTITY — the door order is preserved byte-for-byte, exactly today's behaviour. The shell
263
+ // passes a lookup only in 'live' mode (SHIPPED_REWARD_MODE), so the shipped default is a provable no-op.
264
+
265
+ // The relevance magnitude the reward factor modulates: the door's fused score, with the dense cosine as
266
+ // a fallback when a hit carries no fused score (totality), else 0.
267
+ function baseScore(hit) {
268
+ const sc = Number(hit && hit.score);
269
+ if (Number.isFinite(sc)) return sc;
270
+ const sem = Number(hit && hit.semanticScore);
271
+ return Number.isFinite(sem) ? sem : 0;
272
+ }
273
+
274
+ // A hit's reward factor from the lookup, keyed by its wiki-rel path. A page absent from the lookup, a
275
+ // non-wiki hit, or a non-positive / garbage factor → neutral 1 (fail-open: reward never zeroes a rank).
276
+ function factorFor(lookup, hit) {
277
+ const key = wikiRelPath(hit && hit.file);
278
+ const f = key ? Number(lookup[key]) : NaN;
279
+ return Number.isFinite(f) && f > 0 ? f : 1;
280
+ }
281
+
282
+ // Re-rank prose candidates by base relevance × reward factor, descending. STABLE: equal effective
283
+ // scores keep door order (so an all-neutral lookup is the exact identity). An absent / empty / non-object
284
+ // lookup short-circuits to the identity — the shadow no-op, by construction byte-identical to today.
285
+ function rerankByReward(prose, rewardLookup) {
286
+ if (!rewardLookup || typeof rewardLookup !== 'object' || !Object.keys(rewardLookup).length) return prose;
287
+ return prose
288
+ .map((h, i) => ({ h, i, eff: baseScore(h) * factorFor(rewardLookup, h) }))
289
+ .sort((a, b) => b.eff - a.eff || a.i - b.i)
290
+ .map((x) => x.h);
291
+ }
292
+
293
+ // PURE: the prose hits that clear the gate, reward-re-ranked, capped at TOP_N — exactly the hits
294
+ // decideRecall renders (and the pages reinforce stamps). The optional reward lookup is injected; absent
295
+ // → door order (shadow). The optional `exclude` predicate (true ⇒ drop) is applied FIRST — before the
296
+ // prose-filter, gate, reward re-rank, and top-N cut — so a retired page (stale / superseded) never
297
+ // occupies a slot a live page should hold; absent ⇒ no exclusion (byte-identical to today). Factored
298
+ // out so the IO shell can stamp the surfaced pages by path.
299
+ function qualifyingHits(hits, rewardLookup, exclude) {
130
300
  const list = Array.isArray(hits) ? hits : [];
131
- return list.filter((h) => isProse(h) && qualifies(h)).slice(0, TOP_N);
301
+ const kept = typeof exclude === 'function' ? list.filter((h) => !exclude(h)) : list;
302
+ const prose = kept.filter((h) => isProse(h) && qualifies(h));
303
+ return rerankByReward(prose, rewardLookup).slice(0, TOP_N);
132
304
  }
133
305
 
134
- // PURE: prose-filter → gate → top-N → format. Returns the block string, or null (Abstain).
135
- function decideRecall(hits) {
136
- const qualified = qualifyingHits(hits);
306
+ // PURE: exclusion → prose-filter → gate → reward re-rank → top-N → format. Returns the block string,
307
+ // or null (Abstain). The reward lookup and the exclusion predicate are optional and injected; with
308
+ // neither (the shipped default) the output is byte-identical to the pre-exclusion behaviour.
309
+ function decideRecall(hits, rewardLookup, exclude) {
310
+ const qualified = qualifyingHits(hits, rewardLookup, exclude);
137
311
  if (!qualified.length) return null;
138
312
  return renderBlock(qualified);
139
313
  }
@@ -169,27 +343,13 @@ function dayStamp(now) {
169
343
  // Stamp each surfaced prose hit's wiki-rel path → today into <root>/.wrxn/reinforce.json. Writes only
170
344
  // when the map actually changes (coalesced). Wholly best-effort: never throws, never blocks recall.
171
345
  function reinforce(root, hits, now) {
172
- try {
173
- const list = Array.isArray(hits) ? hits : [];
174
- if (!root || !list.length) return;
175
- const file = path.join(root, REINFORCE_REL);
176
- let map = {};
177
- let raw = null;
178
- try {
179
- raw = fs.readFileSync(file, 'utf8');
180
- } catch {
181
- raw = null; // absent → fresh map (normal, not a fault)
182
- }
183
- if (raw !== null) {
184
- let parsed;
185
- try {
186
- parsed = JSON.parse(raw);
187
- } catch {
188
- return; // malformed existing sidecar → skip silently, leave it untouched (never clobber)
189
- }
190
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; // not a map → skip
191
- map = parsed;
192
- }
346
+ const list = Array.isArray(hits) ? hits : [];
347
+ if (!root || !list.length) return;
348
+ // The whole read/parse/coalesce/rewrite/fail-open/secret-scan mechanism lives in coalesceSidecar; here
349
+ // we supply only the domain mutation — stamp each surfaced prose hit's wiki-rel join key to today,
350
+ // reporting whether the map actually changed (so an all-already-today recall is a coalesced no-op). The
351
+ // day-stamp is computed INSIDE the mutate so an invalid clock is caught by the helper's fail-open envelope.
352
+ coalesceSidecar(path.join(root, REINFORCE_REL), (map) => {
193
353
  const day = dayStamp(now);
194
354
  let changed = false;
195
355
  for (const h of list) {
@@ -200,12 +360,86 @@ function reinforce(root, hits, now) {
200
360
  changed = true;
201
361
  }
202
362
  }
203
- if (!changed) return; // coalesced no-op → file stays byte-identical (<= 1 write/page/day)
204
- fs.mkdirSync(path.dirname(file), { recursive: true });
205
- fs.writeFileSync(file, JSON.stringify(map, null, 2) + '\n');
363
+ return changed;
364
+ });
365
+ }
366
+
367
+ // ── surfaced-log: the per-session record of what recall surfaced (S1 / kernel #12) ────
368
+ //
369
+ // When Recall surfaces prose pages, record THIS session's surfaced (qualifying) page-paths into
370
+ // <root>/.wrxn/surfaced.json — a compact map { "<session_id>": ["<wiki-rel-path>", …] } via the shared
371
+ // coalesced-sidecar helper. The value uses the SAME wiki-root-relative join key reinforce stamps (a
372
+ // slug/path mismatch silently breaks downstream consumers). Coalesced: re-surfacing the identical set
373
+ // for a session rewrites nothing. Best-effort + non-blocking — the helper swallows every fault, so a
374
+ // surfaced-log write can never alter or break the recall surfacing.
375
+
376
+ // Map the surfaced hits to their wiki-rel join keys, de-duplicated, order preserved. (qualifyingHits
377
+ // has already prose-filtered + gated + capped; here we only project to the path key and drop non-wiki
378
+ // hits / dupes.)
379
+ function surfacedPaths(hits) {
380
+ const list = Array.isArray(hits) ? hits : [];
381
+ const seen = new Set();
382
+ const out = [];
383
+ for (const h of list) {
384
+ const key = wikiRelPath(h && h.file);
385
+ if (!key || seen.has(key)) continue;
386
+ seen.add(key);
387
+ out.push(key);
388
+ }
389
+ return out;
390
+ }
391
+
392
+ // Record `paths(hits)` under `sessionId` in the surfaced-log. No session id or no surfaced paths → no
393
+ // write. Coalesced: an unchanged set for the session leaves the file byte-identical.
394
+ function surfacedLog(root, sessionId, hits) {
395
+ if (!root || !sessionId) return;
396
+ const paths = surfacedPaths(hits);
397
+ if (!paths.length) return;
398
+ coalesceSidecar(path.join(root, SURFACED_REL), (map) => {
399
+ const prev = map[sessionId];
400
+ if (Array.isArray(prev) && prev.length === paths.length && prev.every((v, i) => v === paths[i])) {
401
+ return false; // identical surfaced set for this session → coalesced no-op
402
+ }
403
+ map[sessionId] = paths;
404
+ return true;
405
+ });
406
+ }
407
+
408
+ // ── S4 structural arm: read the .touched edit trail (IO shell) (#23) ─────────────────────────────────
409
+ //
410
+ // The session-id → filename transform MUST match code-intel-push's safeId byte-for-byte, or the arm reads
411
+ // the wrong file. Replicated here (self-contained: no shared import) — the same discipline that duplicates
412
+ // secretScan across the install-only modules.
413
+ function safeSessionId(sid) {
414
+ return String(sid || 'session')
415
+ .toLowerCase()
416
+ .replace(/[^a-z0-9]+/g, '-')
417
+ .replace(/^-+|-+$/g, '')
418
+ .slice(0, 48) || 'session';
419
+ }
420
+
421
+ // Read this session's edited paths back from <root>/.wrxn/history/<safeId>.touched — the list
422
+ // code-intel-push already maintains (REUSE; no new persistence path). Deduped on read, order preserved.
423
+ // FAIL-OPEN: an absent file (the common no-edits case) or any read fault → [] (the structural arm then
424
+ // never fires). The values are treated ONLY as a query seed — never as filesystem paths.
425
+ function readTouched(root, sessionId) {
426
+ if (!root) return [];
427
+ const marker = path.join(root, HISTORY_REL, `${safeSessionId(sessionId)}.touched`);
428
+ let raw;
429
+ try {
430
+ raw = fs.readFileSync(marker, 'utf8');
206
431
  } catch {
207
- /* best-effort: a reinforce fault must NEVER alter or break the recall surfacing */
432
+ return []; // absent / unreadable no edits this session
208
433
  }
434
+ const seen = new Set();
435
+ const out = [];
436
+ for (const line of raw.split('\n')) {
437
+ const p = line.trim();
438
+ if (!p || seen.has(p)) continue;
439
+ seen.add(p);
440
+ out.push(p);
441
+ }
442
+ return out;
209
443
  }
210
444
 
211
445
  // ── the door (IO shell, injectable transport) ───────────────────────────────────────
@@ -308,36 +542,203 @@ function httpTransport({ port, path: reqPath, body, timeoutMs }) {
308
542
  });
309
543
  }
310
544
 
545
+ // Read the reward sidecar (.wrxn/reward.json = { "<wiki-rel-path>": { s, f } }) and pre-compute each
546
+ // page's reward factor → { "<wiki-rel-path>": factor } for the live re-rank. FAIL-OPEN: a missing /
547
+ // corrupt / non-object store → null (a neutral lookup → recall unaffected). The keys are treated ONLY
548
+ // as join keys (map lookups against wikiRelPath), NEVER as filesystem paths (sec posture). rewardFactor
549
+ // is total, so a malformed per-page slot reads as zero evidence → neutral 1.
550
+ function readRewardLookup(root) {
551
+ if (!root) return null;
552
+ let raw;
553
+ try {
554
+ raw = fs.readFileSync(path.join(root, REWARD_REL), 'utf8');
555
+ } catch {
556
+ return null; // absent → neutral (the common case before the gate flips, and on any read fault)
557
+ }
558
+ let counts;
559
+ try {
560
+ counts = JSON.parse(raw);
561
+ } catch {
562
+ return null; // corrupt JSON → neutral, recall proceeds unchanged
563
+ }
564
+ if (!counts || typeof counts !== 'object' || Array.isArray(counts)) return null;
565
+ const lookup = {};
566
+ for (const key of Object.keys(counts)) lookup[key] = rewardFactor(counts[key]);
567
+ return lookup;
568
+ }
569
+
570
+ // ── S1 exclusion: read each candidate page's retirement frontmatter from disk (#20) ──────────────────
571
+ //
572
+ // buildExclusion is PURE over a { hit.file → frontmatter } view; this shell builds that view by reading
573
+ // each candidate page's frontmatter off disk under <root>. A recon FindHit carries no frontmatter, so
574
+ // this is the only source of the `stale:` / `superseded_by:` flags harvest stamps. BEST-EFFORT: a page
575
+ // read fault (missing/unreadable file, no fence) contributes empty frontmatter → the page is treated as
576
+ // unflagged (kept) and nothing throws — so a since-deleted page or a parse hiccup never breaks recall.
577
+
578
+ // Extract the frontmatter block (between the leading `---` fence and the next `---`). Mirrors drift-detect:
579
+ // a page without a closed fence yields '' (no flags → kept).
580
+ function frontmatterBlock(content) {
581
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
582
+ return m ? m[1] : '';
583
+ }
584
+
585
+ // A YAML-falsy scalar — a `stale:`/`superseded_by:` set to any of these means NOT retired (the page is
586
+ // kept). It mirrors YAML's boolean/null primitives so that, e.g., `stale: false` un-flags a page rather
587
+ // than being read as the truthy STRING 'false' (#25). Compared case-insensitively after quote-stripping.
588
+ const YAML_FALSY = new Set(['false', 'no', 'null', '~', '0', '']);
589
+
590
+ // Parse the two retirement keys from a page's frontmatter into { stale?, superseded_by? }. Values are
591
+ // simple scalars (harvest writes `stale: <path>` / `superseded_by: <path>`); surrounding quotes are
592
+ // stripped. The scalar is interpreted as a YAML boolean: a falsy scalar (false / no / null / ~ / 0 /
593
+ // empty) means NOT retired and is DROPPED — only a truthy flag (a non-empty non-falsy value, e.g.
594
+ // `true`, `yes`, or a harvest source path) is kept, so the downstream truthiness check is correct (#25).
595
+ // Only these two keys are read — everything else in the frontmatter is ignored.
596
+ function parseRetirement(content) {
597
+ const fm = frontmatterBlock(content);
598
+ if (!fm) return {};
599
+ const out = {};
600
+ for (const line of fm.split(/\r?\n/)) {
601
+ const m = /^(stale|superseded_by):\s*(.*)$/.exec(line);
602
+ if (!m) continue;
603
+ const val = m[2].trim().replace(/^['"]|['"]$/g, '');
604
+ if (val && !YAML_FALSY.has(val.toLowerCase())) out[m[1]] = val;
605
+ }
606
+ return out;
607
+ }
608
+
609
+ // Resolve a candidate hit.file to the absolute path to OPEN, confined to the REAL wiki root — or null
610
+ // when it escapes (#26). wikiRelPath is a lenient substring join-key (an absolute or `..`-bearing path
611
+ // passes it), so it is NOT a read guard: a hit.file like `.wrxn/wiki/../../../etc/passwd` would slip it
612
+ // and `path.join(root, file)` would resolve OUTSIDE the wiki root (readFileSync also follows symlinks).
613
+ // Here we canonicalize and enforce a resolved-prefix check — the same discipline as lib/connect.cjs'
614
+ // `state:` confinement: the resolved path (and, when it exists, its symlink-realpath) MUST sit at or
615
+ // under <root>/.wrxn/wiki/. A non-string path, an escaping/absolute path, or a symlink whose target
616
+ // escapes → null (the caller treats it as unreadable/unflagged → kept, fail-open). A path that does not
617
+ // yet exist on disk stays confined lexically and is returned (the read then fails-open as a missing page).
618
+ function confinedWikiPath(root, file) {
619
+ if (typeof file !== 'string' || !file) return null;
620
+ const wikiRoot = path.resolve(root, WIKI_PREFIX); // <root>/.wrxn/wiki
621
+ const underWiki = (p) => p === wikiRoot || p.startsWith(wikiRoot + path.sep);
622
+ const abs = path.resolve(root, file);
623
+ if (!underWiki(abs)) return null; // lexical `..`/absolute escape → refused, never opened
624
+ let real;
625
+ try {
626
+ real = fs.realpathSync(abs); // follows symlinks — a link pointing outside is caught here
627
+ } catch {
628
+ return abs; // does not (yet) exist / unresolvable → lexically confined; read fails-open as missing
629
+ }
630
+ return underWiki(real) ? abs : null; // a symlink whose real target escapes the wiki root → refused
631
+ }
632
+
633
+ // Read the frontmatter of each prose candidate page into a view keyed by the EXACT path the hit carries
634
+ // (hit.file), so buildExclusion's predicate — which reads off hit.file — joins to it directly. The path
635
+ // is CONFINED to the real wiki root via confinedWikiPath: a `..`-escaping, absolute, or symlink-escaping
636
+ // hit.file is never opened (#26 — defense-in-depth, mirrors lib/connect.cjs' resolved-prefix discipline).
637
+ // A read fault, a parse fault, or a refused path all contribute {} (unflagged → kept). Returns {path: fm}.
638
+ function readExclusionView(root, hits) {
639
+ const view = {};
640
+ if (!root || !Array.isArray(hits)) return view;
641
+ for (const h of hits) {
642
+ const file = h && h.file;
643
+ if (typeof file !== 'string' || !file || view[file]) continue;
644
+ if (wikiRelPath(file) == null) continue; // not under the wiki root (no join key) → never opened
645
+ const abs = confinedWikiPath(root, file); // resolved-prefix confinement (the real read guard)
646
+ if (abs == null) { view[file] = {}; continue; } // escapes the wiki root → refused, treated as unflagged
647
+ let content;
648
+ try {
649
+ content = fs.readFileSync(abs, 'utf8');
650
+ } catch {
651
+ view[file] = {}; // unreadable (e.g. a since-deleted page) → unflagged, kept
652
+ continue;
653
+ }
654
+ try {
655
+ view[file] = parseRetirement(content);
656
+ } catch {
657
+ view[file] = {}; // any parse fault → unflagged, kept (fail-open)
658
+ }
659
+ }
660
+ return view;
661
+ }
662
+
663
+ // Build the exclude predicate for this candidate set from the page frontmatter on disk. Wholly fail-open:
664
+ // any fault yields a never-exclude predicate, so a broken read can only ever LEAVE RECALL UNCHANGED — it
665
+ // can never wrongly drop a live page or throw.
666
+ function diskExclusion(root, hits) {
667
+ try {
668
+ return buildExclusion(readExclusionView(root, hits));
669
+ } catch {
670
+ return () => false;
671
+ }
672
+ }
673
+
674
+ // One recon_find POST against the warm door → the hits array. Both arms (prompt-semantic and S4
675
+ // structural) share this single fetch path, so they hit the SAME door identically. THROWS on any fault
676
+ // (transport reject / non-200 / malformed body); each caller decides what a fault means — the semantic
677
+ // arm aborts recall (the existing contract), the structural arm swallows it (fail-open).
678
+ async function findHits(transport, port, query, timeoutMs) {
679
+ const resp = await (transport || httpTransport)({
680
+ port,
681
+ path: FIND_PATH,
682
+ body: { query, limit: FETCH_LIMIT },
683
+ timeoutMs: timeoutMs || TIMEOUT_MS,
684
+ });
685
+ if (!resp || resp.statusCode !== 200) throw new Error('recall door non-200');
686
+ const parsed = JSON.parse(resp.body); // throws on a malformed body
687
+ return Array.isArray(parsed.hits) ? parsed.hits : [];
688
+ }
689
+
690
+ // S4 structural arm: seed a query from this session's edited paths (.touched) and issue a SECOND
691
+ // recon_find for it. Empty .touched → empty seed → [] (the arm never fires — the no-op). The hits are
692
+ // returned RAW; the caller fuses them with the semantic arm and the shared pipeline then prose-filters,
693
+ // excludes, gates, and caps — so a code-seeded arm's code hits are dropped exactly as in the semantic arm.
694
+ async function structuralArm(root, sessionId, port, { transport, timeoutMs } = {}) {
695
+ try {
696
+ const query = buildStructuralQuery(readTouched(root, sessionId));
697
+ if (!query) return []; // no edits this session → no structural arm
698
+ return await findHits(transport, port, query, timeoutMs);
699
+ } catch {
700
+ return []; // any structural fault (read / fetch / non-200 / malformed) → empty arm; recall proceeds on the semantic arm
701
+ }
702
+ }
703
+
311
704
  // IO shell: discover the door, POST the prose query, gate the hits. Returns the block string or null.
312
705
  // `transport` is injected in tests; production uses httpTransport. Sends NO `type` (recon_find takes a
313
706
  // single NodeType, not an array) — prose scope is enforced by decideRecall's post-filter.
314
- async function recallFromDoor(root, prompt, { transport, timeoutMs, now } = {}) {
707
+ async function recallFromDoor(root, prompt, { transport, timeoutMs, now, sessionId, rewardMode } = {}) {
315
708
  const door = discoverEndpoint(root);
316
709
  if (!door) return null; // not warm → Abstain (silent)
317
710
  const query = String(prompt || '').trim().slice(0, MAX_QUERY_CHARS);
318
711
  if (!query) return null;
319
- let resp;
712
+ let semanticHits;
320
713
  try {
321
- resp = await (transport || httpTransport)({
322
- port: door.port,
323
- path: FIND_PATH,
324
- body: { query, limit: FETCH_LIMIT },
325
- timeoutMs: timeoutMs || TIMEOUT_MS,
326
- });
714
+ semanticHits = await findHits(transport, door.port, query, timeoutMs);
327
715
  } catch {
328
- return null; // timeout / connection refused / abort → silent
716
+ return null; // timeout / connection refused / abort / non-200 / malformed body → silent (existing contract)
329
717
  }
330
- if (!resp || resp.statusCode !== 200) return null;
331
- let parsed;
332
- try {
333
- parsed = JSON.parse(resp.body);
334
- } catch {
335
- return null; // malformed body silent
718
+ // S4 structural arm: a SECOND recon_find seeded by this session's edited paths (.touched), RRF-fused
719
+ // with the prompt-semantic arm BEFORE the exclusion / gate / reward / top-N. Fuse only when the arm has
720
+ // hits: empty .touched (or any structural fault) → no structural hits → the fused set IS the semantic
721
+ // set object, so recall output stays byte-identical to pre-S4 (the strict no-op guarantee).
722
+ const structuralHits = await structuralArm(root, sessionId, door.port, { transport, timeoutMs });
723
+ const hits = structuralHits.length ? rrfFuse(semanticHits, structuralHits) : semanticHits;
724
+ // SHADOW (shipped default): pass NO lookup → decideRecall is the identity (door order), byte-identical
725
+ // to pre-reward recall regardless of what sits in reward.json. LIVE (gate-flipped / test-forced): read
726
+ // the reward sidecar and re-rank the qualifying candidates by reward factor before the top-N cut.
727
+ const mode = rewardMode || SHIPPED_REWARD_MODE;
728
+ const rewardLookup = mode === 'live' ? readRewardLookup(root) : null;
729
+ // S1 exclusion: drop curation-retired (stale / superseded) candidates BEFORE the gate/rerank/top-N, by
730
+ // reading each candidate page's frontmatter from disk. Fail-open: a fault yields a never-exclude
731
+ // predicate (recall unchanged), and an unflagged corpus makes this the identity (no-op, AC-6).
732
+ const exclude = diskExclusion(root, hits);
733
+ const block = decideRecall(hits, rewardLookup, exclude);
734
+ // Side effects on a surfacing (both best-effort, never block): stamp access-recency for the pages we
735
+ // surfaced, and record this session's surfaced set in the per-session surfaced-log. Use the SAME lookup
736
+ // and exclusion so the recorded set matches the re-ranked, excluded top-N that was rendered.
737
+ if (block) {
738
+ const surfaced = qualifyingHits(hits, rewardLookup, exclude);
739
+ reinforce(root, surfaced, now);
740
+ surfacedLog(root, sessionId, surfaced);
336
741
  }
337
- const hits = Array.isArray(parsed.hits) ? parsed.hits : [];
338
- const block = decideRecall(hits);
339
- // Side effect: stamp access-recency for the pages we actually surfaced (best-effort, never blocks).
340
- if (block) reinforce(root, qualifyingHits(hits), now);
341
742
  return block;
342
743
  }
343
744
 
@@ -360,7 +761,7 @@ async function main() {
360
761
 
361
762
  let block = null;
362
763
  try {
363
- block = await recallFromDoor(root, prompt.trim());
764
+ block = await recallFromDoor(root, prompt.trim(), { sessionId: event.session_id });
364
765
  } catch {
365
766
  return emit({});
366
767
  }
@@ -376,8 +777,14 @@ if (require.main === module) {
376
777
  module.exports = {
377
778
  decideRecall,
378
779
  qualifyingHits,
780
+ rerankByReward,
379
781
  recallFromDoor,
782
+ readRewardLookup,
783
+ readExclusionView,
784
+ confinedWikiPath,
785
+ parseRetirement,
380
786
  reinforce,
787
+ surfacedLog,
381
788
  wikiRelPath,
382
789
  dayStamp,
383
790
  discoverEndpoint,
@@ -386,8 +793,14 @@ module.exports = {
386
793
  isProse,
387
794
  hasConsensus,
388
795
  qualifies,
796
+ buildExclusion,
797
+ resolveHead,
798
+ buildStructuralQuery,
799
+ rrfFuse,
800
+ readTouched,
389
801
  renderBlock,
390
802
  findInstallRoot,
391
803
  SEMANTIC_FLOOR,
392
804
  PROSE_TYPES,
805
+ SHIPPED_REWARD_MODE,
393
806
  };