@gcunharodrigues/wrxn 0.13.3 → 0.15.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 +10 -0
- package/lib/release.cjs +13 -1
- package/manifest.json +35 -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/.env.example +6 -0
- 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 +5 -4
- package/payload/.wrxn/memory.config.json +4 -4
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// WRXN shared rolling-prune for append-only jsonl logs (kernel #34 / C1) — the first retention policy
|
|
4
|
+
// in the system. The dream/sync/harvest consolidation trails (.wrxn/{dream,sync,harvest}/*.jsonl) are
|
|
5
|
+
// append-only and otherwise grow unbounded; this one mechanism (not four) bounds every such log by AGE
|
|
6
|
+
// and COUNT, wired into the session-end hook.
|
|
7
|
+
//
|
|
8
|
+
// SPLIT (mirrors the codebase: pure math + IO shell, like reward's deriveSignal/gitFactsSince):
|
|
9
|
+
// · retain(records, opts) — PURE, DETERMINISTIC: given parsed records in append order, return the
|
|
10
|
+
// survivors. The clock is INJECTED (opts.now) — there is no Date.now() in the core.
|
|
11
|
+
// · prune(dir, opts) — IO shell: read each *.jsonl file in `dir`, and IFF every non-empty line
|
|
12
|
+
// parses, rewrite it with retain()'s survivors. The clock defaults to Date.now() HERE, then flows
|
|
13
|
+
// into the pure core.
|
|
14
|
+
//
|
|
15
|
+
// CORRUPT-SAFE: a file with ANY unparseable line is left BYTE-INTACT (never deleted or truncated) — we
|
|
16
|
+
// never drop a line we cannot understand. SAFE NO-OP on an empty/missing dir. FAIL-OPEN: every fault is
|
|
17
|
+
// swallowed so a retention fault can never break session shutdown — it never throws.
|
|
18
|
+
//
|
|
19
|
+
// Self-contained: ships into installs alongside the hooks — node stdlib ONLY (fs / path), NO kernel-lib
|
|
20
|
+
// or recon import (exactly like the sidecar helper it sits beside).
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
// Retention bounds — NAMED CONSTANTS, not magic numbers. These trails are operational breadcrumbs for
|
|
26
|
+
// memory consolidation; the DURABLE knowledge lives in the wiki pages, so old breadcrumbs decay fast.
|
|
27
|
+
const MAX_AGE_DAYS = 90; // one quarter of history — the primary bound; older audit entries have little value
|
|
28
|
+
const MAX_RECORDS = 500; // hard per-file ceiling — the safety net against pathological (runaway-append) growth
|
|
29
|
+
const MS_PER_DAY = 86400000;
|
|
30
|
+
|
|
31
|
+
// The install-relative log dirs the session-end hook sweeps for WITHIN-FILE record bounding. `.wrxn/events`
|
|
32
|
+
// (the S2/#35 metadata-grade event source) is included so a long session's per-session *.jsonl is bounded
|
|
33
|
+
// by age + count like the rest; whole STALE event FILES are GC'd separately by pruneFiles (below).
|
|
34
|
+
const LOG_DIRS = ['.wrxn/dream', '.wrxn/sync', '.wrxn/harvest', '.wrxn/events'];
|
|
35
|
+
|
|
36
|
+
// Coerce an injected clock (ms-epoch number or a Date) to ms. Anything else → NaN (no clock).
|
|
37
|
+
function clockMs(now) {
|
|
38
|
+
if (Number.isFinite(now)) return now;
|
|
39
|
+
if (now instanceof Date) return now.getTime();
|
|
40
|
+
return NaN;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* PURE: given parsed records in append (chronological) order, return the survivors.
|
|
45
|
+
* AGE-DROP: a record whose `ts` parses to a time older than `now - maxAgeDays` is dropped. A record with
|
|
46
|
+
* NO datable `ts` is NEVER aged out (conservative — we never drop what we cannot date). The clock is
|
|
47
|
+
* INJECTED via `now` (ms-epoch or Date); there is no Date.now() in this core. With no clock, age is skipped.
|
|
48
|
+
* COUNT-TRIM oldest-first: of what remains, keep the newest `maxRecords` (append order ⇒ newest = tail).
|
|
49
|
+
* @param {object[]} records
|
|
50
|
+
* @param {{maxAgeDays?:number, maxRecords?:number, now?:number|Date}} [opts]
|
|
51
|
+
* @returns {object[]}
|
|
52
|
+
*/
|
|
53
|
+
function retain(records, { maxAgeDays = MAX_AGE_DAYS, maxRecords = MAX_RECORDS, now } = {}) {
|
|
54
|
+
let kept = Array.isArray(records) ? records : [];
|
|
55
|
+
const clock = clockMs(now);
|
|
56
|
+
if (Number.isFinite(maxAgeDays) && maxAgeDays > 0 && Number.isFinite(clock)) {
|
|
57
|
+
const cutoff = clock - maxAgeDays * MS_PER_DAY;
|
|
58
|
+
kept = kept.filter((r) => {
|
|
59
|
+
const t = r && typeof r === 'object' ? Date.parse(r.ts) : NaN;
|
|
60
|
+
return Number.isNaN(t) ? true : t >= cutoff; // undatable → keep; datable → keep iff within window
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (Number.isFinite(maxRecords) && maxRecords >= 0 && kept.length > maxRecords) {
|
|
64
|
+
kept = kept.slice(kept.length - maxRecords); // drop from the front (oldest) — keep the newest N
|
|
65
|
+
}
|
|
66
|
+
return kept;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Monotonic disambiguator for the atomic-rewrite temp name — no wall clock needed in the IO shell.
|
|
70
|
+
let tmpSeq = 0;
|
|
71
|
+
|
|
72
|
+
// Prune one jsonl file. Returns true iff it was rewritten. CORRUPT-SAFE: if any non-empty line fails to
|
|
73
|
+
// parse, return false and leave the file byte-intact (never truncate on parse failure). A re-serialized
|
|
74
|
+
// survivor is byte-faithful to a compact jsonl line (JSON.stringify(JSON.parse(x)) preserves key order).
|
|
75
|
+
function pruneFile(file, opts) {
|
|
76
|
+
let raw;
|
|
77
|
+
try {
|
|
78
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
79
|
+
} catch {
|
|
80
|
+
return false; // unreadable (e.g. a directory at this path) → leave it
|
|
81
|
+
}
|
|
82
|
+
const records = [];
|
|
83
|
+
for (const line of raw.split('\n')) {
|
|
84
|
+
const s = line.trim();
|
|
85
|
+
if (!s) continue; // blank / trailing newline — not a record, not corruption
|
|
86
|
+
let rec;
|
|
87
|
+
try {
|
|
88
|
+
rec = JSON.parse(s);
|
|
89
|
+
} catch {
|
|
90
|
+
return false; // a corrupt line → leave the WHOLE file byte-intact
|
|
91
|
+
}
|
|
92
|
+
records.push(rec);
|
|
93
|
+
}
|
|
94
|
+
const survivors = retain(records, opts);
|
|
95
|
+
if (survivors.length === records.length) return false; // nothing dropped → coalesced no-op (no rewrite)
|
|
96
|
+
const body = survivors.length ? survivors.map((r) => JSON.stringify(r)).join('\n') + '\n' : '';
|
|
97
|
+
// ATOMIC REWRITE: write survivors to a sibling temp, then rename it OVER the original. A torn write
|
|
98
|
+
// (crash / ENOSPC) can only damage the temp — the live log is replaced in ONE atomic step, or not at
|
|
99
|
+
// all. On any failure the original survives byte-intact (fail-open) and the partial temp is removed.
|
|
100
|
+
const tmpFile = `${file}.${process.pid}.${tmpSeq++}.tmp`; // same dir ⇒ rename is atomic (one filesystem)
|
|
101
|
+
try {
|
|
102
|
+
fs.writeFileSync(tmpFile, body);
|
|
103
|
+
fs.renameSync(tmpFile, file);
|
|
104
|
+
} catch {
|
|
105
|
+
try { fs.unlinkSync(tmpFile); } catch { /* nothing to clean up */ }
|
|
106
|
+
return false; // rewrite failed → leave the original untouched
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* IO shell: prune every `*.jsonl` log file directly under `dir`. The clock defaults to Date.now() here,
|
|
113
|
+
* then flows into the pure core. SAFE NO-OP on a missing/unreadable/empty dir. FAIL-OPEN: never throws —
|
|
114
|
+
* one bad file never aborts the sweep, and any unexpected fault is swallowed.
|
|
115
|
+
* @param {string} dir
|
|
116
|
+
* @param {{maxAgeDays?:number, maxRecords?:number, now?:number}} [opts]
|
|
117
|
+
* @returns {{scanned:number, rewritten:number}}
|
|
118
|
+
*/
|
|
119
|
+
function prune(dir, opts) {
|
|
120
|
+
const out = { scanned: 0, rewritten: 0 };
|
|
121
|
+
try {
|
|
122
|
+
const o = opts && typeof opts === 'object' ? opts : {};
|
|
123
|
+
// Resolve the clock HERE (the shell) — defaulting to Date.now() — then inject it into the pure core.
|
|
124
|
+
const now = Number.isFinite(clockMs(o.now)) ? clockMs(o.now) : Date.now();
|
|
125
|
+
const ropts = { maxAgeDays: o.maxAgeDays, maxRecords: o.maxRecords, now };
|
|
126
|
+
// SYMLINK-SAFE (dir): refuse to descend into anything that is not a REAL directory. lstat does NOT
|
|
127
|
+
// follow the link, so a symlinked log path (e.g. .wrxn/dream → /outside) is a no-op — never swept.
|
|
128
|
+
let dirStat;
|
|
129
|
+
try {
|
|
130
|
+
dirStat = fs.lstatSync(dir);
|
|
131
|
+
} catch {
|
|
132
|
+
return out; // missing / unreadable dir → no-op
|
|
133
|
+
}
|
|
134
|
+
if (!dirStat.isDirectory()) return out; // a symlink (or non-dir) at the log path → do not descend
|
|
135
|
+
let names;
|
|
136
|
+
try {
|
|
137
|
+
names = fs.readdirSync(dir);
|
|
138
|
+
} catch {
|
|
139
|
+
return out; // unreadable dir → no-op
|
|
140
|
+
}
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
143
|
+
try {
|
|
144
|
+
const file = path.join(dir, name);
|
|
145
|
+
// SYMLINK-SAFE (entry): lstat does NOT follow the link, so we process ONLY a regular file. A
|
|
146
|
+
// planted *.jsonl symlink (or a sub-dir / special file) is skipped — this destructive rewrite can
|
|
147
|
+
// never follow a link out of the log dir to trim a file outside it.
|
|
148
|
+
if (!fs.lstatSync(file).isFile()) continue;
|
|
149
|
+
out.scanned++;
|
|
150
|
+
if (pruneFile(file, ropts)) out.rewritten++;
|
|
151
|
+
} catch {
|
|
152
|
+
/* one bad file never aborts the sweep */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
/* fail-open: retention must never throw into session shutdown */
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── WHOLE-FILE GC (S2 / #35) ─────────────────────────────────────────────────────────
|
|
162
|
+
//
|
|
163
|
+
// The event source writes one file PER SESSION, so the FILES accumulate even though retain bounds the
|
|
164
|
+
// records WITHIN each file. pruneFiles GCs whole *.jsonl files; retainFiles is its PURE core (same split
|
|
165
|
+
// as retain/prune: the clock is INJECTED, no Date.now() here).
|
|
166
|
+
|
|
167
|
+
const MAX_FILES = 200; // per-dir whole-file ceiling — the safety net (mirrors MAX_RECORDS) against unbounded per-session files
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* PURE: given file descriptors {name, mtimeMs, size}, return the survivors (the files to KEEP).
|
|
171
|
+
* EMPTY-DROP: a size-0 file (e.g. one the within-file prune drained to nothing) is dropped — it carries
|
|
172
|
+
* no records. AGE-DROP: a file whose mtime is older than `now - maxAgeDays` is dropped; a file with NO
|
|
173
|
+
* datable mtime is NEVER aged out (conservative). The clock is INJECTED via `now`; with no clock, age
|
|
174
|
+
* is skipped. COUNT-TRIM: of what remains, keep the newest `maxFiles` by mtime.
|
|
175
|
+
* @param {{name:string, mtimeMs:number, size:number}[]} files
|
|
176
|
+
* @param {{maxAgeDays?:number, maxFiles?:number, now?:number|Date}} [opts]
|
|
177
|
+
* @returns {object[]}
|
|
178
|
+
*/
|
|
179
|
+
function retainFiles(files, { maxAgeDays = MAX_AGE_DAYS, maxFiles = MAX_FILES, now } = {}) {
|
|
180
|
+
let kept = (Array.isArray(files) ? files : []).filter((f) => f && typeof f.name === 'string');
|
|
181
|
+
kept = kept.filter((f) => !(Number.isFinite(f.size) && f.size === 0)); // empty file → no records → drop
|
|
182
|
+
const clock = clockMs(now);
|
|
183
|
+
if (Number.isFinite(maxAgeDays) && maxAgeDays > 0 && Number.isFinite(clock)) {
|
|
184
|
+
const cutoff = clock - maxAgeDays * MS_PER_DAY;
|
|
185
|
+
kept = kept.filter((f) => {
|
|
186
|
+
const t = Number(f.mtimeMs);
|
|
187
|
+
return Number.isFinite(t) ? t >= cutoff : true; // undatable mtime → keep
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (Number.isFinite(maxFiles) && maxFiles >= 0 && kept.length > maxFiles) {
|
|
191
|
+
kept = [...kept].sort((a, b) => Number(b.mtimeMs) - Number(a.mtimeMs)).slice(0, maxFiles); // newest-first, keep N
|
|
192
|
+
}
|
|
193
|
+
return kept;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* IO shell: delete whole stale `*.jsonl` files directly under `dir` (the non-survivors of retainFiles).
|
|
198
|
+
* The clock defaults to Date.now() here, then flows into the pure core. SYMLINK-SAFE exactly like prune:
|
|
199
|
+
* lstat refuses a symlinked dir (no descend) and processes ONLY regular files (a planted *.jsonl symlink
|
|
200
|
+
* is skipped, never deleted/followed). SAFE NO-OP on a missing/empty dir. FAIL-OPEN: never throws — one
|
|
201
|
+
* bad entry never aborts the sweep.
|
|
202
|
+
* @param {string} dir
|
|
203
|
+
* @param {{maxAgeDays?:number, maxFiles?:number, now?:number}} [opts]
|
|
204
|
+
* @returns {{scanned:number, deleted:number}}
|
|
205
|
+
*/
|
|
206
|
+
function pruneFiles(dir, opts) {
|
|
207
|
+
const out = { scanned: 0, deleted: 0 };
|
|
208
|
+
try {
|
|
209
|
+
const o = opts && typeof opts === 'object' ? opts : {};
|
|
210
|
+
const now = Number.isFinite(clockMs(o.now)) ? clockMs(o.now) : Date.now();
|
|
211
|
+
let dirStat;
|
|
212
|
+
try {
|
|
213
|
+
dirStat = fs.lstatSync(dir);
|
|
214
|
+
} catch {
|
|
215
|
+
return out; // missing / unreadable dir → no-op
|
|
216
|
+
}
|
|
217
|
+
if (!dirStat.isDirectory()) return out; // a symlink (or non-dir) at the event-dir path → do not descend
|
|
218
|
+
let names;
|
|
219
|
+
try {
|
|
220
|
+
names = fs.readdirSync(dir);
|
|
221
|
+
} catch {
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
const descriptors = [];
|
|
225
|
+
for (const name of names) {
|
|
226
|
+
if (!name.endsWith('.jsonl')) continue; // never touch the .gitkeep sentinel or anything non-jsonl
|
|
227
|
+
try {
|
|
228
|
+
const st = fs.lstatSync(path.join(dir, name)); // lstat → never follow a symlink
|
|
229
|
+
if (!st.isFile()) continue; // a planted symlink / sub-dir / special file → skip
|
|
230
|
+
out.scanned++;
|
|
231
|
+
descriptors.push({ name, mtimeMs: st.mtimeMs, size: st.size });
|
|
232
|
+
} catch {
|
|
233
|
+
/* one bad entry never aborts the sweep */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const survivors = new Set(retainFiles(descriptors, { maxAgeDays: o.maxAgeDays, maxFiles: o.maxFiles, now }).map((f) => f.name));
|
|
237
|
+
for (const d of descriptors) {
|
|
238
|
+
if (survivors.has(d.name)) continue;
|
|
239
|
+
try {
|
|
240
|
+
fs.unlinkSync(path.join(dir, d.name));
|
|
241
|
+
out.deleted++;
|
|
242
|
+
} catch {
|
|
243
|
+
/* best-effort: a failed unlink never aborts the sweep */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
/* fail-open: whole-file GC must never throw into session shutdown */
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { prune, pruneFiles, retain, retainFiles, MAX_AGE_DAYS, MAX_RECORDS, MAX_FILES, LOG_DIRS };
|