@gcunharodrigues/wrxn 0.16.0 → 0.18.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.18.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()));
|
|
@@ -212,6 +212,33 @@ function holdForHandoff({ root, capMs = HOLD_CAP_MS, now = Date.now, sleep } = {
|
|
|
212
212
|
|
|
213
213
|
const SYNTH_LOG_REL = ['.wrxn', 'continuity', '.synth.log'];
|
|
214
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
|
+
|
|
215
242
|
// A synth row is a FAILURE iff its outcome is `no-engine` or an `error…` string (`wrote`/`trivial` = healthy).
|
|
216
243
|
function isFailure(outcome) {
|
|
217
244
|
const o = String(outcome || '');
|
|
@@ -338,7 +365,7 @@ function main() {
|
|
|
338
365
|
// — a frozen baton must not be surfaced as current silently. Fail-open: any read/stat/parse fault → no
|
|
339
366
|
// warning, never a throw. Pure file reads + a stat only (no polling/sleeping beyond the hold cap above).
|
|
340
367
|
try {
|
|
341
|
-
const rows = parseSynthLog(
|
|
368
|
+
const rows = parseSynthLog(readSynthLogTail(path.join(root, ...SYNTH_LOG_REL)));
|
|
342
369
|
const batonMtimeMs = fs.statSync(path.join(root, '.wrxn', 'continuity', 'latest.md')).mtimeMs;
|
|
343
370
|
const failing = batonStaleness({ batonMtimeMs, rows });
|
|
344
371
|
if (failing) {
|
|
@@ -372,4 +399,4 @@ if (require.main === module) {
|
|
|
372
399
|
}
|
|
373
400
|
}
|
|
374
401
|
|
|
375
|
-
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness };
|
|
402
|
+
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness, parseSynthLog, readSynthLogTail, SYNTH_LOG_TAIL_BYTES };
|
|
@@ -24,19 +24,28 @@ const path = require('path');
|
|
|
24
24
|
const https = require('https');
|
|
25
25
|
const { spawnSync } = require('child_process');
|
|
26
26
|
|
|
27
|
-
// ── default tiering (PRD)
|
|
28
|
-
// handoff
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
27
|
+
// ── default tiering (PRD + #59) ────────────────────────────────────────────────
|
|
28
|
+
// handoff stays FAST — gemini-3.1-flash-lite with thinkingBudget:0 (reasoning OFF), because handoff is on the
|
|
29
|
+
// 180s session-start hold path. dream REASONS — gemini-3.5-flash with thinkingBudget:-1 (dynamic, the model
|
|
30
|
+
// decides) + maxOutputTokens:8192 (sized for reasoning + the ~4.3k-char dream JSON so it can't truncate),
|
|
31
|
+
// because dream runs off the critical path where quality matters. Both fall back to claude/claude-sonnet-4-6.
|
|
32
|
+
// The seeded memory.config.json is DEFAULTS serialized; an operator edits it and `wrxn update` preserves it
|
|
33
|
+
// (seeded class), so EXISTING installs keep their config and only FRESH installs get these defaults (no
|
|
34
|
+
// migration). Claude stays the fallback so a keyless fresh install still writes a baton via the operator's CLI
|
|
35
|
+
// auth (no GEMINI_API_KEY → gemini gated → claude carries it). thinkingBudget: 0 off / -1 dynamic / N>0 bounded.
|
|
36
|
+
// DEFAULT_TASK is the conservative per-task default for any task NOT named in DEFAULTS.tasks (reasoning off,
|
|
37
|
+
// preserving #30's safe default); handoff + dream pin their own tiering below.
|
|
32
38
|
const DEFAULT_TASK = {
|
|
33
|
-
primary: { engine: 'gemini', model: 'gemini-3.1-flash-lite' },
|
|
39
|
+
primary: { engine: 'gemini', model: 'gemini-3.1-flash-lite', thinkingBudget: 0 },
|
|
34
40
|
fallback: { engine: 'claude', model: 'claude-sonnet-4-6' },
|
|
35
41
|
};
|
|
36
42
|
const DEFAULTS = {
|
|
37
43
|
tasks: {
|
|
38
44
|
handoff: clone(DEFAULT_TASK),
|
|
39
|
-
dream:
|
|
45
|
+
dream: {
|
|
46
|
+
primary: { engine: 'gemini', model: 'gemini-3.5-flash', thinkingBudget: -1, maxOutputTokens: 8192 },
|
|
47
|
+
fallback: { engine: 'claude', model: 'claude-sonnet-4-6' },
|
|
48
|
+
},
|
|
40
49
|
},
|
|
41
50
|
};
|
|
42
51
|
|
|
@@ -242,9 +251,10 @@ function readTranscriptBlob(transcriptPath) {
|
|
|
242
251
|
const CLAUDE_TIMEOUT_MS = 120000; // headless sonnet handoff/dream — generous but bounded.
|
|
243
252
|
const GEMINI_TIMEOUT_MS = 30000; // mirrors the proven aimem `curl -m 30` fallback.
|
|
244
253
|
const SENTINEL = 'WRXN_MEMORY_SYNTH'; // recursion guard: set on every engine spawn (the spawn hook no-ops when it sees it).
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
254
|
+
// 4096 (vs the aimem reference's 900, and the prior 1200): a thinking-default model spends part of the
|
|
255
|
+
// output budget on reasoning BEFORE the answer, and a rich `dream` consolidation runs ~4.3k chars — 1200
|
|
256
|
+
// truncated even a non-thinking model on a dense session. 4096 fits the observed clean dream JSON (#30).
|
|
257
|
+
const GEMINI_MAX_OUTPUT_TOKENS = 4096;
|
|
248
258
|
|
|
249
259
|
/** Assemble the model input: the task system prompt, then the transcript blob (mirrors the reference). */
|
|
250
260
|
function assemblePrompt(prompt, blob) {
|
|
@@ -270,8 +280,18 @@ function buildClaudeSpec({ model, prompt, blob }) {
|
|
|
270
280
|
* Build the `gemini` engine spec: a POST to `…/v1beta/models/<model>:generateContent` with the
|
|
271
281
|
* `x-goog-api-key` header, the task prompt as `system_instruction` and the blob as user content
|
|
272
282
|
* (mirrors the proven aimem-handoff-synth call). PURE.
|
|
283
|
+
*
|
|
284
|
+
* `thinkingBudget` is the PER-ENGINE reasoning directive (#59, generalizing #30): a NUMBER sets
|
|
285
|
+
* `generationConfig.thinkingConfig = { thinkingBudget }` — `0` disables model-side thinking (a verified
|
|
286
|
+
* HTTP-200 no-op on non-thinking models), `-1` lets the model decide (dynamic), `N>0` bounds it to N thinking
|
|
287
|
+
* tokens. An ABSENT budget defaults to `0` (preserves #30's safe default). An explicit `null` OMITS
|
|
288
|
+
* thinkingConfig entirely — the AC3 retry path for forced-thinking models (gemma-class) that reject the
|
|
289
|
+
* directive with an HTTP 400. `maxOutputTokens` is the per-engine output cap (default GEMINI_MAX_OUTPUT_TOKENS).
|
|
273
290
|
*/
|
|
274
|
-
function buildGeminiSpec({ model, prompt, blob, apiKey }) {
|
|
291
|
+
function buildGeminiSpec({ model, prompt, blob, apiKey, thinkingBudget = 0, maxOutputTokens = GEMINI_MAX_OUTPUT_TOKENS }) {
|
|
292
|
+
const generationConfig = { temperature: 0.2, maxOutputTokens };
|
|
293
|
+
// a NUMBER (incl. 0 and -1) sets the directive; an explicit `null` OMITS it (the AC3 retry-without path).
|
|
294
|
+
if (thinkingBudget !== null) generationConfig.thinkingConfig = { thinkingBudget };
|
|
275
295
|
return {
|
|
276
296
|
engine: 'gemini',
|
|
277
297
|
method: 'POST',
|
|
@@ -280,30 +300,59 @@ function buildGeminiSpec({ model, prompt, blob, apiKey }) {
|
|
|
280
300
|
body: {
|
|
281
301
|
system_instruction: { parts: [{ text: prompt || '' }] },
|
|
282
302
|
contents: [{ role: 'user', parts: [{ text: `TRANSCRIPT:\n${blob || ''}` }] }],
|
|
283
|
-
generationConfig
|
|
303
|
+
generationConfig,
|
|
284
304
|
},
|
|
285
305
|
timeoutMs: GEMINI_TIMEOUT_MS,
|
|
286
306
|
};
|
|
287
307
|
}
|
|
288
308
|
|
|
289
309
|
/**
|
|
290
|
-
* Extract the model text from a Gemini `generateContent` response body
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
310
|
+
* Extract the model text from a Gemini `generateContent` response body. Concatenates the `.text` of every
|
|
311
|
+
* content part EXCEPT parts flagged `thought: true` — a thinking-default / forced-thinking model emits its
|
|
312
|
+
* reasoning as a separate `thought` part (often parts[0]), which must be dropped, not returned, and the
|
|
313
|
+
* answer may itself span several parts (#30). An error payload, unexpected shape, no answer text, or an
|
|
314
|
+
* unparseable body → null, so the engine fails cleanly (→ fallback / null). PURE + total — pins the response
|
|
315
|
+
* contract with no network round-trip, never throws.
|
|
294
316
|
*/
|
|
295
317
|
function parseGeminiResponse(bodyString) {
|
|
296
318
|
try {
|
|
297
319
|
const d = JSON.parse(bodyString);
|
|
298
|
-
const
|
|
299
|
-
&& d.candidates[0].content.parts
|
|
300
|
-
|
|
301
|
-
|
|
320
|
+
const parts = d && d.candidates && d.candidates[0] && d.candidates[0].content
|
|
321
|
+
&& d.candidates[0].content.parts;
|
|
322
|
+
if (!Array.isArray(parts)) return null;
|
|
323
|
+
const text = parts
|
|
324
|
+
.filter((p) => p && p.thought !== true && typeof p.text === 'string')
|
|
325
|
+
.map((p) => p.text)
|
|
326
|
+
.join('');
|
|
327
|
+
return text.trim() ? text : null;
|
|
302
328
|
} catch {
|
|
303
329
|
return null;
|
|
304
330
|
}
|
|
305
331
|
}
|
|
306
332
|
|
|
333
|
+
// A forced-thinking model (gemma-class) rejects the thinkingConfig directive with an HTTP 400 whose body
|
|
334
|
+
// mentions "thinking" (e.g. "Thinking budget is not supported"). That single discriminating token gates the
|
|
335
|
+
// retry-without path (#30) — narrow enough that an unrelated 400 is NOT mis-flagged (it degrades as a plain
|
|
336
|
+
// failure → fallback / null). A false positive would only cost one harmless retry-without-thinkingConfig.
|
|
337
|
+
const THINKING_UNSUPPORTED_RE = /thinking/i;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Classify a Gemini HTTP response into the invoke-result the orchestration consumes (#30). PURE + total:
|
|
341
|
+
* - a parseable answer → `{ ok:true, text }`;
|
|
342
|
+
* - HTTP 400 whose body mentions thinking → `{ ok:false, thinkingUnsupported:true, detail }` (the signal
|
|
343
|
+
* runEngine uses to rebuild the spec WITHOUT thinkingConfig and retry once);
|
|
344
|
+
* - anything else → `{ ok:false, detail }` (a plain failure, exactly as before).
|
|
345
|
+
* Kept pure (statusCode + body in, result out) so invokeGemini's 400-detection is unit-tested with no network.
|
|
346
|
+
*/
|
|
347
|
+
function parseGeminiResult(statusCode, bodyString) {
|
|
348
|
+
const text = parseGeminiResponse(bodyString);
|
|
349
|
+
if (text) return { ok: true, text };
|
|
350
|
+
if (statusCode === 400 && THINKING_UNSUPPORTED_RE.test(String(bodyString || ''))) {
|
|
351
|
+
return { ok: false, text: '', thinkingUnsupported: true, detail: 'gemini http 400 thinking-unsupported' };
|
|
352
|
+
}
|
|
353
|
+
return { ok: false, text: '', detail: `gemini http ${statusCode}` };
|
|
354
|
+
}
|
|
355
|
+
|
|
307
356
|
// ── defaultInvoke: the REAL engine invoker (the production path behind the seam) ──
|
|
308
357
|
// Mirrors lib/protect.cjs's defaultInvoke: the single real-call site. The suite NEVER reaches here — it
|
|
309
358
|
// injects a fake. `claude` → spawnSync `claude -p`; `gemini` → an HTTPS POST. Both judge success by the
|
|
@@ -341,8 +390,9 @@ function invokeGemini(spec) {
|
|
|
341
390
|
let data = '';
|
|
342
391
|
res.on('data', (c) => { data += c; });
|
|
343
392
|
res.on('end', () => {
|
|
344
|
-
|
|
345
|
-
|
|
393
|
+
// parseGeminiResult is status-aware: a 400 thinking-unsupported body becomes a `thinkingUnsupported`
|
|
394
|
+
// signal so runEngine retries WITHOUT thinkingConfig; any other failure stays a plain ok:false (#30).
|
|
395
|
+
resolve(parseGeminiResult(res.statusCode, data));
|
|
346
396
|
});
|
|
347
397
|
},
|
|
348
398
|
);
|
|
@@ -398,12 +448,28 @@ function defaultSleep(ms) {
|
|
|
398
448
|
*/
|
|
399
449
|
async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep, validate }) {
|
|
400
450
|
if (!engine || !engine.engine) return { text: null, attempts: 0 };
|
|
401
|
-
|
|
451
|
+
|
|
452
|
+
// `callOnce()` is ONE engine call — the unit the transient-spawn retry loop below repeats. For gemini it
|
|
453
|
+
// also encapsulates the thinking-unsupported fallback (#30): issue the spec carrying thinkingConfig; if the
|
|
454
|
+
// model REJECTS that directive (HTTP 400 → `thinkingUnsupported`), rebuild the SAME request WITHOUT
|
|
455
|
+
// thinkingConfig and issue it once more, using that result. This lives ABOVE the invoke seam, so a fake
|
|
456
|
+
// invoke simulates both halves with no network; it composes with the transient retries (the whole callOnce
|
|
457
|
+
// is the retried unit) and preserves the gemini-no-key early-out (no key → no request, below).
|
|
458
|
+
let callOnce;
|
|
402
459
|
if (engine.engine === 'claude') {
|
|
403
|
-
spec = buildClaudeSpec({ model: engine.model, prompt, blob });
|
|
460
|
+
const spec = buildClaudeSpec({ model: engine.model, prompt, blob });
|
|
461
|
+
callOnce = () => invoke(spec);
|
|
404
462
|
} else if (engine.engine === 'gemini') {
|
|
405
463
|
if (!apiKey) return { text: null, attempts: 0 }; // missing key fails this engine (→ fallback / null), no request, no retry.
|
|
406
|
-
|
|
464
|
+
const { thinkingBudget, maxOutputTokens } = engine; // per-engine reasoning config (#59); absent → buildGeminiSpec defaults (0 / 4096).
|
|
465
|
+
callOnce = async () => {
|
|
466
|
+
const r = await invoke(buildGeminiSpec({ model: engine.model, prompt, blob, apiKey, thinkingBudget, maxOutputTokens }));
|
|
467
|
+
if (r && r.thinkingUnsupported) {
|
|
468
|
+
// this model rejects the thinkingConfig directive (forced-thinking / gemma-class) — retry once without it (same cap).
|
|
469
|
+
return invoke(buildGeminiSpec({ model: engine.model, prompt, blob, apiKey, thinkingBudget: null, maxOutputTokens }));
|
|
470
|
+
}
|
|
471
|
+
return r;
|
|
472
|
+
};
|
|
407
473
|
} else {
|
|
408
474
|
return { text: null, attempts: 0 }; // unknown engine name → skip.
|
|
409
475
|
}
|
|
@@ -412,7 +478,7 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
|
|
|
412
478
|
attempts += 1;
|
|
413
479
|
let text = null;
|
|
414
480
|
try {
|
|
415
|
-
const r = await
|
|
481
|
+
const r = await callOnce();
|
|
416
482
|
const t = r && r.ok && typeof r.text === 'string' ? r.text.trim() : '';
|
|
417
483
|
text = t || null;
|
|
418
484
|
} catch {
|
|
@@ -432,9 +498,11 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
|
|
|
432
498
|
// writes nothing. A task with NO registered validator is permissive (any non-empty text wins, exactly as
|
|
433
499
|
// before), so every existing caller is preserved.
|
|
434
500
|
const TASK_VALIDATORS = {
|
|
435
|
-
// dream output is usable only if the gate (parseProposals) reads ≥1 proposal from it, OR it is
|
|
436
|
-
// abstain —
|
|
437
|
-
|
|
501
|
+
// dream output is usable only if the gate (parseProposals) reads ≥1 proposal from it, OR it is a STRUCTURED
|
|
502
|
+
// abstain (isAbstain — the text carries an actual {abstain:true}, NOT merely the word in prose). A deliberate
|
|
503
|
+
// "nothing to record" is a valid answer, NOT a reason to churn through the fallback (#52: the old /abstain/i
|
|
504
|
+
// substring let a model that just wrote the word pass as an abstain).
|
|
505
|
+
dream: (text) => parseProposals(text).length > 0 || isAbstain(text),
|
|
438
506
|
};
|
|
439
507
|
|
|
440
508
|
// Resolve a task's engine-success validator; an unregistered task is permissive (any non-empty text wins).
|
|
@@ -631,6 +699,18 @@ function writeBatonAtomic(root, body) {
|
|
|
631
699
|
* @returns {Promise<{ wrote:boolean, blob:string, reason?:string }>}
|
|
632
700
|
*/
|
|
633
701
|
async function runHandoff({ root, invoke = defaultInvoke, sleep }) {
|
|
702
|
+
// Stash-presence gate (#45): a synth with NO `.pending` stash has no session to process — no-op cleanly
|
|
703
|
+
// and write NO synth-log row. This is the secondary defense behind the spawn hook's once-per-session
|
|
704
|
+
// guard: a 2nd synth whose sibling already consumed+cleared the stash (or a manual --from-spawn with
|
|
705
|
+
// none) must not log a spurious `trivial`/`no-engine` row that pollutes the log + the baton-staleness
|
|
706
|
+
// signal. SPECIFIC to the ABSENT-file case — a present-but-trivial stash still logs `trivial` below.
|
|
707
|
+
// existsSync is total (never throws). NB-1: a stranded `.pending-handoff` can linger here (a concurrent /
|
|
708
|
+
// no-session-id race cleared `.pending` after a sibling re-raised both) — release session-start's hold
|
|
709
|
+
// before returning, or its holdForHandoff waits the full cap. rmQuiet is best-effort/total.
|
|
710
|
+
if (!fs.existsSync(continuityPath(root, PENDING))) {
|
|
711
|
+
rmQuiet(continuityPath(root, PENDING_HANDOFF));
|
|
712
|
+
return { wrote: false, blob: '', reason: 'no-stash' };
|
|
713
|
+
}
|
|
634
714
|
let wrote = false;
|
|
635
715
|
let reason;
|
|
636
716
|
let safeBlob = ''; // the redacted blob, returned so dream can reuse it in memory (auto-memory-04).
|
|
@@ -693,37 +773,61 @@ function dreamAdapter() {
|
|
|
693
773
|
}
|
|
694
774
|
|
|
695
775
|
/**
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* parse
|
|
776
|
+
* Extract the first JSON value from model output. The model is asked for STRICT JSON, but a real model may
|
|
777
|
+
* wrap it in prose or ```json fences — so on a direct-parse miss we grab the first balanced {…} / […] span and
|
|
778
|
+
* parse that. Returns the parsed value, or null when nothing parses. PURE + total — never throws. This is the
|
|
779
|
+
* SINGLE tolerant parse shared by both parseProposals and isAbstain so the two can never diverge (#52).
|
|
699
780
|
* @param {string} text the engine output
|
|
700
|
-
* @returns {Array}
|
|
781
|
+
* @returns {object|Array|null}
|
|
701
782
|
*/
|
|
702
|
-
function
|
|
783
|
+
function firstJsonValue(text) {
|
|
703
784
|
const s = String(text || '');
|
|
704
|
-
let parsed = null;
|
|
705
785
|
try {
|
|
706
|
-
|
|
786
|
+
return JSON.parse(s);
|
|
707
787
|
} catch {
|
|
708
788
|
// tolerate prose/fences around the JSON: grab the first { … } or [ … ] span and try that.
|
|
709
789
|
const start = s.search(/[[{]/);
|
|
710
|
-
if (start === -1) return
|
|
790
|
+
if (start === -1) return null;
|
|
711
791
|
const open = s[start];
|
|
712
792
|
const close = open === '{' ? '}' : ']';
|
|
713
793
|
const end = s.lastIndexOf(close);
|
|
714
|
-
if (end <= start) return
|
|
794
|
+
if (end <= start) return null;
|
|
715
795
|
try {
|
|
716
|
-
|
|
796
|
+
return JSON.parse(s.slice(start, end + 1));
|
|
717
797
|
} catch {
|
|
718
|
-
return
|
|
798
|
+
return null;
|
|
719
799
|
}
|
|
720
800
|
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Parse the engine's dream output into a proposals array (via firstJsonValue's tolerant extraction). An
|
|
805
|
+
* `{ abstain:true }` or anything unparseable / shapeless yields [] (write nothing). PURE.
|
|
806
|
+
* @param {string} text the engine output
|
|
807
|
+
* @returns {Array} the proposals (possibly empty)
|
|
808
|
+
*/
|
|
809
|
+
function parseProposals(text) {
|
|
810
|
+
const parsed = firstJsonValue(text);
|
|
721
811
|
if (parsed && typeof parsed === 'object' && parsed.abstain === true) return [];
|
|
722
812
|
if (Array.isArray(parsed)) return parsed;
|
|
723
813
|
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.proposals)) return parsed.proposals;
|
|
724
814
|
return [];
|
|
725
815
|
}
|
|
726
816
|
|
|
817
|
+
/**
|
|
818
|
+
* A STRUCTURED dream abstain (#52): the output carries an actual `{ abstain:true }` — not merely the word
|
|
819
|
+
* "abstain" somewhere in prose. Reuses firstJsonValue's extraction (clean / fenced / prose-wrapped JSON all
|
|
820
|
+
* resolve) and returns true only when the parsed object literally has `abstain === true`. PURE + total — never
|
|
821
|
+
* throws. The dream engine-success validator uses it so a model that just writes the word does NOT pass as a
|
|
822
|
+
* deliberate "nothing to record" (the old broad /abstain/i substring did).
|
|
823
|
+
* @param {string} text the engine output
|
|
824
|
+
* @returns {boolean}
|
|
825
|
+
*/
|
|
826
|
+
function isAbstain(text) {
|
|
827
|
+
const parsed = firstJsonValue(text);
|
|
828
|
+
return !!(parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.abstain === true);
|
|
829
|
+
}
|
|
830
|
+
|
|
727
831
|
/** Run a dream.cjs subcommand in-process-but-separate (spawnSync node), rooted at the install. Parses its
|
|
728
832
|
* JSON stdout; a non-zero exit / unparseable output → null (the caller treats it as "no result"). */
|
|
729
833
|
function runDreamCli(root, args) {
|
|
@@ -750,17 +854,19 @@ function writeTemp(root, tag, content) {
|
|
|
750
854
|
* the dream.cjs gate (check → stage accepted → commit, all `--source`-verified against the blob), and
|
|
751
855
|
* returns the committed slugs. Writes nothing on a trivial blob / abstain / empty-or-rejected set. Cleans
|
|
752
856
|
* up its temp files. Never throws (a fault degrades to "wrote nothing").
|
|
753
|
-
* @param {{ root:string, blob:string, invoke?:Function }} opts
|
|
857
|
+
* @param {{ root:string, blob:string, invoke?:Function, sleep?:Function }} opts
|
|
754
858
|
* @returns {Promise<{ written:string[], reason?:string }>}
|
|
755
859
|
*/
|
|
756
|
-
async function runDream({ root, blob, invoke = defaultInvoke }) {
|
|
860
|
+
async function runDream({ root, blob, invoke = defaultInvoke, sleep }) {
|
|
757
861
|
const temps = [];
|
|
758
862
|
try {
|
|
759
863
|
if (!blob || blob.trim().length < TRIVIAL_BLOB_MIN) return { written: [], reason: 'trivial' };
|
|
760
864
|
const config = loadConfig(root);
|
|
761
865
|
const apiKey = loadEnv(root).GEMINI_API_KEY;
|
|
762
866
|
const safeBlob = redactSecrets(blob); // scrub BEFORE the blob egresses to the external model.
|
|
763
|
-
|
|
867
|
+
// `sleep` is threaded to the engine retry loop (default real backoff in production; tests inject a no-op so
|
|
868
|
+
// a validate-fail dream — which exhausts ENGINE_ATTEMPTS × ENGINE_BACKOFF_MS — runs instantly, #52).
|
|
869
|
+
const text = await synthesize({ task: 'dream', prompt: PROMPTS.dream, blob: safeBlob, config, apiKey, invoke, sleep });
|
|
764
870
|
const proposals = parseProposals(text);
|
|
765
871
|
if (proposals.length === 0) return { written: [], reason: 'abstain' };
|
|
766
872
|
|
|
@@ -900,7 +1006,9 @@ module.exports = {
|
|
|
900
1006
|
buildClaudeSpec,
|
|
901
1007
|
buildGeminiSpec,
|
|
902
1008
|
parseGeminiResponse,
|
|
1009
|
+
parseGeminiResult,
|
|
903
1010
|
parseProposals,
|
|
1011
|
+
isAbstain,
|
|
904
1012
|
defaultInvoke,
|
|
905
1013
|
synthesize,
|
|
906
1014
|
runHandoff,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"tasks": {
|
|
3
3
|
"handoff": {
|
|
4
|
-
"primary": { "engine": "gemini", "model": "gemini-3.1-flash-lite" },
|
|
4
|
+
"primary": { "engine": "gemini", "model": "gemini-3.1-flash-lite", "thinkingBudget": 0 },
|
|
5
5
|
"fallback": { "engine": "claude", "model": "claude-sonnet-4-6" }
|
|
6
6
|
},
|
|
7
7
|
"dream": {
|
|
8
|
-
"primary": { "engine": "gemini", "model": "gemini-3.
|
|
8
|
+
"primary": { "engine": "gemini", "model": "gemini-3.5-flash", "thinkingBudget": -1, "maxOutputTokens": 8192 },
|
|
9
9
|
"fallback": { "engine": "claude", "model": "claude-sonnet-4-6" }
|
|
10
10
|
}
|
|
11
11
|
}
|