@yeaft/webchat-agent 0.1.778 → 0.1.780

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.778",
3
+ "version": "0.1.780",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -163,7 +163,7 @@ export async function applyMergedTarget(merged, opts) {
163
163
  await snapFn(opts.root, ts, scopeDirRel);
164
164
 
165
165
  let memoryMd = await readMemory(scope, { root: opts.root });
166
- let summaryMd = await readSummary(scope, { root: opts.root });
166
+ let summaryMd = await readSummary(scope, { root: opts.root, language: opts.language });
167
167
 
168
168
  if (merged.kind === 'create' && (memoryMd || summaryMd)) {
169
169
  // Race / partial state: the scope already exists. Treat as update —
@@ -226,7 +226,7 @@ export async function applyMergedTarget(merged, opts) {
226
226
  // Stamp the per-scope dream marker, then atomically write both files.
227
227
  const stamped = withDreamMarker(memoryMd, { lastDreamAt: nowIso });
228
228
  await writeMemory(scope, stamped, { root: opts.root });
229
- await writeSummary(scope, summaryMd || '', { root: opts.root });
229
+ await writeSummary(scope, summaryMd || '', { root: opts.root, language: opts.language });
230
230
 
231
231
  if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'done', batches: batchesUsed });
232
232
  return { target: merged.target, kind: merged.kind, batches: batchesUsed };
@@ -88,6 +88,7 @@ export async function runDream(opts) {
88
88
  // 1. enumerate groups
89
89
  const groupIds = await safeCall(opts.listGroups, []);
90
90
  const filter = Array.isArray(opts.scopeFilter) ? new Set(opts.scopeFilter) : null;
91
+ const groupFilter = deriveGroupFilter(filter);
91
92
  const groupsReport = [];
92
93
  const groupTriages = [];
93
94
  const processedGroups = [];
@@ -95,12 +96,18 @@ export async function runDream(opts) {
95
96
  // 2. per-group: skip / segment / triage
96
97
  const topicSummaries = opts.listTopicSummaries
97
98
  ? await safeCall(opts.listTopicSummaries, [])
98
- : await defaultListTopicSummaries(opts.root).catch(() => []);
99
+ : await defaultListTopicSummaries(opts.root, opts.language).catch(() => []);
99
100
 
100
101
  for (const groupId of groupIds) {
101
- // NOTE: scopeFilter is applied at the merge/apply stage, not here.
102
- // A filter like ['user'] still requires triaging every group so their
103
- // hard-rule actions can contribute to the user target.
102
+ // Current-group manual dream passes are the one case where scopeFilter
103
+ // must constrain enumeration too: clicking the conversation header means
104
+ // "dream this group now", not "triage every group and then only apply
105
+ // group/<id>". Pure target filters such as ['user'] still triage every
106
+ // group so their hard-rule actions can contribute to the requested scope.
107
+ if (groupFilter && !groupFilter.has(groupId)) {
108
+ groupsReport.push({ groupId, new: 0, status: 'skipped', reason: 'scope-filtered' });
109
+ continue;
110
+ }
104
111
  const state = await readGroupState(opts.root, groupId);
105
112
  const beforeCount = await safeCall(() => opts.countMessages(groupId), 0);
106
113
  const newCount = Math.max(0, beforeCount - (state.messageCount || 0));
@@ -170,7 +177,7 @@ export async function runDream(opts) {
170
177
  // 3. merge
171
178
  const mergedTargets = mergeByTarget(groupTriages);
172
179
  const targetsToApply = filter && filter.size > 0 && !filter.has('*')
173
- ? mergedTargets.filter(t => filter.has(t.target))
180
+ ? mergedTargets.filter(t => filter.has(t.target) || filter.has(`group/${sourceGroupId(t)}`))
174
181
  : mergedTargets;
175
182
 
176
183
  onProgress({ phase: 'merge', targets: targetsToApply.length });
@@ -263,6 +270,22 @@ function lastMessageId(messages) {
263
270
  return null;
264
271
  }
265
272
 
273
+ function deriveGroupFilter(filter) {
274
+ if (!filter || filter.size === 0 || filter.has('*')) return null;
275
+ const groups = [];
276
+ for (const scope of filter) {
277
+ if (typeof scope !== 'string') continue;
278
+ const m = /^group\/([^/]+)$/.exec(scope);
279
+ if (m && m[1]) groups.push(m[1]);
280
+ }
281
+ return groups.length > 0 ? new Set(groups) : null;
282
+ }
283
+
284
+ function sourceGroupId(mergedTarget) {
285
+ const src = Array.isArray(mergedTarget?.sources) ? mergedTarget.sources[0] : null;
286
+ return src && typeof src.groupId === 'string' ? src.groupId : '';
287
+ }
288
+
266
289
  async function safeCall(fn, fallback) {
267
290
  try {
268
291
  if (typeof fn !== 'function') return fallback;
@@ -273,12 +296,12 @@ async function safeCall(fn, fallback) {
273
296
  }
274
297
  }
275
298
 
276
- async function defaultListTopicSummaries(root) {
299
+ async function defaultListTopicSummaries(root, language) {
277
300
  const all = await listScopes({ root });
278
301
  const out = [];
279
302
  for (const sc of all) {
280
303
  if (sc.kind !== 'topic') continue;
281
- const summary = await readSummary(sc, { root });
304
+ const summary = await readSummary(sc, { root, language });
282
305
  out.push({ path: sc.path.join('/'), summary });
283
306
  }
284
307
  return out;
package/unify/engine.js CHANGED
@@ -216,6 +216,10 @@ export function buildResidentEntries(args) {
216
216
  return out;
217
217
  }
218
218
 
219
+ function isZhRuntimeLanguage(language) {
220
+ return String(language || '').toLowerCase().startsWith('zh');
221
+ }
222
+
219
223
  export class Engine {
220
224
  /** @type {import('./llm/adapter.js').LLMAdapter} */
221
225
  #adapter;
@@ -326,10 +330,12 @@ export class Engine {
326
330
  * config: object,
327
331
  * conversationStore?: import('./conversation/persist.js').ConversationStore,
328
332
  * memoryIndex?: import('./memory/index-db.js').SegmentIndex,
333
+ * amsRegistry?: object,
329
334
  * toolRegistry?: import('./tools/registry.js').ToolRegistry,
330
335
  * skillManager?: import('./skills.js').SkillManager,
331
336
  * mcpManager?: import('./mcp.js').MCPManager,
332
337
  * yeaftDir?: string,
338
+ * toolStats?: import('./stats/tool-usage.js').ToolUsageStats,
333
339
  * }} params
334
340
  */
335
341
  constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null }) {
@@ -476,19 +482,19 @@ export class Engine {
476
482
  * dream tick (Phase 6) is what populates these; on a fresh install they
477
483
  * all return ''.
478
484
  *
479
- * @param {{groupId?: string, vpId?: string}} ctx
485
+ * @param {{groupId?: string, vpId?: string, language?: string}} ctx
480
486
  * @returns {Promise<{user:string, group:string, vp:string}>}
481
487
  */
482
- async #loadLayerASummaries({ groupId, vpId } = {}) {
488
+ async #loadLayerASummaries({ groupId, vpId, language } = {}) {
483
489
  if (!this.#yeaftDir) return { user: '', group: '', vp: '' };
484
490
  const memoryRoot = `${this.#yeaftDir}/memory`;
485
491
  const tasks = [
486
- readScopeSummary({ kind: 'user' }, { root: memoryRoot }).catch(() => ''),
492
+ readScopeSummary({ kind: 'user' }, { root: memoryRoot, language }).catch(() => ''),
487
493
  groupId
488
- ? readScopeSummary({ kind: 'group', id: groupId }, { root: memoryRoot }).catch(() => '')
494
+ ? readScopeSummary({ kind: 'group', id: groupId }, { root: memoryRoot, language }).catch(() => '')
489
495
  : Promise.resolve(''),
490
496
  vpId
491
- ? readScopeSummary({ kind: 'vp', id: vpId }, { root: memoryRoot }).catch(() => '')
497
+ ? readScopeSummary({ kind: 'vp', id: vpId }, { root: memoryRoot, language }).catch(() => '')
492
498
  : Promise.resolve(''),
493
499
  ];
494
500
  const [user, group, vp] = await Promise.all(tasks);
@@ -542,7 +548,7 @@ export class Engine {
542
548
  ams.setOnDemand(segs);
543
549
 
544
550
  // (c) Snapshot — render the AMS layers as a single prompt block.
545
- const snapshotBlock = this.#renderAmsSnapshot(ams);
551
+ const snapshotBlock = this.#renderAmsSnapshot(ams, this.#config.language || 'en');
546
552
 
547
553
  const scopes = buildRelevantScopes({
548
554
  groupId: args.groupId,
@@ -558,30 +564,35 @@ export class Engine {
558
564
  * so the LLM sees a consistent layout.
559
565
  *
560
566
  * @param {import('./memory/ams.js').ActiveMemorySet} ams
567
+ * @param {string} [language]
561
568
  * @returns {string}
562
569
  */
563
- #renderAmsSnapshot(ams) {
570
+ #renderAmsSnapshot(ams, language = 'en') {
564
571
  const snap = ams.snapshot();
565
572
  if (!snap) return '';
566
573
  const parts = [];
567
574
  if (snap.resident.length === 0 && snap.recent.length === 0 && snap.onDemand.length === 0) {
568
575
  return '';
569
576
  }
570
- parts.push('## Active Memory Set');
577
+ const zh = isZhRuntimeLanguage(language);
578
+ parts.push(zh ? '## 活跃记忆集' : '## Active Memory Set');
579
+ parts.push(zh
580
+ ? '以下记忆按当前用户语言呈现;如果个别历史摘要仍是其他语言,请只把它当作事实来源,回答和新增记忆应使用中文。'
581
+ : 'Memory is presented for the current user language; if an older summary is in another language, treat it as factual context and continue in English.');
571
582
  if (snap.resident.length > 0) {
572
- parts.push('### Resident');
583
+ parts.push(zh ? '### 常驻记忆' : '### Resident');
573
584
  for (const r of snap.resident) {
574
585
  parts.push(`- **${r.scope}**: ${r.summary}`);
575
586
  }
576
587
  }
577
588
  if (snap.recent.length > 0) {
578
- parts.push('### Recent');
589
+ parts.push(zh ? '### 最近记忆' : '### Recent');
579
590
  for (const s of snap.recent) {
580
591
  parts.push(`- (${s.scope}) ${(s.body || '').trim()}`);
581
592
  }
582
593
  }
583
594
  if (snap.onDemand.length > 0) {
584
- parts.push('### OnDemand');
595
+ parts.push(zh ? '### 按需记忆' : '### OnDemand');
585
596
  for (const s of snap.onDemand) {
586
597
  parts.push(`- (${s.scope}) ${(s.body || '').trim()}`);
587
598
  }
@@ -772,6 +783,11 @@ export class Engine {
772
783
  parentVpPersona: vpCtx?.vpPersona || null,
773
784
  onEvent: this.#subAgentEventSink || null,
774
785
  language: this.#config?.language || 'en',
786
+ // Forward the session-shared ToolUsageStats so sub-agent
787
+ // engines record tool calls into the same on-disk snapshot
788
+ // (~/.yeaft/stats/tool-usage.json) the parent engine writes
789
+ // to. Null when the parent has no stats wired (e.g. tests).
790
+ toolStats: this.#toolStats || null,
775
791
  },
776
792
  };
777
793
  }
@@ -1133,6 +1149,7 @@ export class Engine {
1133
1149
  vpId: vpPersona && typeof vpPersona === 'object' && typeof vpPersona.vpId === 'string'
1134
1150
  ? vpPersona.vpId
1135
1151
  : (typeof senderVpId === 'string' ? senderVpId : undefined),
1152
+ language: this.#config.language || 'en',
1136
1153
  });
1137
1154
 
1138
1155
  // ─── AMS: populate + snapshot ───────────────────────────────
@@ -90,7 +90,11 @@ export async function runSingleEval(evalCase, { adapter, model, config = {} }) {
90
90
  const trace = new NullTrace();
91
91
  const engineConfig = { model, maxOutputTokens: 4096, ...config };
92
92
 
93
- // Build engine — optionally with ToolRegistry
93
+ // Build engine — optionally with ToolRegistry. NOTE: intentionally
94
+ // no `toolStats` here. The eval runner is an offline scoring harness
95
+ // and must NOT pollute the user-facing `~/.yeaft/stats/tool-usage.json`
96
+ // snapshot (see PR #782 for the session-shared ToolUsageStats
97
+ // wiring on the runtime paths).
94
98
  const engineOpts = { adapter, trace, config: engineConfig };
95
99
 
96
100
  if (evalCase.registryTools) {
@@ -197,7 +197,7 @@ async function atomicWrite(absPath, content) {
197
197
  * Read a scope's memory.md. Missing → empty string.
198
198
  *
199
199
  * @param {Scope} scope
200
- * @param {{ root?: string, currentVpId?: string }} [opts]
200
+ * @param {{ root?: string, currentVpId?: string, language?: string }} [opts]
201
201
  * @returns {Promise<string>}
202
202
  */
203
203
  export async function readMemory(scope, opts = {}) {
@@ -254,23 +254,38 @@ export async function appendMemory(scope, chunk, opts = {}) {
254
254
 
255
255
  // ─── summary.md ────────────────────────────────────────────────
256
256
 
257
+
258
+ function summaryFileName(language) {
259
+ const normalized = String(language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en';
260
+ return normalized === 'zh' ? 'summary.zh.md' : 'summary.md';
261
+ }
262
+
263
+ function summaryCandidateRels(scope, language) {
264
+ const dir = scopeDir(scope);
265
+ const primary = `${dir}/${summaryFileName(language)}`;
266
+ const fallback = `${dir}/summary.md`;
267
+ return primary === fallback ? [fallback] : [primary, fallback];
268
+ }
269
+
257
270
  /**
258
271
  * Read a scope's summary.md (trimmed). Missing → empty string.
259
272
  *
260
273
  * @param {Scope} scope
261
- * @param {{ root?: string, currentVpId?: string }} [opts]
274
+ * @param {{ root?: string, currentVpId?: string, language?: string }} [opts]
262
275
  * @returns {Promise<string>}
263
276
  */
264
277
  export async function readSummary(scope, opts = {}) {
265
- const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
266
- const rel = `${scopeDir(scope)}/summary.md`;
267
- enforceVpAcl(rel, currentVpId);
268
- const abs = join(root, rel);
269
- try { return (await fsp.readFile(abs, 'utf8')).trim(); }
270
- catch (err) {
271
- if (err && err.code === 'ENOENT') return '';
272
- throw err;
278
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId, language } = opts;
279
+ for (const rel of summaryCandidateRels(scope, language)) {
280
+ enforceVpAcl(rel, currentVpId);
281
+ const abs = join(root, rel);
282
+ try { return (await fsp.readFile(abs, 'utf8')).trim(); }
283
+ catch (err) {
284
+ if (err && err.code === 'ENOENT') continue;
285
+ throw err;
286
+ }
273
287
  }
288
+ return '';
274
289
  }
275
290
 
276
291
  /**
@@ -278,11 +293,11 @@ export async function readSummary(scope, opts = {}) {
278
293
  *
279
294
  * @param {Scope} scope
280
295
  * @param {string} body
281
- * @param {{ root?: string, currentVpId?: string }} [opts]
296
+ * @param {{ root?: string, currentVpId?: string, language?: string }} [opts]
282
297
  */
283
298
  export async function writeSummary(scope, body, opts = {}) {
284
- const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
285
- const rel = `${scopeDir(scope)}/summary.md`;
299
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId, language } = opts;
300
+ const rel = `${scopeDir(scope)}/${summaryFileName(language)}`;
286
301
  enforceVpAcl(rel, currentVpId);
287
302
  const abs = join(root, rel);
288
303
  await atomicWrite(abs, `${(body || '').trim()}\n`);
@@ -86,6 +86,7 @@ export function isRestrictedToolName(name) {
86
86
  * parentName?: string,
87
87
  * parentVpId?: string,
88
88
  * parentVpPersona?: object,
89
+ * toolStats?: object,
89
90
  * onEvent?: (agentId: string, evt: object) => void,
90
91
  * language?: 'en'|'zh',
91
92
  * }} deps
@@ -112,6 +113,11 @@ export function startSubAgent(agent, deps = {}) {
112
113
  skillManager: deps.skillManager || null,
113
114
  mcpManager: deps.mcpManager || null,
114
115
  yeaftDir: deps.yeaftDir || null,
116
+ // Share the session-shared ToolUsageStats so sub-agent tool calls
117
+ // land in the same on-disk snapshot the parent records into. Sub-
118
+ // agents are often the heaviest tool users — leaving them out
119
+ // skewed `unify_fetch_tool_stats` output.
120
+ toolStats: deps.toolStats || null,
115
121
  });
116
122
 
117
123
  agent.subEngine = subEngine;
@@ -499,6 +499,19 @@ export function __testGroupContextEntry(groupId) {
499
499
  return groupContexts.get(groupId);
500
500
  }
501
501
 
502
+ /**
503
+ * Test-only: build (or return cached) per-VP Engine for a session that
504
+ * was wired via `__testSetSession`. Lets tests assert that the engine's
505
+ * dependencies (notably `toolStats`) come from the session reference —
506
+ * see `test/agent/web-bridge-vp-engine-tool-stats.test.js`.
507
+ *
508
+ * @param {string} groupId
509
+ * @param {string} vpId
510
+ */
511
+ export function __testGetOrCreateVpEngine(groupId, vpId) {
512
+ return getOrCreateVpEngine(groupId, vpId);
513
+ }
514
+
502
515
  /** Whether we've already sent a permission warning to the UI */
503
516
  let _permissionDiagnosticSent = false;
504
517
 
@@ -540,6 +553,12 @@ function getOrCreateVpEngine(groupId, vpId) {
540
553
  skillManager: session.skillManager,
541
554
  mcpManager: session.mcpManager,
542
555
  yeaftDir: session.yeaftDir,
556
+ // Share the session-shared ToolUsageStats so per-VP tool calls land
557
+ // in the same on-disk snapshot the `unify_fetch_tool_stats` handler
558
+ // reads. Without this, engine's record-on-tool-exec guard
559
+ // (`if (this.#toolStats && ...)`) is false and group VP tool calls
560
+ // are silently dropped.
561
+ toolStats: session.toolStats || null,
543
562
  });
544
563
  vpEngines.set(key, eng);
545
564
  return eng;
@@ -2673,6 +2692,37 @@ export const __testRaceWithEscalation = raceWithEscalation;
2673
2692
  * Backwards-compat: when neither field is set, defaults to `vpId='default'`
2674
2693
  * which matches the pre-v0.1.754 behavior.
2675
2694
  */
2695
+ export function normalizeDreamResult(result) {
2696
+ const groups = Array.isArray(result?.groups) ? result.groups : [];
2697
+ const targets = Array.isArray(result?.targets) ? result.targets : [];
2698
+ const groupsProcessed = groups.filter(g => g && g.status === 'triaged').length;
2699
+ const skippedGroups = groups.filter(g => g && g.status === 'skipped');
2700
+ const groupsSkipped = skippedGroups.length;
2701
+ const targetsApplied = targets.filter(t => t && t.status === 'done').length;
2702
+ const targetErrors = targets
2703
+ .filter(t => t && t.status === 'error')
2704
+ .map(t => ({ target: t.target || null, error: t.error || 'unknown' }));
2705
+ const hardError = result?.error || null;
2706
+ const skipped = !hardError && groupsProcessed === 0 && targetsApplied === 0;
2707
+ const skippedReason = skipped
2708
+ ? (skippedGroups[0]?.reason || 'no-targets-applied')
2709
+ : null;
2710
+ const success = !hardError && targetErrors.length === 0 && !skipped && targetsApplied > 0;
2711
+
2712
+ return {
2713
+ success,
2714
+ skipped,
2715
+ skippedReason,
2716
+ groupsProcessed,
2717
+ groupsSkipped,
2718
+ targetsApplied,
2719
+ targetErrors,
2720
+ entriesCreated: targetsApplied,
2721
+ lastDreamAt: result?.startedAt || new Date().toISOString(),
2722
+ error: hardError || (targetErrors[0]?.error || null),
2723
+ };
2724
+ }
2725
+
2676
2726
  export async function handleUnifyDreamTrigger(msg = {}) {
2677
2727
  // Resolve tag up-front so EVERY outbound envelope (including the
2678
2728
  // scheduler-uninitialised early-return below) carries `groupId` /
@@ -2685,11 +2735,11 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2685
2735
  const tag = groupId ? { groupId } : { vpId };
2686
2736
 
2687
2737
  if (!session?.dreamScheduler) {
2738
+ const error = 'Dream scheduler not initialized — session not loaded.';
2688
2739
  sendToServer({
2689
2740
  type: 'unify_dream_result',
2690
2741
  ...tag,
2691
- success: false,
2692
- error: 'Dream scheduler not initialized — session not loaded.',
2742
+ ...normalizeDreamResult({ error }),
2693
2743
  });
2694
2744
  return;
2695
2745
  }
@@ -2705,11 +2755,11 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2705
2755
  // dream-v2/schedule.js inflight reuse), so the user-facing semantics
2706
2756
  // are unchanged ("you already asked").
2707
2757
  if (groupId && inflightScopedDreamGroups.size > 0) {
2758
+ const error = 'A dream pass is already running.';
2708
2759
  sendToServer({
2709
2760
  type: 'unify_dream_result',
2710
2761
  ...tag,
2711
- success: false,
2712
- error: 'A dream pass is already running.',
2762
+ ...normalizeDreamResult({ error }),
2713
2763
  });
2714
2764
  return;
2715
2765
  }
@@ -2745,17 +2795,12 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2745
2795
  ? await session.dreamScheduler.triggerDreamForScopes([`group/${groupId}`])
2746
2796
  : await session.dreamScheduler.triggerDreamNow();
2747
2797
 
2748
- // fix/dream-cadence-and-ui-trigger: derive a single "entries
2749
- // created" count for the UI bubble. The runner returns a richer
2750
- // shape (groups[], targets[]); the front-end button only needs a
2751
- // scalar plus the start timestamp.
2752
- const targets = Array.isArray(result?.targets) ? result.targets : [];
2753
- const entriesCreated = targets.filter(t => t && t.status === 'done').length;
2754
- const lastDreamAt = result?.startedAt || new Date().toISOString();
2755
- const success = !result.error && !result.skipped;
2756
-
2757
- // Spread `result` FIRST so derived fields (success, entriesCreated,
2758
- // lastDreamAt) authoritatively shadow anything the runner might grow
2798
+ const normalized = normalizeDreamResult(result);
2799
+
2800
+ // Spread `result` FIRST so normalized fields (success, skipped,
2801
+ // skippedReason, groupsProcessed, groupsSkipped, targetsApplied,
2802
+ // targetErrors, entriesCreated, lastDreamAt) authoritatively shadow
2803
+ // anything the runner might grow
2759
2804
  // with the same name. Today there is no collision (runner.js returns
2760
2805
  // { groups, targets, startedAt, error?, skipped? }) but the failure
2761
2806
  // mode of the alternative ordering is silent — review feedback from
@@ -2772,16 +2817,14 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2772
2817
  type: 'unify_dream_result',
2773
2818
  ...tag,
2774
2819
  ...result,
2775
- success,
2776
- entriesCreated,
2777
- lastDreamAt,
2820
+ ...normalized,
2778
2821
  });
2779
2822
  } catch (err) {
2823
+ const error = err?.message || String(err);
2780
2824
  sendToServer({
2781
2825
  type: 'unify_dream_result',
2782
2826
  ...tag,
2783
- success: false,
2784
- error: err?.message || String(err),
2827
+ ...normalizeDreamResult({ error }),
2785
2828
  });
2786
2829
  } finally {
2787
2830
  // Restore the original sink and release the per-group inflight lock.