@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.
- package/bin/wrxn.cjs +10 -1
- package/lib/executor.cjs +110 -3
- package/lib/install.cjs +13 -0
- package/lib/release.cjs +13 -1
- package/manifest.json +30 -0
- package/package.json +2 -2
- package/payload/.claude/hooks/emit-event.cjs +181 -0
- package/payload/.claude/hooks/prune.cjs +252 -0
- package/payload/.claude/hooks/recall-surface.cjs +465 -52
- package/payload/.claude/hooks/reward.cjs +244 -0
- package/payload/.claude/hooks/session-end-reward.cjs +265 -0
- package/payload/.claude/hooks/session-start.cjs +87 -2
- package/payload/.claude/hooks/sidecar.cjs +93 -0
- package/payload/.claude/hooks/synapse-engine.cjs +45 -3
- package/payload/.claude/hooks/wiki-lint.cjs +70 -1
- package/payload/.claude/settings.json +9 -2
- package/payload/.mcp.json +1 -1
- package/payload/.wrxn/dream.cjs +329 -5
- package/payload/.wrxn/events/.gitkeep +0 -0
- package/payload/.wrxn/harvest.cjs +159 -4
- package/payload/.wrxn/memory-synth.cjs +158 -31
|
@@ -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
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
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
|
-
|
|
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,
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
712
|
+
let semanticHits;
|
|
320
713
|
try {
|
|
321
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
};
|