@gcunharodrigues/wrxn 0.15.0 → 0.17.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/lib/install.cjs
CHANGED
|
@@ -99,6 +99,9 @@ function init(opts) {
|
|
|
99
99
|
// the memory-synth writes one outcome line per synth run to `.wrxn/continuity/.synth.log` (synth-handoff-fix-01)
|
|
100
100
|
// — install runtime state (never shipped in the payload), so keep it out of version control.
|
|
101
101
|
ensureGitignoreLine(target, '.wrxn/continuity/.synth.log');
|
|
102
|
+
// memory-synth-spawn claims each session with a persistent `.wrxn/continuity/.spawned-<id>` marker (#45) —
|
|
103
|
+
// install runtime state (never shipped), so keep the family out of version control like the synth log.
|
|
104
|
+
ensureGitignoreLine(target, '.wrxn/continuity/.spawned-*');
|
|
102
105
|
// the memory-synth gemini fallback reads GEMINI_API_KEY from `.env` — ignore it so the secret is
|
|
103
106
|
// never committed (the synth's doc-comment calls it the install's gitignored `.env`).
|
|
104
107
|
ensureGitignoreLine(target, '.env');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcunharodrigues/wrxn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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"
|
|
@@ -25,6 +25,19 @@ const { spawn } = require('child_process');
|
|
|
25
25
|
|
|
26
26
|
const SENTINEL = 'WRXN_MEMORY_SYNTH'; // recursion guard (mirrors memory-synth.cjs's SENTINEL).
|
|
27
27
|
|
|
28
|
+
// safeId — canonicalize a session id used as a FILESYSTEM PATH component (#45): lowercase, collapse every
|
|
29
|
+
// non-alnum run to '-', trim, cap length. A raw session id concatenated into a path is a traversal surface;
|
|
30
|
+
// this keeps the once-per-session marker INSIDE .wrxn/continuity. REPLICATED byte-identically from
|
|
31
|
+
// session-start.cjs (and code-intel-push / the reward shell) — each install-only hook is self-contained
|
|
32
|
+
// (node stdlib only, no shared import).
|
|
33
|
+
function safeId(sid) {
|
|
34
|
+
return String(sid || 'session')
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
37
|
+
.replace(/^-+|-+$/g, '')
|
|
38
|
+
.slice(0, 48) || 'session';
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
function findInstallRoot(startDir) {
|
|
29
42
|
let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
30
43
|
for (let i = 0; i < 12; i++) {
|
|
@@ -52,6 +65,19 @@ function run({ payload, root, env = process.env, spawn: spawnFn = spawn }) {
|
|
|
52
65
|
const dir = path.join(root, '.wrxn', 'continuity');
|
|
53
66
|
fs.mkdirSync(dir, { recursive: true });
|
|
54
67
|
|
|
68
|
+
// Once-per-session claim (#45): the harness fires SessionEnd more than once per session, so an
|
|
69
|
+
// unguarded hook launches N synths that race on one shared `.pending`. CLAIM the session ATOMICALLY
|
|
70
|
+
// with an exclusive create (`wx` throws EEXIST if the marker exists) BEFORE staging markers / spawning:
|
|
71
|
+
// the FIRST fire creates the marker and proceeds; every later fire for the same session throws → the
|
|
72
|
+
// outer catch returns {} (spawns nothing, writes no markers). Race-safe across the separate hook
|
|
73
|
+
// processes (no TOCTOU). The marker is a PERSISTENT per-session file — it must OUTLIVE the synth, so it
|
|
74
|
+
// is NOT `.pending`/`.pending-handoff` (which the synth clears on exit). A missing/empty session_id
|
|
75
|
+
// cannot be deduped → preserve today's spawn-every-time behavior for that path.
|
|
76
|
+
const sid = payload && payload.session_id;
|
|
77
|
+
if (sid) {
|
|
78
|
+
fs.closeSync(fs.openSync(path.join(dir, `.spawned-${safeId(sid)}`), 'wx'));
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
// Stash the payload as .pending (the synth reads it for transcript_path) and raise the handoff gate.
|
|
56
82
|
fs.writeFileSync(path.join(dir, '.pending'), JSON.stringify(payload || {}));
|
|
57
83
|
fs.writeFileSync(path.join(dir, '.pending-handoff'), String(Date.now()));
|
|
@@ -201,6 +201,126 @@ 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
|
+
// sec-F2 (#52): cap the .synth.log read at its TAIL. The staleness decision needs only the NEWEST rows (+ the
|
|
216
|
+
// latest `wrote`), which live at the END of this append-only log — so reading the last SYNTH_LOG_TAIL_BYTES
|
|
217
|
+
// bounds memory against a pathologically large log without losing the signal. A partial first line (a row
|
|
218
|
+
// sliced mid-way by the byte window) is dropped so parseSynthLog only ever sees complete rows. FAIL-OPEN +
|
|
219
|
+
// total: a sub-cap log is read whole (unchanged); any stat/open/read fault → "" (no rows → no warning, no throw).
|
|
220
|
+
const SYNTH_LOG_TAIL_BYTES = 64 * 1024;
|
|
221
|
+
|
|
222
|
+
function readSynthLogTail(p) {
|
|
223
|
+
let fd;
|
|
224
|
+
try {
|
|
225
|
+
const size = fs.statSync(p).size;
|
|
226
|
+
if (size <= SYNTH_LOG_TAIL_BYTES) return readFileOr(p, '');
|
|
227
|
+
fd = fs.openSync(p, 'r');
|
|
228
|
+
const buf = Buffer.allocUnsafe(SYNTH_LOG_TAIL_BYTES);
|
|
229
|
+
const read = fs.readSync(fd, buf, 0, SYNTH_LOG_TAIL_BYTES, size - SYNTH_LOG_TAIL_BYTES);
|
|
230
|
+
const text = buf.toString('utf8', 0, read);
|
|
231
|
+
const nl = text.indexOf('\n');
|
|
232
|
+
return nl === -1 ? text : text.slice(nl + 1); // drop the partial first line sliced by the byte window
|
|
233
|
+
} catch {
|
|
234
|
+
return ''; // fail-open: any stat/open/read fault → no rows → no warning
|
|
235
|
+
} finally {
|
|
236
|
+
if (fd !== undefined) {
|
|
237
|
+
try { fs.closeSync(fd); } catch { /* fd already gone — never throw out of the cleanup */ }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A synth row is a FAILURE iff its outcome is `no-engine` or an `error…` string (`wrote`/`trivial` = healthy).
|
|
243
|
+
function isFailure(outcome) {
|
|
244
|
+
const o = String(outcome || '');
|
|
245
|
+
return o === 'no-engine' || /^error/.test(o);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Parse the tab-separated synth log into chronological rows { timestampMs, sessionId, outcome }, dropping
|
|
249
|
+
// any line we cannot resolve (malformed / unparseable timestamp) — "newest-resolvable last". The sessionId
|
|
250
|
+
// (field 1, `-` when absent) is what discriminates a double-spawn from genuine rot. PURE + total, never throws.
|
|
251
|
+
function parseSynthLog(text) {
|
|
252
|
+
const rows = [];
|
|
253
|
+
for (const line of String(text || '').split('\n')) {
|
|
254
|
+
if (!line.trim()) continue;
|
|
255
|
+
const f = line.split('\t');
|
|
256
|
+
if (f.length < 6) continue; // not a full outcome row → skip (fail-open)
|
|
257
|
+
const ts = Date.parse(f[0]);
|
|
258
|
+
if (!Number.isFinite(ts)) continue; // unresolvable timestamp → drop
|
|
259
|
+
rows.push({ timestampMs: ts, sessionId: f[1], outcome: f.slice(5).join('\t').trim() });
|
|
260
|
+
}
|
|
261
|
+
return rows;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Human age for the warning line (computed where the clock is, so batonStaleness stays clock-free). PURE.
|
|
265
|
+
function formatAge(ms) {
|
|
266
|
+
const m = Math.max(0, Math.round(Number(ms) / 60000));
|
|
267
|
+
if (m < 60) return `${m}m`;
|
|
268
|
+
const h = Math.floor(m / 60);
|
|
269
|
+
if (h < 24) return `${h}h`;
|
|
270
|
+
const d = Math.floor(h / 24);
|
|
271
|
+
const rem = h % 24;
|
|
272
|
+
return rem ? `${d}d ${rem}h` : `${d}d`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Sanitize the echoed synth outcome before it is interpolated into the orientation block (sec-F1). An
|
|
276
|
+
// `error: …` row carries a FREE-FORM message, so a crafted/corrupt log row could smuggle control chars or a
|
|
277
|
+
// forged `</wrxn-orientation>` close tag into additionalContext. Strip control chars (incl CR/LF/tab), drop
|
|
278
|
+
// angle brackets so no tag can be forged, and cap the length. PURE + total. (Mirrors memory-synth's
|
|
279
|
+
// sanitizeLogField idiom — each install-only hook is self-contained, node stdlib only.)
|
|
280
|
+
function sanitizeOutcome(s) {
|
|
281
|
+
return String(s == null ? '' : s)
|
|
282
|
+
// eslint-disable-next-line no-control-regex
|
|
283
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
284
|
+
.replace(/[<>]/g, '')
|
|
285
|
+
.slice(0, 120);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Pure baton-staleness decision (#51). Given the baton's last-write time and the synth-log rows
|
|
290
|
+
* (chronological, newest-resolvable last, each carrying its sessionId), return the FAILING outcome to name
|
|
291
|
+
* in the warning, or null when the baton is healthy/fresh. Warn IFF the newest row failed (`no-engine` |
|
|
292
|
+
* `error…`) AND the baton predates that newest row AND it is NOT a same-session double-spawn.
|
|
293
|
+
*
|
|
294
|
+
* Double-spawn vs genuine-rot is discriminated by SESSION ID, NOT by timestamp. The session that wrote the
|
|
295
|
+
* CURRENT baton is the session of the MOST-RECENT `wrote` row (every successful synth logs a `wrote` row in
|
|
296
|
+
* runHandoff's finally, a few ms AFTER it set the baton mtime — so that row's timestamp is ALWAYS ≥ the baton
|
|
297
|
+
* mtime, and a timestamp test can never tell the two apart). The #45 double-spawn logs `wrote` then a spurious
|
|
298
|
+
* `no-engine` ~2s later under the SAME session → suppress. A genuine freeze has later failures from DIFFERENT
|
|
299
|
+
* sessions (real incident 2026-06-22→23: baton written by 6898c0a9, then 7b69b97c / 39e5754b no-engine hours
|
|
300
|
+
* later) → warn. A missing/`-` session id on either side cannot confirm a double-spawn → default to warn (a
|
|
301
|
+
* loud false-positive beats silent rot). PURE + total: no IO, no clock, never throws.
|
|
302
|
+
* @param {{ batonMtimeMs:number, rows:Array<{timestampMs:number, sessionId?:string, outcome:string}> }} [input]
|
|
303
|
+
* @returns {string|null} the failing outcome to name, or null when healthy
|
|
304
|
+
*/
|
|
305
|
+
function batonStaleness({ batonMtimeMs, rows } = {}) {
|
|
306
|
+
if (!Array.isArray(rows) || rows.length === 0) return null; // no rows → nothing to judge
|
|
307
|
+
if (typeof batonMtimeMs !== 'number' || !Number.isFinite(batonMtimeMs)) return null; // no usable mtime
|
|
308
|
+
const newest = rows[rows.length - 1];
|
|
309
|
+
if (!newest || !isFailure(newest.outcome)) return null; // latest run healthy → fresh
|
|
310
|
+
if (Number.isFinite(newest.timestampMs) && newest.timestampMs <= batonMtimeMs) return null; // baton at/after newest → fresh
|
|
311
|
+
// The session that wrote the CURRENT baton = the session of the most-recent `wrote` row.
|
|
312
|
+
let wroteSession;
|
|
313
|
+
for (const r of rows) {
|
|
314
|
+
if (r && r.outcome === 'wrote') wroteSession = r.sessionId;
|
|
315
|
+
}
|
|
316
|
+
const sameSession =
|
|
317
|
+
!!wroteSession && wroteSession !== '-' &&
|
|
318
|
+
!!newest.sessionId && newest.sessionId !== '-' &&
|
|
319
|
+
wroteSession === newest.sessionId;
|
|
320
|
+
if (sameSession) return null; // same-session double-spawn → not stale
|
|
321
|
+
return newest.outcome; // genuine rot (or unconfirmable) → name the failing outcome
|
|
322
|
+
}
|
|
323
|
+
|
|
204
324
|
function main() {
|
|
205
325
|
let consumed = '';
|
|
206
326
|
try {
|
|
@@ -241,6 +361,24 @@ function main() {
|
|
|
241
361
|
const baton = readBaton(root);
|
|
242
362
|
if (baton && baton.trim()) {
|
|
243
363
|
parts.push('', 'Resume — deliberate handoff baton (.wrxn/continuity/latest.md):', baton.trim());
|
|
364
|
+
// #51: warn if the latest SessionEnd synth failed and no successful write has refreshed the baton since
|
|
365
|
+
// — a frozen baton must not be surfaced as current silently. Fail-open: any read/stat/parse fault → no
|
|
366
|
+
// warning, never a throw. Pure file reads + a stat only (no polling/sleeping beyond the hold cap above).
|
|
367
|
+
try {
|
|
368
|
+
const rows = parseSynthLog(readSynthLogTail(path.join(root, ...SYNTH_LOG_REL)));
|
|
369
|
+
const batonMtimeMs = fs.statSync(path.join(root, '.wrxn', 'continuity', 'latest.md')).mtimeMs;
|
|
370
|
+
const failing = batonStaleness({ batonMtimeMs, rows });
|
|
371
|
+
if (failing) {
|
|
372
|
+
const age = formatAge(Date.now() - batonMtimeMs);
|
|
373
|
+
const outcome = sanitizeOutcome(failing); // sec-F1: never echo a raw log field into the orientation
|
|
374
|
+
parts.push(
|
|
375
|
+
'',
|
|
376
|
+
`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.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
/* fail-open: a staleness-check fault must never block orientation (#51 AC3) */
|
|
381
|
+
}
|
|
244
382
|
} else {
|
|
245
383
|
parts.push('', 'Resume — no prior handoff.');
|
|
246
384
|
}
|
|
@@ -261,4 +399,4 @@ if (require.main === module) {
|
|
|
261
399
|
}
|
|
262
400
|
}
|
|
263
401
|
|
|
264
|
-
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL };
|
|
402
|
+
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness, parseSynthLog, readSynthLogTail, SYNTH_LOG_TAIL_BYTES };
|
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/ },
|
|
@@ -242,9 +242,10 @@ function readTranscriptBlob(transcriptPath) {
|
|
|
242
242
|
const CLAUDE_TIMEOUT_MS = 120000; // headless sonnet handoff/dream — generous but bounded.
|
|
243
243
|
const GEMINI_TIMEOUT_MS = 30000; // mirrors the proven aimem `curl -m 30` fallback.
|
|
244
244
|
const SENTINEL = 'WRXN_MEMORY_SYNTH'; // recursion guard: set on every engine spawn (the spawn hook no-ops when it sees it).
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
245
|
+
// 4096 (vs the aimem reference's 900, and the prior 1200): a thinking-default model spends part of the
|
|
246
|
+
// output budget on reasoning BEFORE the answer, and a rich `dream` consolidation runs ~4.3k chars — 1200
|
|
247
|
+
// truncated even a non-thinking model on a dense session. 4096 fits the observed clean dream JSON (#30).
|
|
248
|
+
const GEMINI_MAX_OUTPUT_TOKENS = 4096;
|
|
248
249
|
|
|
249
250
|
/** Assemble the model input: the task system prompt, then the transcript blob (mirrors the reference). */
|
|
250
251
|
function assemblePrompt(prompt, blob) {
|
|
@@ -270,8 +271,16 @@ function buildClaudeSpec({ model, prompt, blob }) {
|
|
|
270
271
|
* Build the `gemini` engine spec: a POST to `…/v1beta/models/<model>:generateContent` with the
|
|
271
272
|
* `x-goog-api-key` header, the task prompt as `system_instruction` and the blob as user content
|
|
272
273
|
* (mirrors the proven aimem-handoff-synth call). PURE.
|
|
274
|
+
*
|
|
275
|
+
* `thinking` controls the `generationConfig.thinkingConfig` directive (#30): when true (default) the spec
|
|
276
|
+
* carries `{ thinkingBudget: 0 }`, which DISABLES model-side thinking on thinking-default models (a verified
|
|
277
|
+
* HTTP-200 no-op on non-thinking models) so reasoning tokens never consume the output budget and the answer
|
|
278
|
+
* is never split into a dropped `thought` part. Pass `false` to OMIT the directive — the AC3 retry path for
|
|
279
|
+
* forced-thinking models (gemma-class) that reject thinkingConfig with an HTTP 400.
|
|
273
280
|
*/
|
|
274
|
-
function buildGeminiSpec({ model, prompt, blob, apiKey }) {
|
|
281
|
+
function buildGeminiSpec({ model, prompt, blob, apiKey, thinking = true }) {
|
|
282
|
+
const generationConfig = { temperature: 0.2, maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS };
|
|
283
|
+
if (thinking) generationConfig.thinkingConfig = { thinkingBudget: 0 };
|
|
275
284
|
return {
|
|
276
285
|
engine: 'gemini',
|
|
277
286
|
method: 'POST',
|
|
@@ -280,30 +289,59 @@ function buildGeminiSpec({ model, prompt, blob, apiKey }) {
|
|
|
280
289
|
body: {
|
|
281
290
|
system_instruction: { parts: [{ text: prompt || '' }] },
|
|
282
291
|
contents: [{ role: 'user', parts: [{ text: `TRANSCRIPT:\n${blob || ''}` }] }],
|
|
283
|
-
generationConfig
|
|
292
|
+
generationConfig,
|
|
284
293
|
},
|
|
285
294
|
timeoutMs: GEMINI_TIMEOUT_MS,
|
|
286
295
|
};
|
|
287
296
|
}
|
|
288
297
|
|
|
289
298
|
/**
|
|
290
|
-
* Extract the model text from a Gemini `generateContent` response body
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
299
|
+
* Extract the model text from a Gemini `generateContent` response body. Concatenates the `.text` of every
|
|
300
|
+
* content part EXCEPT parts flagged `thought: true` — a thinking-default / forced-thinking model emits its
|
|
301
|
+
* reasoning as a separate `thought` part (often parts[0]), which must be dropped, not returned, and the
|
|
302
|
+
* answer may itself span several parts (#30). An error payload, unexpected shape, no answer text, or an
|
|
303
|
+
* unparseable body → null, so the engine fails cleanly (→ fallback / null). PURE + total — pins the response
|
|
304
|
+
* contract with no network round-trip, never throws.
|
|
294
305
|
*/
|
|
295
306
|
function parseGeminiResponse(bodyString) {
|
|
296
307
|
try {
|
|
297
308
|
const d = JSON.parse(bodyString);
|
|
298
|
-
const
|
|
299
|
-
&& d.candidates[0].content.parts
|
|
300
|
-
|
|
301
|
-
|
|
309
|
+
const parts = d && d.candidates && d.candidates[0] && d.candidates[0].content
|
|
310
|
+
&& d.candidates[0].content.parts;
|
|
311
|
+
if (!Array.isArray(parts)) return null;
|
|
312
|
+
const text = parts
|
|
313
|
+
.filter((p) => p && p.thought !== true && typeof p.text === 'string')
|
|
314
|
+
.map((p) => p.text)
|
|
315
|
+
.join('');
|
|
316
|
+
return text.trim() ? text : null;
|
|
302
317
|
} catch {
|
|
303
318
|
return null;
|
|
304
319
|
}
|
|
305
320
|
}
|
|
306
321
|
|
|
322
|
+
// A forced-thinking model (gemma-class) rejects the thinkingConfig directive with an HTTP 400 whose body
|
|
323
|
+
// mentions "thinking" (e.g. "Thinking budget is not supported"). That single discriminating token gates the
|
|
324
|
+
// retry-without path (#30) — narrow enough that an unrelated 400 is NOT mis-flagged (it degrades as a plain
|
|
325
|
+
// failure → fallback / null). A false positive would only cost one harmless retry-without-thinkingConfig.
|
|
326
|
+
const THINKING_UNSUPPORTED_RE = /thinking/i;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Classify a Gemini HTTP response into the invoke-result the orchestration consumes (#30). PURE + total:
|
|
330
|
+
* - a parseable answer → `{ ok:true, text }`;
|
|
331
|
+
* - HTTP 400 whose body mentions thinking → `{ ok:false, thinkingUnsupported:true, detail }` (the signal
|
|
332
|
+
* runEngine uses to rebuild the spec WITHOUT thinkingConfig and retry once);
|
|
333
|
+
* - anything else → `{ ok:false, detail }` (a plain failure, exactly as before).
|
|
334
|
+
* Kept pure (statusCode + body in, result out) so invokeGemini's 400-detection is unit-tested with no network.
|
|
335
|
+
*/
|
|
336
|
+
function parseGeminiResult(statusCode, bodyString) {
|
|
337
|
+
const text = parseGeminiResponse(bodyString);
|
|
338
|
+
if (text) return { ok: true, text };
|
|
339
|
+
if (statusCode === 400 && THINKING_UNSUPPORTED_RE.test(String(bodyString || ''))) {
|
|
340
|
+
return { ok: false, text: '', thinkingUnsupported: true, detail: 'gemini http 400 thinking-unsupported' };
|
|
341
|
+
}
|
|
342
|
+
return { ok: false, text: '', detail: `gemini http ${statusCode}` };
|
|
343
|
+
}
|
|
344
|
+
|
|
307
345
|
// ── defaultInvoke: the REAL engine invoker (the production path behind the seam) ──
|
|
308
346
|
// Mirrors lib/protect.cjs's defaultInvoke: the single real-call site. The suite NEVER reaches here — it
|
|
309
347
|
// injects a fake. `claude` → spawnSync `claude -p`; `gemini` → an HTTPS POST. Both judge success by the
|
|
@@ -341,8 +379,9 @@ function invokeGemini(spec) {
|
|
|
341
379
|
let data = '';
|
|
342
380
|
res.on('data', (c) => { data += c; });
|
|
343
381
|
res.on('end', () => {
|
|
344
|
-
|
|
345
|
-
|
|
382
|
+
// parseGeminiResult is status-aware: a 400 thinking-unsupported body becomes a `thinkingUnsupported`
|
|
383
|
+
// signal so runEngine retries WITHOUT thinkingConfig; any other failure stays a plain ok:false (#30).
|
|
384
|
+
resolve(parseGeminiResult(res.statusCode, data));
|
|
346
385
|
});
|
|
347
386
|
},
|
|
348
387
|
);
|
|
@@ -390,17 +429,35 @@ function defaultSleep(ms) {
|
|
|
390
429
|
* Run a single engine through the invoker; return `{ text, attempts }` (text null when no output). A
|
|
391
430
|
* `gemini` engine with no API key fails WITHOUT calling the invoker (no key → no request, attempts 0). A
|
|
392
431
|
* transient `ok:false` is retried up to ENGINE_ATTEMPTS with a fixed backoff via the injected sleep; the
|
|
393
|
-
* first
|
|
432
|
+
* first text that is non-empty AND passes the optional task `validate` predicate wins. A non-empty text
|
|
433
|
+
* that FAILS `validate` (unusable output — e.g. dream prose the gate can't parse) is a failed attempt →
|
|
434
|
+
* retried, then null, so synthesizeDetailed advances to the fallback. No validator → any non-empty wins.
|
|
435
|
+
* An invoker that throws is caught → counts as a failed attempt, then null.
|
|
394
436
|
* @returns {Promise<{ text: string|null, attempts: number }>}
|
|
395
437
|
*/
|
|
396
|
-
async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep }) {
|
|
438
|
+
async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep, validate }) {
|
|
397
439
|
if (!engine || !engine.engine) return { text: null, attempts: 0 };
|
|
398
|
-
|
|
440
|
+
|
|
441
|
+
// `callOnce()` is ONE engine call — the unit the transient-spawn retry loop below repeats. For gemini it
|
|
442
|
+
// also encapsulates the thinking-unsupported fallback (#30): issue the spec carrying thinkingConfig; if the
|
|
443
|
+
// model REJECTS that directive (HTTP 400 → `thinkingUnsupported`), rebuild the SAME request WITHOUT
|
|
444
|
+
// thinkingConfig and issue it once more, using that result. This lives ABOVE the invoke seam, so a fake
|
|
445
|
+
// invoke simulates both halves with no network; it composes with the transient retries (the whole callOnce
|
|
446
|
+
// is the retried unit) and preserves the gemini-no-key early-out (no key → no request, below).
|
|
447
|
+
let callOnce;
|
|
399
448
|
if (engine.engine === 'claude') {
|
|
400
|
-
spec = buildClaudeSpec({ model: engine.model, prompt, blob });
|
|
449
|
+
const spec = buildClaudeSpec({ model: engine.model, prompt, blob });
|
|
450
|
+
callOnce = () => invoke(spec);
|
|
401
451
|
} else if (engine.engine === 'gemini') {
|
|
402
452
|
if (!apiKey) return { text: null, attempts: 0 }; // missing key fails this engine (→ fallback / null), no request, no retry.
|
|
403
|
-
|
|
453
|
+
callOnce = async () => {
|
|
454
|
+
const r = await invoke(buildGeminiSpec({ model: engine.model, prompt, blob, apiKey, thinking: true }));
|
|
455
|
+
if (r && r.thinkingUnsupported) {
|
|
456
|
+
// this model rejects the thinkingConfig directive (forced-thinking / gemma-class) — retry once without it.
|
|
457
|
+
return invoke(buildGeminiSpec({ model: engine.model, prompt, blob, apiKey, thinking: false }));
|
|
458
|
+
}
|
|
459
|
+
return r;
|
|
460
|
+
};
|
|
404
461
|
} else {
|
|
405
462
|
return { text: null, attempts: 0 }; // unknown engine name → skip.
|
|
406
463
|
}
|
|
@@ -409,18 +466,38 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
|
|
|
409
466
|
attempts += 1;
|
|
410
467
|
let text = null;
|
|
411
468
|
try {
|
|
412
|
-
const r = await
|
|
469
|
+
const r = await callOnce();
|
|
413
470
|
const t = r && r.ok && typeof r.text === 'string' ? r.text.trim() : '';
|
|
414
471
|
text = t || null;
|
|
415
472
|
} catch {
|
|
416
473
|
text = null; // an invoker throw is a transient failure for this attempt (degrade, never throw).
|
|
417
474
|
}
|
|
418
|
-
|
|
475
|
+
// success = non-empty AND task-usable (validate). No validator → permissive: any non-empty text wins.
|
|
476
|
+
if (text && (!validate || validate(text))) return { text, attempts };
|
|
419
477
|
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.
|
|
420
478
|
}
|
|
421
479
|
return { text: null, attempts };
|
|
422
480
|
}
|
|
423
481
|
|
|
482
|
+
// ── per-task engine-success validators (synth-robustness #50) ───────────────────
|
|
483
|
+
// An engine attempt succeeds only when its text is non-empty AND passes the task's validator, so an engine
|
|
484
|
+
// that returns non-empty-but-UNUSABLE output exhausts its retries and synthesizeDetailed advances to the
|
|
485
|
+
// fallback — instead of the unusable primary "winning" while the downstream gate parses zero proposals and
|
|
486
|
+
// writes nothing. A task with NO registered validator is permissive (any non-empty text wins, exactly as
|
|
487
|
+
// before), so every existing caller is preserved.
|
|
488
|
+
const TASK_VALIDATORS = {
|
|
489
|
+
// dream output is usable only if the gate (parseProposals) reads ≥1 proposal from it, OR it is a STRUCTURED
|
|
490
|
+
// abstain (isAbstain — the text carries an actual {abstain:true}, NOT merely the word in prose). A deliberate
|
|
491
|
+
// "nothing to record" is a valid answer, NOT a reason to churn through the fallback (#52: the old /abstain/i
|
|
492
|
+
// substring let a model that just wrote the word pass as an abstain).
|
|
493
|
+
dream: (text) => parseProposals(text).length > 0 || isAbstain(text),
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// Resolve a task's engine-success validator; an unregistered task is permissive (any non-empty text wins).
|
|
497
|
+
function validatorFor(task) {
|
|
498
|
+
return TASK_VALIDATORS[task] || (() => true);
|
|
499
|
+
}
|
|
500
|
+
|
|
424
501
|
/**
|
|
425
502
|
* Synthesize text for `task` from `prompt` + `blob`, resolving the engine per task (primary → fallback),
|
|
426
503
|
* also reporting WHICH engine produced the text and HOW MANY attempts the producing engine spent (the
|
|
@@ -431,9 +508,10 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
|
|
|
431
508
|
*/
|
|
432
509
|
async function synthesizeDetailed({ task, prompt, blob, config, apiKey, invoke = defaultInvoke, sleep }) {
|
|
433
510
|
const { primary, fallback } = resolveTask(config || DEFAULTS, task);
|
|
511
|
+
const validate = validatorFor(task); // task-aware success: dream needs parseable proposals; handoff is permissive.
|
|
434
512
|
let attempts = 0;
|
|
435
513
|
for (const engine of [primary, fallback]) {
|
|
436
|
-
const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep });
|
|
514
|
+
const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep, validate });
|
|
437
515
|
attempts += r.attempts;
|
|
438
516
|
if (r.text) return { text: r.text, engine: engine && engine.engine, attempts };
|
|
439
517
|
}
|
|
@@ -609,6 +687,18 @@ function writeBatonAtomic(root, body) {
|
|
|
609
687
|
* @returns {Promise<{ wrote:boolean, blob:string, reason?:string }>}
|
|
610
688
|
*/
|
|
611
689
|
async function runHandoff({ root, invoke = defaultInvoke, sleep }) {
|
|
690
|
+
// Stash-presence gate (#45): a synth with NO `.pending` stash has no session to process — no-op cleanly
|
|
691
|
+
// and write NO synth-log row. This is the secondary defense behind the spawn hook's once-per-session
|
|
692
|
+
// guard: a 2nd synth whose sibling already consumed+cleared the stash (or a manual --from-spawn with
|
|
693
|
+
// none) must not log a spurious `trivial`/`no-engine` row that pollutes the log + the baton-staleness
|
|
694
|
+
// signal. SPECIFIC to the ABSENT-file case — a present-but-trivial stash still logs `trivial` below.
|
|
695
|
+
// existsSync is total (never throws). NB-1: a stranded `.pending-handoff` can linger here (a concurrent /
|
|
696
|
+
// no-session-id race cleared `.pending` after a sibling re-raised both) — release session-start's hold
|
|
697
|
+
// before returning, or its holdForHandoff waits the full cap. rmQuiet is best-effort/total.
|
|
698
|
+
if (!fs.existsSync(continuityPath(root, PENDING))) {
|
|
699
|
+
rmQuiet(continuityPath(root, PENDING_HANDOFF));
|
|
700
|
+
return { wrote: false, blob: '', reason: 'no-stash' };
|
|
701
|
+
}
|
|
612
702
|
let wrote = false;
|
|
613
703
|
let reason;
|
|
614
704
|
let safeBlob = ''; // the redacted blob, returned so dream can reuse it in memory (auto-memory-04).
|
|
@@ -671,37 +761,61 @@ function dreamAdapter() {
|
|
|
671
761
|
}
|
|
672
762
|
|
|
673
763
|
/**
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
* parse
|
|
764
|
+
* Extract the first JSON value from model output. The model is asked for STRICT JSON, but a real model may
|
|
765
|
+
* wrap it in prose or ```json fences — so on a direct-parse miss we grab the first balanced {…} / […] span and
|
|
766
|
+
* parse that. Returns the parsed value, or null when nothing parses. PURE + total — never throws. This is the
|
|
767
|
+
* SINGLE tolerant parse shared by both parseProposals and isAbstain so the two can never diverge (#52).
|
|
677
768
|
* @param {string} text the engine output
|
|
678
|
-
* @returns {Array}
|
|
769
|
+
* @returns {object|Array|null}
|
|
679
770
|
*/
|
|
680
|
-
function
|
|
771
|
+
function firstJsonValue(text) {
|
|
681
772
|
const s = String(text || '');
|
|
682
|
-
let parsed = null;
|
|
683
773
|
try {
|
|
684
|
-
|
|
774
|
+
return JSON.parse(s);
|
|
685
775
|
} catch {
|
|
686
776
|
// tolerate prose/fences around the JSON: grab the first { … } or [ … ] span and try that.
|
|
687
777
|
const start = s.search(/[[{]/);
|
|
688
|
-
if (start === -1) return
|
|
778
|
+
if (start === -1) return null;
|
|
689
779
|
const open = s[start];
|
|
690
780
|
const close = open === '{' ? '}' : ']';
|
|
691
781
|
const end = s.lastIndexOf(close);
|
|
692
|
-
if (end <= start) return
|
|
782
|
+
if (end <= start) return null;
|
|
693
783
|
try {
|
|
694
|
-
|
|
784
|
+
return JSON.parse(s.slice(start, end + 1));
|
|
695
785
|
} catch {
|
|
696
|
-
return
|
|
786
|
+
return null;
|
|
697
787
|
}
|
|
698
788
|
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Parse the engine's dream output into a proposals array (via firstJsonValue's tolerant extraction). An
|
|
793
|
+
* `{ abstain:true }` or anything unparseable / shapeless yields [] (write nothing). PURE.
|
|
794
|
+
* @param {string} text the engine output
|
|
795
|
+
* @returns {Array} the proposals (possibly empty)
|
|
796
|
+
*/
|
|
797
|
+
function parseProposals(text) {
|
|
798
|
+
const parsed = firstJsonValue(text);
|
|
699
799
|
if (parsed && typeof parsed === 'object' && parsed.abstain === true) return [];
|
|
700
800
|
if (Array.isArray(parsed)) return parsed;
|
|
701
801
|
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.proposals)) return parsed.proposals;
|
|
702
802
|
return [];
|
|
703
803
|
}
|
|
704
804
|
|
|
805
|
+
/**
|
|
806
|
+
* A STRUCTURED dream abstain (#52): the output carries an actual `{ abstain:true }` — not merely the word
|
|
807
|
+
* "abstain" somewhere in prose. Reuses firstJsonValue's extraction (clean / fenced / prose-wrapped JSON all
|
|
808
|
+
* resolve) and returns true only when the parsed object literally has `abstain === true`. PURE + total — never
|
|
809
|
+
* throws. The dream engine-success validator uses it so a model that just writes the word does NOT pass as a
|
|
810
|
+
* deliberate "nothing to record" (the old broad /abstain/i substring did).
|
|
811
|
+
* @param {string} text the engine output
|
|
812
|
+
* @returns {boolean}
|
|
813
|
+
*/
|
|
814
|
+
function isAbstain(text) {
|
|
815
|
+
const parsed = firstJsonValue(text);
|
|
816
|
+
return !!(parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.abstain === true);
|
|
817
|
+
}
|
|
818
|
+
|
|
705
819
|
/** Run a dream.cjs subcommand in-process-but-separate (spawnSync node), rooted at the install. Parses its
|
|
706
820
|
* JSON stdout; a non-zero exit / unparseable output → null (the caller treats it as "no result"). */
|
|
707
821
|
function runDreamCli(root, args) {
|
|
@@ -728,17 +842,19 @@ function writeTemp(root, tag, content) {
|
|
|
728
842
|
* the dream.cjs gate (check → stage accepted → commit, all `--source`-verified against the blob), and
|
|
729
843
|
* returns the committed slugs. Writes nothing on a trivial blob / abstain / empty-or-rejected set. Cleans
|
|
730
844
|
* up its temp files. Never throws (a fault degrades to "wrote nothing").
|
|
731
|
-
* @param {{ root:string, blob:string, invoke?:Function }} opts
|
|
845
|
+
* @param {{ root:string, blob:string, invoke?:Function, sleep?:Function }} opts
|
|
732
846
|
* @returns {Promise<{ written:string[], reason?:string }>}
|
|
733
847
|
*/
|
|
734
|
-
async function runDream({ root, blob, invoke = defaultInvoke }) {
|
|
848
|
+
async function runDream({ root, blob, invoke = defaultInvoke, sleep }) {
|
|
735
849
|
const temps = [];
|
|
736
850
|
try {
|
|
737
851
|
if (!blob || blob.trim().length < TRIVIAL_BLOB_MIN) return { written: [], reason: 'trivial' };
|
|
738
852
|
const config = loadConfig(root);
|
|
739
853
|
const apiKey = loadEnv(root).GEMINI_API_KEY;
|
|
740
854
|
const safeBlob = redactSecrets(blob); // scrub BEFORE the blob egresses to the external model.
|
|
741
|
-
|
|
855
|
+
// `sleep` is threaded to the engine retry loop (default real backoff in production; tests inject a no-op so
|
|
856
|
+
// a validate-fail dream — which exhausts ENGINE_ATTEMPTS × ENGINE_BACKOFF_MS — runs instantly, #52).
|
|
857
|
+
const text = await synthesize({ task: 'dream', prompt: PROMPTS.dream, blob: safeBlob, config, apiKey, invoke, sleep });
|
|
742
858
|
const proposals = parseProposals(text);
|
|
743
859
|
if (proposals.length === 0) return { written: [], reason: 'abstain' };
|
|
744
860
|
|
|
@@ -878,7 +994,9 @@ module.exports = {
|
|
|
878
994
|
buildClaudeSpec,
|
|
879
995
|
buildGeminiSpec,
|
|
880
996
|
parseGeminiResponse,
|
|
997
|
+
parseGeminiResult,
|
|
881
998
|
parseProposals,
|
|
999
|
+
isAbstain,
|
|
882
1000
|
defaultInvoke,
|
|
883
1001
|
synthesize,
|
|
884
1002
|
runHandoff,
|