@yeaft/webchat-agent 1.0.450 → 1.0.453

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.
Binary file
@@ -16,6 +16,6 @@
16
16
  </head>
17
17
  <body>
18
18
  <div id="app"></div>
19
- <script type="module" src="app.bundle.js?v=0117a18d"></script>
19
+ <script type="module" src="app.bundle.js?v=1ef42512"></script>
20
20
  </body>
21
21
  </html>
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.450",
3
+ "version": "1.0.453",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
- "yeaft-agent": "./cli.js",
9
- "yeaft-agent-container": "./container-cli.js",
10
- "yeaft": "./yeaft/cli.js",
11
- "yeaft-stats": "./bin/yeaft-stats.js"
8
+ "yeaft-agent": "cli.js",
9
+ "yeaft-agent-container": "container-cli.js",
10
+ "yeaft": "yeaft/cli.js",
11
+ "yeaft-stats": "bin/yeaft-stats.js"
12
12
  },
13
13
  "scripts": {
14
14
  "start": "node index.js",
@@ -754,8 +754,13 @@ function summarizeTrace(trace, detailsLoaded = false) {
754
754
  summaryInputTokens: loops.length > 0 ? usage.summaryInputTokens : Number(trace?.summaryInputTokens || 0),
755
755
  summaryOutputTokens: loops.length > 0 ? usage.summaryOutputTokens : Number(trace?.summaryOutputTokens || 0),
756
756
  loopCount: loops.length > 0 ? loops.length : Number(trace?.loopCount || 0),
757
- memoryLoaded: null,
758
- memoryAdjust: null,
757
+ memoryLoaded: Array.isArray(trace?.memoryLoaded) ? cloneJsonValue(trace.memoryLoaded) : null,
758
+ memoryLoadedMeta: trace?.memoryLoadedMeta && typeof trace.memoryLoadedMeta === 'object'
759
+ ? cloneJsonValue(trace.memoryLoadedMeta)
760
+ : null,
761
+ memoryAdjust: trace?.memoryAdjust && typeof trace.memoryAdjust === 'object'
762
+ ? cloneJsonValue(trace.memoryAdjust)
763
+ : null,
759
764
  tools: Array.isArray(trace?.tools) ? trace.tools.map(t => ({
760
765
  loopNumber: t.loopNumber || 0,
761
766
  callId: t.toolCallId || t.id || null,
@@ -910,7 +915,7 @@ export function projectDebugDetailForWire(detail, maxBytes = DEBUG_DETAIL_WIRE_M
910
915
  }
911
916
  for (const [turnIndex, turn] of (Array.isArray(wire.turns) ? wire.turns : []).entries()) {
912
917
  if (!turn || typeof turn !== 'object') continue;
913
- for (const field of ['userPrompt', 'memoryLoaded', 'memoryAdjust']) {
918
+ for (const field of ['userPrompt', 'memoryLoaded', 'memoryLoadedMeta', 'memoryAdjust']) {
914
919
  addCandidate(turn, field, `turns[${turnIndex}].${field}`);
915
920
  }
916
921
  for (const [toolIndex, tool] of (Array.isArray(turn.tools) ? turn.tools : []).entries()) {
@@ -1212,7 +1217,7 @@ export class DebugTrace {
1212
1217
  this.#textMaxBytes = normalizeTextMaxBytes(nextValue, this.#textMaxBytes);
1213
1218
  }
1214
1219
 
1215
- startTurn({ traceId, messageId = null, mode = null, turnNumber = null, sessionId = null, vpId = null, threadId = null, userPrompt = null } = {}) {
1220
+ startTurn({ traceId, messageId = null, mode = null, turnNumber = null, sessionId = null, vpId = null, threadId = null, userPrompt = null, memoryLoaded = null, memoryLoadedMeta = null } = {}) {
1216
1221
  if (!this.#acceptingWrites) return 'null';
1217
1222
  const turnRowId = randomUUID();
1218
1223
  const now = Date.now();
@@ -1225,6 +1230,8 @@ export class DebugTrace {
1225
1230
  vpId,
1226
1231
  threadId,
1227
1232
  userPrompt,
1233
+ memoryLoaded,
1234
+ memoryLoadedMeta,
1228
1235
  now,
1229
1236
  turnRowId,
1230
1237
  });
@@ -1584,7 +1591,7 @@ export class DebugTrace {
1584
1591
  await this.#drainWrites();
1585
1592
  }
1586
1593
 
1587
- #getOrCreateRequest({ traceId, turnNumber, messageId, mode, sessionId, vpId, threadId, userPrompt, now, turnRowId }) {
1594
+ #getOrCreateRequest({ traceId, turnNumber, messageId, mode, sessionId, vpId, threadId, userPrompt, memoryLoaded, memoryLoadedMeta, now, turnRowId }) {
1588
1595
  const normalizedSessionId = sessionId || null;
1589
1596
  const isUsableExisting = (t) => (
1590
1597
  t?.sessionId === normalizedSessionId
@@ -1604,6 +1611,12 @@ export class DebugTrace {
1604
1611
  .at(-1) || null;
1605
1612
  if (existing) {
1606
1613
  existing.updatedAt = now;
1614
+ if (!Array.isArray(existing.memoryLoaded) && Array.isArray(memoryLoaded)) {
1615
+ existing.memoryLoaded = cloneJsonValue(memoryLoaded);
1616
+ }
1617
+ if (!existing.memoryLoadedMeta && memoryLoadedMeta && typeof memoryLoadedMeta === 'object') {
1618
+ existing.memoryLoadedMeta = cloneJsonValue(memoryLoadedMeta);
1619
+ }
1607
1620
  this.#requestCache.set(existing.requestKey, existing);
1608
1621
  return existing;
1609
1622
  }
@@ -1621,6 +1634,11 @@ export class DebugTrace {
1621
1634
  vpId: vpId || null,
1622
1635
  threadId: threadId || null,
1623
1636
  userPrompt: truncateText(userPrompt || '', this.#textMaxBytes),
1637
+ memoryLoaded: Array.isArray(memoryLoaded) ? cloneJsonValue(memoryLoaded) : null,
1638
+ memoryLoadedMeta: memoryLoadedMeta && typeof memoryLoadedMeta === 'object'
1639
+ ? cloneJsonValue(memoryLoadedMeta)
1640
+ : null,
1641
+ memoryAdjust: null,
1624
1642
  openedAt: now,
1625
1643
  closedAt: null,
1626
1644
  updatedAt: now,
@@ -20,6 +20,11 @@ export const MAX_SINGLE_MESSAGE_CHARS = 8000;
20
20
  export const MAX_DREAM_PROMPT_CHARS = 96000;
21
21
  export const MAX_DIFF_TOKENS_PER_TRIAGE = 60000;
22
22
  export const MAX_APPLY_TOKENS = 80000;
23
+ // Dream writes compact canonical memory, not long-form user output. Keep the
24
+ // response large enough for structured updates while bounding runaway cost.
25
+ export const MAX_DREAM_OUTPUT_TOKENS = 8192;
26
+ export const DREAM_FAILURE_BACKOFF_HOURS = 2;
27
+ export const DREAM_FAILURE_BACKOFF_MAX_HOURS = 24;
23
28
  export const DREAM_BACKUP_KEEP = 7;
24
29
 
25
30
  // task-710: nudge dream off the 1h timer when a group has accumulated
@@ -35,6 +40,9 @@ export const DEFAULT_LIMITS = Object.freeze({
35
40
  MAX_DREAM_PROMPT_CHARS,
36
41
  MAX_DIFF_TOKENS_PER_TRIAGE,
37
42
  MAX_APPLY_TOKENS,
43
+ MAX_DREAM_OUTPUT_TOKENS,
44
+ DREAM_FAILURE_BACKOFF_HOURS,
45
+ DREAM_FAILURE_BACKOFF_MAX_HOURS,
38
46
  DREAM_BACKUP_KEEP,
39
47
  DREAM_NUDGE_AFTER_MESSAGES,
40
48
  });
@@ -150,6 +150,17 @@ export async function runDream(opts) {
150
150
  sessionsReport.push({ sessionId, new: newCount, status: 'skipped', reason: 'below-threshold' });
151
151
  continue;
152
152
  }
153
+ const retryAt = dreamRetryAt(state, limits);
154
+ if (!opts.manual && retryAt && Date.parse(nowIso) < retryAt) {
155
+ sessionsReport.push({
156
+ sessionId,
157
+ new: newCount,
158
+ status: 'skipped',
159
+ reason: 'failure-backoff',
160
+ retryAt: new Date(retryAt).toISOString(),
161
+ });
162
+ continue;
163
+ }
153
164
 
154
165
  onProgress({ phase: 'load-diff', sessionId });
155
166
  const diffCursor = state.lastDreamMessageId;
@@ -182,6 +193,8 @@ export async function runDream(opts) {
182
193
  lastDreamMessageId: tailId,
183
194
  lastDreamAt: nowIso,
184
195
  messageCount: beforeCount,
196
+ failureCount: 0,
197
+ lastFailureAt: null,
185
198
  });
186
199
  }
187
200
  sessionsReport.push({ sessionId, new: newCount, status: 'skipped', reason: 'no-durable-new-messages' });
@@ -221,6 +234,7 @@ export async function runDream(opts) {
221
234
  stack: err.stack,
222
235
  rawSnippet: err.rawSnippet,
223
236
  });
237
+ await recordDreamFailure(opts.root, sessionId, state, nowIso);
224
238
  continue;
225
239
  }
226
240
 
@@ -232,7 +246,7 @@ export async function runDream(opts) {
232
246
  });
233
247
 
234
248
  const tailId = lastMessageId(diffNew);
235
- processedSessions.push({ sessionId, tailId, beforeCount, newCount, segments: segments.length, actions: actions.length });
249
+ processedSessions.push({ sessionId, tailId, beforeCount, newCount, state, segments: segments.length, actions: actions.length });
236
250
  sessionsReport.push({ sessionId, new: newCount, segments: segments.length, actions: actions.length, status: 'triaged' });
237
251
  }
238
252
 
@@ -304,9 +318,21 @@ export async function runDream(opts) {
304
318
  nowIso: opts.nowIso || (() => nowIso),
305
319
  segmentIndex: opts.segmentIndex || null,
306
320
  });
307
- await clearDreamError(opts.root, `sessions/${triage.sessionId}`);
308
- segmentReports.push({ sessionId: triage.sessionId, status: 'done', ...r });
309
- onProgress({ phase: 'extract-segments', sessionId: triage.sessionId, status: 'done', ...r });
321
+ if (r.errors.length > 0) {
322
+ const status = r.successfulScopes > 0 ? 'partial' : 'error';
323
+ const error = summarizeExtractionErrors(r.errors);
324
+ segmentReports.push({ sessionId: triage.sessionId, status, ...r, error });
325
+ onProgress({ phase: 'extract-segments', sessionId: triage.sessionId, status, ...r, error });
326
+ await writeDreamError(opts.root, `sessions/${triage.sessionId}`, {
327
+ phase: 'extract-segments',
328
+ message: error,
329
+ rawSnippet: r.errors.find(item => item.rawSnippet)?.rawSnippet,
330
+ });
331
+ } else {
332
+ await clearDreamError(opts.root, `sessions/${triage.sessionId}`);
333
+ segmentReports.push({ sessionId: triage.sessionId, status: 'done', ...r });
334
+ onProgress({ phase: 'extract-segments', sessionId: triage.sessionId, status: 'done', ...r });
335
+ }
310
336
  } catch (err) {
311
337
  segmentReports.push({ sessionId: triage.sessionId, status: 'error', error: err.message });
312
338
  onProgress({ phase: 'extract-segments', sessionId: triage.sessionId, status: 'error', error: err.message });
@@ -349,18 +375,22 @@ export async function runDream(opts) {
349
375
  }
350
376
  }
351
377
 
352
- // 7. bookkeep — advance a Session cursor only after every Apply target
353
- // selected for that Session in this run completed successfully. Apply keeps
354
- // all batch output in memory until the target's final canonical write, so a
355
- // failed target has no durable progress that can safely move the cursor.
356
- // Keeping the cursor on any target error lets the next run retry the failed
357
- // target together with the same source diff.
378
+ // 7. bookkeep — advance a Session cursor only after every Apply target and
379
+ // its provenance extraction completed successfully. Apply keeps all batch
380
+ // output in memory until the target's final canonical write, while extraction
381
+ // reports per-scope failures after any successful writes. Either failure must
382
+ // retain the source diff so the next run can retry it rather than permanently
383
+ // losing canonical content or evidence.
358
384
  const targetStatus = new Map(targetsReport.map(report => [report.target, report.status]));
385
+ const extractionStatus = new Map(segmentReports.map(report => [report.sessionId, report.status]));
359
386
  for (const pg of processedSessions) {
360
387
  const sessionTargets = targetsToApply
361
388
  .filter(target => target.sources.some(source => source.sessionId === pg.sessionId))
362
389
  .map(target => target.target);
363
- if (sessionTargets.length === 0 || !sessionTargets.every(target => targetStatus.get(target) === 'done')) {
390
+ const applySucceeded = sessionTargets.length > 0
391
+ && sessionTargets.every(target => targetStatus.get(target) === 'done');
392
+ if (!applySucceeded || extractionStatus.get(pg.sessionId) !== 'done') {
393
+ await recordDreamFailure(opts.root, pg.sessionId, pg.state, nowIso);
364
394
  continue;
365
395
  }
366
396
  if (pg.tailId) {
@@ -368,6 +398,8 @@ export async function runDream(opts) {
368
398
  lastDreamMessageId: pg.tailId,
369
399
  lastDreamAt: nowIso,
370
400
  messageCount: pg.beforeCount,
401
+ failureCount: 0,
402
+ lastFailureAt: null,
371
403
  });
372
404
  }
373
405
  }
@@ -399,6 +431,35 @@ export async function runDream(opts) {
399
431
 
400
432
  // ─── helpers ──────────────────────────────────────────────────
401
433
 
434
+ function summarizeExtractionErrors(errors) {
435
+ const details = errors
436
+ .slice(0, 3)
437
+ .map(item => `${item.scope}: ${String(item.error || 'unknown error').slice(0, 300)}`)
438
+ .join('; ');
439
+ return `Dream segment extraction failed for ${errors.length} scope(s): ${details}`;
440
+ }
441
+
442
+ function dreamRetryAt(state, limits) {
443
+ const failureCount = Math.max(0, Number(state?.failureCount) || 0);
444
+ const failedAt = Date.parse(state?.lastFailureAt || '');
445
+ if (failureCount === 0 || !Number.isFinite(failedAt)) return null;
446
+ const hours = Math.min(
447
+ limits.DREAM_FAILURE_BACKOFF_MAX_HOURS,
448
+ limits.DREAM_FAILURE_BACKOFF_HOURS * (2 ** Math.max(0, failureCount - 1)),
449
+ );
450
+ return failedAt + hours * 60 * 60 * 1000;
451
+ }
452
+
453
+ async function recordDreamFailure(root, sessionId, state, nowIso) {
454
+ await writeSessionState(root, sessionId, {
455
+ lastDreamMessageId: state?.lastDreamMessageId || null,
456
+ lastDreamAt: state?.lastDreamAt || null,
457
+ messageCount: Number(state?.messageCount) || 0,
458
+ failureCount: Math.max(0, Number(state?.failureCount) || 0) + 1,
459
+ lastFailureAt: nowIso,
460
+ });
461
+ }
462
+
402
463
  function lastMessageId(messages) {
403
464
  if (!Array.isArray(messages) || messages.length === 0) return null;
404
465
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -50,13 +50,16 @@ export async function extractAndWriteMemorySegments(opts) {
50
50
  if (typeof opts.llm !== 'function') throw new Error('extractAndWriteMemorySegments: llm required');
51
51
 
52
52
  const messages = normalizeMessages(opts.messages || []);
53
- if (messages.length === 0) return { scopes: 0, segments: 0, errors: [] };
53
+ if (messages.length === 0) {
54
+ return { scopes: 0, segments: 0, attemptedScopes: 0, successfulScopes: 0, errors: [] };
55
+ }
54
56
  const allowedSourceIds = new Set(messages.map(message => message.id));
55
57
 
56
58
  const targetScopes = normalizeTargetScopes(opts.sessionId, opts.targets || []);
57
59
  const now = opts.nowIso ? opts.nowIso() : new Date().toISOString();
58
60
  let segmentCount = 0;
59
61
  let scopeCount = 0;
62
+ let successfulScopes = 0;
60
63
  const errors = [];
61
64
 
62
65
  for (const scope of targetScopes) {
@@ -72,6 +75,7 @@ export async function extractAndWriteMemorySegments(opts) {
72
75
  allowedSourceIds,
73
76
  maxPromptChars: opts.limits?.MAX_DREAM_PROMPT_CHARS,
74
77
  });
78
+ successfulScopes += 1;
75
79
  } catch (err) {
76
80
  errors.push({ scope, error: err.message, rawSnippet: err.rawSnippet || '' });
77
81
  }
@@ -92,7 +96,13 @@ export async function extractAndWriteMemorySegments(opts) {
92
96
  scopeCount += 1;
93
97
  }
94
98
 
95
- return { scopes: scopeCount, segments: segmentCount, errors };
99
+ return {
100
+ scopes: scopeCount,
101
+ segments: segmentCount,
102
+ attemptedScopes: targetScopes.length,
103
+ successfulScopes,
104
+ errors,
105
+ };
96
106
  }
97
107
 
98
108
  async function extractScopeSegments({ scope, sessionId, messages, llm, language, now, allowedSourceIds, maxPromptChars }) {
@@ -44,6 +44,7 @@ import { runDream } from './runner.js';
44
44
  import { createDreamScheduler } from './schedule.js';
45
45
  import { parseMessage, parseSeqFromId } from '../conversation/persist.js';
46
46
  import { loadSessionConfig, resolveSessionConfig } from '../sessions/session-config.js';
47
+ import { parseModelRef, resolveMaxOutputTokens, resolveModel } from '../models.js';
47
48
  import { listSessions as listSessionMetas } from '../sessions/session-store.js';
48
49
  import { readSessionState } from './state.js';
49
50
  import { boundDreamPrompt } from './segment.js';
@@ -380,12 +381,16 @@ function makeLlm(session) {
380
381
  const dreamLimits = loadLimitsFromConfig(effectiveConfig);
381
382
  const boundedPrompt = boundDreamPrompt(prompt, dreamLimits.MAX_DREAM_PROMPT_CHARS);
382
383
 
384
+ const { modelId } = parseModelRef(model);
385
+ const modelMaxOutput = resolveMaxOutputTokens(modelId, effectiveConfig);
386
+ const maxTokens = Math.min(modelMaxOutput, dreamLimits.MAX_DREAM_OUTPUT_TOKENS);
383
387
  const r = await adapter.call({
384
388
  model,
385
389
  system: effectiveSystem,
386
390
  messages: [{ role: 'user', content: boundedPrompt }],
387
- maxTokens: 2048,
388
- modelEffort: effectiveConfig?.modelEffort || undefined,
391
+ maxTokens,
392
+ effort: effectiveConfig?.modelEffort || undefined,
393
+ effortSource: effectiveConfig?.modelEffort ? 'user' : undefined,
389
394
  });
390
395
 
391
396
  // Emit complete loop event (request + response) to the debug panel.
@@ -417,7 +422,7 @@ function makeLlm(session) {
417
422
  usage,
418
423
  latencyMs,
419
424
  ttfbMs: null,
420
- stopReason: 'end_turn',
425
+ stopReason: r?.stopReason || 'end_turn',
421
426
  rawRequest: null,
422
427
  rawResponse: null,
423
428
  });
@@ -426,6 +431,13 @@ function makeLlm(session) {
426
431
  session._dreamProgressSink(loopEvent);
427
432
  }
428
433
 
434
+ if (r?.stopReason === 'max_tokens') {
435
+ const err = new Error(`Dream ${pass} response exceeded the ${maxTokens}-token output limit`);
436
+ err.code = 'DREAM_OUTPUT_TRUNCATED';
437
+ err.rawSnippet = typeof r.text === 'string' ? r.text.slice(-1000) : '';
438
+ throw err;
439
+ }
440
+
429
441
  return (r && r.text) ? r.text : '';
430
442
  };
431
443
  }
@@ -433,10 +445,34 @@ function makeLlm(session) {
433
445
 
434
446
  function resolveDreamSessionConfig(session, sessionId) {
435
447
  const base = session?.config || {};
436
- if (!sessionId || !session?.yeaftDir) return { ...base };
437
- const sessionConfig = loadSessionConfig(session.yeaftDir, sessionId);
438
- if (!sessionConfig || Object.keys(sessionConfig).length === 0) return { ...base };
439
- return resolveSessionConfig(base, sessionConfig);
448
+ const sessionConfig = sessionId && session?.yeaftDir
449
+ ? loadSessionConfig(session.yeaftDir, sessionId)
450
+ : null;
451
+ const effective = sessionConfig && Object.keys(sessionConfig).length > 0
452
+ ? resolveSessionConfig(base, sessionConfig)
453
+ : { ...base };
454
+ const model = effective.primaryModel || effective.model;
455
+ if (!model) return effective;
456
+
457
+ const { providerName, modelId } = parseModelRef(model);
458
+ const catalogEntry = Array.isArray(effective.availableModels)
459
+ ? effective.availableModels.find(entry => entry?.ref === model
460
+ || (entry?.id === modelId && (!providerName || entry?.provider === providerName)))
461
+ : null;
462
+ const registryInfo = resolveModel(modelId);
463
+ const modelInfo = {
464
+ ...(registryInfo || {}),
465
+ ...(catalogEntry || {}),
466
+ };
467
+
468
+ // The live Session object belongs to the Session that triggered Dream. A
469
+ // target Session may select another model, so its cached modelInfo and token
470
+ // limits are not valid here. Rebuild modelInfo from the target catalog entry
471
+ // and let the resolver use the bare model id for registry/models.dev lookup.
472
+ return {
473
+ ...effective,
474
+ modelInfo: Object.keys(modelInfo).length > 0 ? modelInfo : null,
475
+ };
440
476
  }
441
477
 
442
478
 
@@ -8,11 +8,13 @@
8
8
  *
9
9
  * ~/.yeaft/memory/sessions/<id>/.dream-state
10
10
  *
11
- * A 3-line text file:
11
+ * A small line-oriented text file:
12
12
  *
13
13
  * lastDreamMessageId: m-1024
14
14
  * lastDreamAt: 2026-04-28T03:07:00Z
15
15
  * messageCount: 491
16
+ * failureCount: 0
17
+ * lastFailureAt:
16
18
  *
17
19
  * Fields are independent of each other; missing fields default to
18
20
  * empty / null / 0. The file is rewritten atomically every dream.
@@ -60,11 +62,17 @@ const DREAM_BLOCK_CLOSE = '<!-- /dream-state -->';
60
62
  *
61
63
  * @param {string} root — memory root, e.g. ~/.yeaft/memory
62
64
  * @param {string} sessionId
63
- * @returns {Promise<{ lastDreamMessageId: string|null, lastDreamAt: string|null, messageCount: number }>}
65
+ * @returns {Promise<{ lastDreamMessageId: string|null, lastDreamAt: string|null, messageCount: number, failureCount: number, lastFailureAt: string|null }>}
64
66
  */
65
67
  export async function readSessionState(root, sessionId) {
66
68
  const abs = join(root, 'sessions', sessionId, STATE_FILE);
67
- const empty = { lastDreamMessageId: null, lastDreamAt: null, messageCount: 0 };
69
+ const empty = {
70
+ lastDreamMessageId: null,
71
+ lastDreamAt: null,
72
+ messageCount: 0,
73
+ failureCount: 0,
74
+ lastFailureAt: null,
75
+ };
68
76
  let raw;
69
77
  try { raw = await fsp.readFile(abs, 'utf8'); }
70
78
  catch (err) {
@@ -80,16 +88,20 @@ export async function readSessionState(root, sessionId) {
80
88
  *
81
89
  * @param {string} root
82
90
  * @param {string} sessionId
83
- * @param {{ lastDreamMessageId?: string|null, lastDreamAt?: string|null, messageCount?: number }} state
91
+ * @param {{ lastDreamMessageId?: string|null, lastDreamAt?: string|null, messageCount?: number, failureCount?: number, lastFailureAt?: string|null }} state
84
92
  */
85
93
  export async function writeSessionState(root, sessionId, state) {
86
94
  const dir = join(root, 'sessions', sessionId);
87
95
  await fsp.mkdir(dir, { recursive: true });
88
96
  const abs = join(dir, STATE_FILE);
97
+ const current = existsSync(abs) ? await readSessionState(root, sessionId) : {};
98
+ const merged = { ...current, ...state };
89
99
  const body =
90
- `lastDreamMessageId: ${state.lastDreamMessageId == null ? '' : state.lastDreamMessageId}\n` +
91
- `lastDreamAt: ${state.lastDreamAt == null ? '' : state.lastDreamAt}\n` +
92
- `messageCount: ${Number.isFinite(state.messageCount) ? state.messageCount : 0}\n`;
100
+ `lastDreamMessageId: ${merged.lastDreamMessageId == null ? '' : merged.lastDreamMessageId}\n` +
101
+ `lastDreamAt: ${merged.lastDreamAt == null ? '' : merged.lastDreamAt}\n` +
102
+ `messageCount: ${Number.isFinite(merged.messageCount) ? merged.messageCount : 0}\n` +
103
+ `failureCount: ${Number.isFinite(merged.failureCount) ? Math.max(0, Math.floor(merged.failureCount)) : 0}\n` +
104
+ `lastFailureAt: ${merged.lastFailureAt == null ? '' : merged.lastFailureAt}\n`;
93
105
  await atomicWrite(abs, body);
94
106
  }
95
107
 
@@ -99,7 +111,13 @@ export async function writeSessionState(root, sessionId, state) {
99
111
  * @param {string} raw
100
112
  */
101
113
  function parseSessionState(raw) {
102
- const out = { lastDreamMessageId: null, lastDreamAt: null, messageCount: 0 };
114
+ const out = {
115
+ lastDreamMessageId: null,
116
+ lastDreamAt: null,
117
+ messageCount: 0,
118
+ failureCount: 0,
119
+ lastFailureAt: null,
120
+ };
103
121
  const lines = String(raw || '').split(/\r?\n/);
104
122
  for (const ln of lines) {
105
123
  const m = /^(\w[\w-]*)\s*:\s*(.*)$/.exec(ln);
@@ -111,7 +129,10 @@ function parseSessionState(raw) {
111
129
  else if (k === 'messageCount') {
112
130
  const n = Number(v);
113
131
  out.messageCount = Number.isFinite(n) ? n : 0;
114
- }
132
+ } else if (k === 'failureCount') {
133
+ const n = Number(v);
134
+ out.failureCount = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
135
+ } else if (k === 'lastFailureAt') out.lastFailureAt = v || null;
115
136
  }
116
137
  return out;
117
138
  }
package/yeaft/engine.js CHANGED
@@ -2177,6 +2177,12 @@ export class Engine {
2177
2177
  memoryInjection = amsContext.snapshotBlock;
2178
2178
  }
2179
2179
  const loadedMemoryForDebug = loadedMemoryDebugEntries(amsContext?.snapshot);
2180
+ const loadedMemoryMetaForDebug = {
2181
+ recallLimit: resolveMemoryRecallLimit(this.#config),
2182
+ recallCandidates: Number.isFinite(recallResult?.meta?.hitCount)
2183
+ ? recallResult.meta.hitCount
2184
+ : (recallResult && Array.isArray(recallResult.entries) ? recallResult.entries.length : 0),
2185
+ };
2180
2186
 
2181
2187
  // Diagnostic payload for the Dream debug panel. The full AMS Resident
2182
2188
  // layer can include user and per-VP summaries, but the browser-facing
@@ -2443,12 +2449,7 @@ export class Engine {
2443
2449
  type: 'memory_used',
2444
2450
  turnId: queryTurnId,
2445
2451
  loaded: loadedMemoryForDebug,
2446
- meta: {
2447
- recallLimit: resolveMemoryRecallLimit(this.#config),
2448
- recallCandidates: Number.isFinite(recallResult?.meta?.hitCount)
2449
- ? recallResult.meta.hitCount
2450
- : (recallResult && Array.isArray(recallResult.entries) ? recallResult.entries.length : 0),
2451
- },
2452
+ meta: loadedMemoryMetaForDebug,
2452
2453
  };
2453
2454
  }
2454
2455
 
@@ -2580,6 +2581,10 @@ export class Engine {
2580
2581
  // from `messages.find(role==='user')` would always return turn
2581
2582
  // 1's prompt and mislabel every subsequent Turn header.
2582
2583
  userPrompt: userQuestionPreview,
2584
+ // Persist the exact AMS projection that produced memoryInjection. Live
2585
+ // memory_used events are only progress signals and disappear on reload.
2586
+ memoryLoaded: loadedMemoryForDebug,
2587
+ memoryLoadedMeta: loadedMemoryMetaForDebug,
2583
2588
  });
2584
2589
 
2585
2590
  const startTime = Date.now();
@@ -651,7 +651,7 @@ export class LLMAdapter {
651
651
  * Make a single model call without tools (for side queries like summarization).
652
652
  *
653
653
  * @param {{ model: string, system: string, messages: UnifiedMessage[], maxTokens?: number, signal?: AbortSignal }} params
654
- * @returns {Promise<{ text: string, usage: { inputTokens: number, outputTokens: number } }>}
654
+ * @returns {Promise<{ text: string, stopReason?: 'end_turn'|'tool_use'|'max_tokens', usage: { inputTokens: number, outputTokens: number } }>}
655
655
  */
656
656
  async call(params) { // eslint-disable-line no-unused-vars
657
657
  throw new Error('call() must be implemented by subclass');
@@ -581,6 +581,7 @@ export class AnthropicAdapter extends LLMAdapter {
581
581
 
582
582
  return {
583
583
  text,
584
+ stopReason: this.#mapStopReason(result.stop_reason),
584
585
  usage: {
585
586
  inputTokens: result.usage?.input_tokens || 0,
586
587
  outputTokens: result.usage?.output_tokens || 0,
@@ -594,6 +594,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
594
594
  const usage = result.usage || {};
595
595
  return {
596
596
  text,
597
+ stopReason: this.#mapStopReason(result, false),
597
598
  usage: {
598
599
  inputTokens: usage.input_tokens || 0,
599
600
  outputTokens: usage.output_tokens || 0,
@@ -668,7 +668,7 @@ export class AdapterRouter extends LLMAdapter {
668
668
  * Make a single model call — routes to the correct provider adapter.
669
669
  *
670
670
  * @param {import('./adapter.js').CallParams} params
671
- * @returns {Promise<{ text: string, usage: { inputTokens: number, outputTokens: number } }>}
671
+ * @returns {Promise<{ text: string, stopReason?: 'end_turn'|'tool_use'|'max_tokens', usage: { inputTokens: number, outputTokens: number } }>}
672
672
  */
673
673
  async call(params) {
674
674
  const dispatchSnapshot = this.#captureDispatchSnapshot();