@gcunharodrigues/wrxn 0.14.0 → 0.16.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/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcunharodrigues/wrxn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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"
|
|
@@ -201,6 +201,99 @@ function holdForHandoff({ root, capMs = HOLD_CAP_MS, now = Date.now, sleep } = {
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
// ── baton-staleness guard (#51) ─────────────────────────────────────────────────
|
|
205
|
+
// The handoff baton is surfaced as the resume point with no health check, so a baton frozen by a failed
|
|
206
|
+
// SessionEnd synth (no-engine / error) is shown as current — silently (real incident 2026-06-22→23: the
|
|
207
|
+
// baton froze ~20h while .synth.log logged no-engine from later sessions). This guard reads the synth log +
|
|
208
|
+
// the baton mtime and, when the latest synth attempt failed and it is NOT a same-session double-spawn (the
|
|
209
|
+
// session that wrote the baton failing again right after — #45), surfaces a visible warning. The DECISION is
|
|
210
|
+
// pure (no IO, no clock — the holdDecision idiom); the hook does the IO and formats the age where it holds
|
|
211
|
+
// the clock. NO polling/sleeping — one file read + one stat.
|
|
212
|
+
|
|
213
|
+
const SYNTH_LOG_REL = ['.wrxn', 'continuity', '.synth.log'];
|
|
214
|
+
|
|
215
|
+
// A synth row is a FAILURE iff its outcome is `no-engine` or an `error…` string (`wrote`/`trivial` = healthy).
|
|
216
|
+
function isFailure(outcome) {
|
|
217
|
+
const o = String(outcome || '');
|
|
218
|
+
return o === 'no-engine' || /^error/.test(o);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Parse the tab-separated synth log into chronological rows { timestampMs, sessionId, outcome }, dropping
|
|
222
|
+
// any line we cannot resolve (malformed / unparseable timestamp) — "newest-resolvable last". The sessionId
|
|
223
|
+
// (field 1, `-` when absent) is what discriminates a double-spawn from genuine rot. PURE + total, never throws.
|
|
224
|
+
function parseSynthLog(text) {
|
|
225
|
+
const rows = [];
|
|
226
|
+
for (const line of String(text || '').split('\n')) {
|
|
227
|
+
if (!line.trim()) continue;
|
|
228
|
+
const f = line.split('\t');
|
|
229
|
+
if (f.length < 6) continue; // not a full outcome row → skip (fail-open)
|
|
230
|
+
const ts = Date.parse(f[0]);
|
|
231
|
+
if (!Number.isFinite(ts)) continue; // unresolvable timestamp → drop
|
|
232
|
+
rows.push({ timestampMs: ts, sessionId: f[1], outcome: f.slice(5).join('\t').trim() });
|
|
233
|
+
}
|
|
234
|
+
return rows;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Human age for the warning line (computed where the clock is, so batonStaleness stays clock-free). PURE.
|
|
238
|
+
function formatAge(ms) {
|
|
239
|
+
const m = Math.max(0, Math.round(Number(ms) / 60000));
|
|
240
|
+
if (m < 60) return `${m}m`;
|
|
241
|
+
const h = Math.floor(m / 60);
|
|
242
|
+
if (h < 24) return `${h}h`;
|
|
243
|
+
const d = Math.floor(h / 24);
|
|
244
|
+
const rem = h % 24;
|
|
245
|
+
return rem ? `${d}d ${rem}h` : `${d}d`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Sanitize the echoed synth outcome before it is interpolated into the orientation block (sec-F1). An
|
|
249
|
+
// `error: …` row carries a FREE-FORM message, so a crafted/corrupt log row could smuggle control chars or a
|
|
250
|
+
// forged `</wrxn-orientation>` close tag into additionalContext. Strip control chars (incl CR/LF/tab), drop
|
|
251
|
+
// angle brackets so no tag can be forged, and cap the length. PURE + total. (Mirrors memory-synth's
|
|
252
|
+
// sanitizeLogField idiom — each install-only hook is self-contained, node stdlib only.)
|
|
253
|
+
function sanitizeOutcome(s) {
|
|
254
|
+
return String(s == null ? '' : s)
|
|
255
|
+
// eslint-disable-next-line no-control-regex
|
|
256
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
257
|
+
.replace(/[<>]/g, '')
|
|
258
|
+
.slice(0, 120);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Pure baton-staleness decision (#51). Given the baton's last-write time and the synth-log rows
|
|
263
|
+
* (chronological, newest-resolvable last, each carrying its sessionId), return the FAILING outcome to name
|
|
264
|
+
* in the warning, or null when the baton is healthy/fresh. Warn IFF the newest row failed (`no-engine` |
|
|
265
|
+
* `error…`) AND the baton predates that newest row AND it is NOT a same-session double-spawn.
|
|
266
|
+
*
|
|
267
|
+
* Double-spawn vs genuine-rot is discriminated by SESSION ID, NOT by timestamp. The session that wrote the
|
|
268
|
+
* CURRENT baton is the session of the MOST-RECENT `wrote` row (every successful synth logs a `wrote` row in
|
|
269
|
+
* runHandoff's finally, a few ms AFTER it set the baton mtime — so that row's timestamp is ALWAYS ≥ the baton
|
|
270
|
+
* mtime, and a timestamp test can never tell the two apart). The #45 double-spawn logs `wrote` then a spurious
|
|
271
|
+
* `no-engine` ~2s later under the SAME session → suppress. A genuine freeze has later failures from DIFFERENT
|
|
272
|
+
* sessions (real incident 2026-06-22→23: baton written by 6898c0a9, then 7b69b97c / 39e5754b no-engine hours
|
|
273
|
+
* later) → warn. A missing/`-` session id on either side cannot confirm a double-spawn → default to warn (a
|
|
274
|
+
* loud false-positive beats silent rot). PURE + total: no IO, no clock, never throws.
|
|
275
|
+
* @param {{ batonMtimeMs:number, rows:Array<{timestampMs:number, sessionId?:string, outcome:string}> }} [input]
|
|
276
|
+
* @returns {string|null} the failing outcome to name, or null when healthy
|
|
277
|
+
*/
|
|
278
|
+
function batonStaleness({ batonMtimeMs, rows } = {}) {
|
|
279
|
+
if (!Array.isArray(rows) || rows.length === 0) return null; // no rows → nothing to judge
|
|
280
|
+
if (typeof batonMtimeMs !== 'number' || !Number.isFinite(batonMtimeMs)) return null; // no usable mtime
|
|
281
|
+
const newest = rows[rows.length - 1];
|
|
282
|
+
if (!newest || !isFailure(newest.outcome)) return null; // latest run healthy → fresh
|
|
283
|
+
if (Number.isFinite(newest.timestampMs) && newest.timestampMs <= batonMtimeMs) return null; // baton at/after newest → fresh
|
|
284
|
+
// The session that wrote the CURRENT baton = the session of the most-recent `wrote` row.
|
|
285
|
+
let wroteSession;
|
|
286
|
+
for (const r of rows) {
|
|
287
|
+
if (r && r.outcome === 'wrote') wroteSession = r.sessionId;
|
|
288
|
+
}
|
|
289
|
+
const sameSession =
|
|
290
|
+
!!wroteSession && wroteSession !== '-' &&
|
|
291
|
+
!!newest.sessionId && newest.sessionId !== '-' &&
|
|
292
|
+
wroteSession === newest.sessionId;
|
|
293
|
+
if (sameSession) return null; // same-session double-spawn → not stale
|
|
294
|
+
return newest.outcome; // genuine rot (or unconfirmable) → name the failing outcome
|
|
295
|
+
}
|
|
296
|
+
|
|
204
297
|
function main() {
|
|
205
298
|
let consumed = '';
|
|
206
299
|
try {
|
|
@@ -241,6 +334,24 @@ function main() {
|
|
|
241
334
|
const baton = readBaton(root);
|
|
242
335
|
if (baton && baton.trim()) {
|
|
243
336
|
parts.push('', 'Resume — deliberate handoff baton (.wrxn/continuity/latest.md):', baton.trim());
|
|
337
|
+
// #51: warn if the latest SessionEnd synth failed and no successful write has refreshed the baton since
|
|
338
|
+
// — a frozen baton must not be surfaced as current silently. Fail-open: any read/stat/parse fault → no
|
|
339
|
+
// warning, never a throw. Pure file reads + a stat only (no polling/sleeping beyond the hold cap above).
|
|
340
|
+
try {
|
|
341
|
+
const rows = parseSynthLog(readFileOr(path.join(root, ...SYNTH_LOG_REL), ''));
|
|
342
|
+
const batonMtimeMs = fs.statSync(path.join(root, '.wrxn', 'continuity', 'latest.md')).mtimeMs;
|
|
343
|
+
const failing = batonStaleness({ batonMtimeMs, rows });
|
|
344
|
+
if (failing) {
|
|
345
|
+
const age = formatAge(Date.now() - batonMtimeMs);
|
|
346
|
+
const outcome = sanitizeOutcome(failing); // sec-F1: never echo a raw log field into the orientation
|
|
347
|
+
parts.push(
|
|
348
|
+
'',
|
|
349
|
+
`WARNING — stale handoff baton: the latest memory-synth run failed ("${outcome}") and the baton has not been refreshed for ${age}. It may not reflect the last session — see .wrxn/continuity/.synth.log.`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
/* fail-open: a staleness-check fault must never block orientation (#51 AC3) */
|
|
354
|
+
}
|
|
244
355
|
} else {
|
|
245
356
|
parts.push('', 'Resume — no prior handoff.');
|
|
246
357
|
}
|
|
@@ -261,4 +372,4 @@ if (require.main === module) {
|
|
|
261
372
|
}
|
|
262
373
|
}
|
|
263
374
|
|
|
264
|
-
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL };
|
|
375
|
+
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# WRXN memory-synth — secret config. Copy this file to `.env` and fill in the value below.
|
|
2
|
+
# `.env` is gitignored (the secret is never committed); this `.env.example` is the tracked template.
|
|
3
|
+
#
|
|
4
|
+
# GEMINI_API_KEY powers the `gemini` engine for handoff + dream synthesis — the shipped DEFAULT
|
|
5
|
+
# primary for both. Without it, synth falls back to `claude`, which uses your CLI auth (no key needed).
|
|
6
|
+
GEMINI_API_KEY=
|
package/payload/.wrxn/dream.cjs
CHANGED
|
@@ -390,7 +390,11 @@ const NEGATIVE_FILTERS = [
|
|
|
390
390
|
// a transient environment / setup failure — not a durable property of the system. (The bare adjectives
|
|
391
391
|
// `transient`/`intermittent` are intentionally NOT here: they false-positive on DI-lifetime decisions
|
|
392
392
|
// like "services are registered transient" — the concrete error codes below carry the failure intent.)
|
|
393
|
-
|
|
393
|
+
// SAME discipline for rate-limiting: the bare noun "rate limit" / adjective "rate-limited" is NOT matched
|
|
394
|
+
// (it false-positives on rate limiting AS A FEATURE — a token-bucket decision, a "rate-limited tier").
|
|
395
|
+
// Only the ERROR sense matches: a passive/verb "got rate-limited", a transitive "rate-limited us", or a
|
|
396
|
+
// "rate-limit(ed) error" / "rate limit exceeded" cue — the transient war-story, never the design.
|
|
397
|
+
{ reason: 'negative_filter_transient_failure', re: /\b(econnrefused|enoent|eaddrinuse|etimedout|connection refused|connection reset|timed out|time-?out|flak(e|y|ey)|(?:got|get|getting|gets|was|were|been|being|keeps?|kept|repeatedly) rate[- ]?limited|rate[- ]?limited (?:us|me|them|by|again)|rate[- ]?limit(?:ed|ing)? (?:error|errors|exceeded)|http 5\d\d|50[234]|port (already )?in use|address already in use|network (error|issue|glitch)|dns (error|failure))\b/ },
|
|
394
398
|
// a smoke / sanity / happy-path RESULT — proves nothing durable. Gated on a result word so a forward
|
|
395
399
|
// decision ("we adopt smoke tests", "the happy path must stay fast") is NOT a false positive.
|
|
396
400
|
{ reason: 'negative_filter_smoke_test', re: /\bhello[- ]?world\b|\b(smoke[- ]?tests?|sanity[- ]?checks?|happy path)\s+(pass(ed|es|ing)?|ran|run|succeed(ed|s)?|works?|worked|green)\b/ },
|
|
@@ -25,12 +25,13 @@ const https = require('https');
|
|
|
25
25
|
const { spawnSync } = require('child_process');
|
|
26
26
|
|
|
27
27
|
// ── default tiering (PRD) ──────────────────────────────────────────────────────
|
|
28
|
-
// handoff + dream default to
|
|
28
|
+
// handoff + dream default to gemini/gemini-3.1-flash-lite, falling back to claude/claude-sonnet-4-6.
|
|
29
29
|
// The seeded memory.config.json is this object serialized; an operator edits it and `wrxn update`
|
|
30
|
-
// preserves it (seeded class).
|
|
30
|
+
// preserves it (seeded class). Claude stays the fallback so a keyless fresh install still writes a
|
|
31
|
+
// baton via the operator's CLI auth (no GEMINI_API_KEY → gemini gated → claude carries it).
|
|
31
32
|
const DEFAULT_TASK = {
|
|
32
|
-
primary: { engine: '
|
|
33
|
-
fallback: { engine: '
|
|
33
|
+
primary: { engine: 'gemini', model: 'gemini-3.1-flash-lite' },
|
|
34
|
+
fallback: { engine: 'claude', model: 'claude-sonnet-4-6' },
|
|
34
35
|
};
|
|
35
36
|
const DEFAULTS = {
|
|
36
37
|
tasks: {
|
|
@@ -389,10 +390,13 @@ function defaultSleep(ms) {
|
|
|
389
390
|
* Run a single engine through the invoker; return `{ text, attempts }` (text null when no output). A
|
|
390
391
|
* `gemini` engine with no API key fails WITHOUT calling the invoker (no key → no request, attempts 0). A
|
|
391
392
|
* transient `ok:false` is retried up to ENGINE_ATTEMPTS with a fixed backoff via the injected sleep; the
|
|
392
|
-
* first
|
|
393
|
+
* first text that is non-empty AND passes the optional task `validate` predicate wins. A non-empty text
|
|
394
|
+
* that FAILS `validate` (unusable output — e.g. dream prose the gate can't parse) is a failed attempt →
|
|
395
|
+
* retried, then null, so synthesizeDetailed advances to the fallback. No validator → any non-empty wins.
|
|
396
|
+
* An invoker that throws is caught → counts as a failed attempt, then null.
|
|
393
397
|
* @returns {Promise<{ text: string|null, attempts: number }>}
|
|
394
398
|
*/
|
|
395
|
-
async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep }) {
|
|
399
|
+
async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep, validate }) {
|
|
396
400
|
if (!engine || !engine.engine) return { text: null, attempts: 0 };
|
|
397
401
|
let spec;
|
|
398
402
|
if (engine.engine === 'claude') {
|
|
@@ -414,12 +418,30 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
|
|
|
414
418
|
} catch {
|
|
415
419
|
text = null; // an invoker throw is a transient failure for this attempt (degrade, never throw).
|
|
416
420
|
}
|
|
417
|
-
|
|
421
|
+
// success = non-empty AND task-usable (validate). No validator → permissive: any non-empty text wins.
|
|
422
|
+
if (text && (!validate || validate(text))) return { text, attempts };
|
|
418
423
|
if (i < ENGINE_ATTEMPTS - 1) sleep(ENGINE_BACKOFF_MS); // back off before the next attempt (not after the last). sleep is synchronous (Atomics.wait / injected no-op) — no await.
|
|
419
424
|
}
|
|
420
425
|
return { text: null, attempts };
|
|
421
426
|
}
|
|
422
427
|
|
|
428
|
+
// ── per-task engine-success validators (synth-robustness #50) ───────────────────
|
|
429
|
+
// An engine attempt succeeds only when its text is non-empty AND passes the task's validator, so an engine
|
|
430
|
+
// that returns non-empty-but-UNUSABLE output exhausts its retries and synthesizeDetailed advances to the
|
|
431
|
+
// fallback — instead of the unusable primary "winning" while the downstream gate parses zero proposals and
|
|
432
|
+
// writes nothing. A task with NO registered validator is permissive (any non-empty text wins, exactly as
|
|
433
|
+
// before), so every existing caller is preserved.
|
|
434
|
+
const TASK_VALIDATORS = {
|
|
435
|
+
// dream output is usable only if the gate (parseProposals) reads ≥1 proposal from it, OR it is an explicit
|
|
436
|
+
// abstain — a deliberate "nothing to record" is a valid answer, NOT a reason to churn through the fallback.
|
|
437
|
+
dream: (text) => parseProposals(text).length > 0 || /abstain/i.test(text),
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// Resolve a task's engine-success validator; an unregistered task is permissive (any non-empty text wins).
|
|
441
|
+
function validatorFor(task) {
|
|
442
|
+
return TASK_VALIDATORS[task] || (() => true);
|
|
443
|
+
}
|
|
444
|
+
|
|
423
445
|
/**
|
|
424
446
|
* Synthesize text for `task` from `prompt` + `blob`, resolving the engine per task (primary → fallback),
|
|
425
447
|
* also reporting WHICH engine produced the text and HOW MANY attempts the producing engine spent (the
|
|
@@ -430,9 +452,10 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
|
|
|
430
452
|
*/
|
|
431
453
|
async function synthesizeDetailed({ task, prompt, blob, config, apiKey, invoke = defaultInvoke, sleep }) {
|
|
432
454
|
const { primary, fallback } = resolveTask(config || DEFAULTS, task);
|
|
455
|
+
const validate = validatorFor(task); // task-aware success: dream needs parseable proposals; handoff is permissive.
|
|
433
456
|
let attempts = 0;
|
|
434
457
|
for (const engine of [primary, fallback]) {
|
|
435
|
-
const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep });
|
|
458
|
+
const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep, validate });
|
|
436
459
|
attempts += r.attempts;
|
|
437
460
|
if (r.text) return { text: r.text, engine: engine && engine.engine, attempts };
|
|
438
461
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"tasks": {
|
|
3
3
|
"handoff": {
|
|
4
|
-
"primary": { "engine": "
|
|
5
|
-
"fallback": { "engine": "
|
|
4
|
+
"primary": { "engine": "gemini", "model": "gemini-3.1-flash-lite" },
|
|
5
|
+
"fallback": { "engine": "claude", "model": "claude-sonnet-4-6" }
|
|
6
6
|
},
|
|
7
7
|
"dream": {
|
|
8
|
-
"primary": { "engine": "
|
|
9
|
-
"fallback": { "engine": "
|
|
8
|
+
"primary": { "engine": "gemini", "model": "gemini-3.1-flash-lite" },
|
|
9
|
+
"fallback": { "engine": "claude", "model": "claude-sonnet-4-6" }
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
}
|