@yeaft/webchat-agent 0.1.752 → 0.1.754

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.752",
3
+ "version": "0.1.754",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -62,12 +62,17 @@ export class Compactor {
62
62
  * treats that as a soft failure).
63
63
  * @param {() => number|undefined} [opts.getMaxContextTokens]
64
64
  * Returns `config.maxContextTokens` for `shouldCompactHistory`.
65
+ * @param {() => string|undefined} [opts.getLanguage]
66
+ * Returns the live `config.language`. Threaded into
67
+ * `compactHistory` so the compactor's summary prompt + the
68
+ * "session continued" wrapper render in the user's preferred
69
+ * locale instead of always English.
65
70
  * @param {(groupId: string, result: CompactedResult) => void} [opts.onCompacted]
66
71
  * Optional sink. Bridge wires this to send the
67
72
  * `unify_history_compacted` WS event. Default: no-op. Can be
68
73
  * replaced post-construction via `setOnCompacted`.
69
74
  */
70
- constructor({ summarize, getMaxContextTokens, onCompacted } = {}) {
75
+ constructor({ summarize, getMaxContextTokens, getLanguage, onCompacted } = {}) {
71
76
  if (typeof summarize !== 'function') {
72
77
  throw new TypeError('Compactor: summarize is required');
73
78
  }
@@ -75,6 +80,9 @@ export class Compactor {
75
80
  this._getMaxContextTokens = typeof getMaxContextTokens === 'function'
76
81
  ? getMaxContextTokens
77
82
  : () => undefined;
83
+ this._getLanguage = typeof getLanguage === 'function'
84
+ ? getLanguage
85
+ : () => undefined;
78
86
  this._onCompacted = typeof onCompacted === 'function' ? onCompacted : () => {};
79
87
  /** @type {Map<string, { inFlight: Promise<void>|null, pending: boolean }>} */
80
88
  this._states = new Map();
@@ -186,7 +194,11 @@ export class Compactor {
186
194
  const summarize = ({ system, prompt }) =>
187
195
  this._summarize({ system, prompt, maxTokens: SUMMARIZER_MAX_TOKENS });
188
196
 
189
- const result = await compactHistory(snapshot, { summarize, maxContextTokens });
197
+ const result = await compactHistory(snapshot, {
198
+ summarize,
199
+ maxContextTokens,
200
+ language: this._getLanguage(),
201
+ });
190
202
  if (!result || !result.compacted) {
191
203
  if (result && result.error) {
192
204
  console.warn(
@@ -44,7 +44,7 @@ import { listScopes, readSummary } from '../memory/store-v2.js';
44
44
  import {
45
45
  DEFAULT_LIMITS,
46
46
  } from './limits.js';
47
- import { readGroupState, writeGroupState } from './state.js';
47
+ import { readGroupState, writeGroupState, writeDreamError } from './state.js';
48
48
  import { segmentDiff, truncateMessage, estimateMessagesTokens } from './segment.js';
49
49
  import { triageGroupSegments } from './triage.js';
50
50
  import { mergeByTarget } from './merge.js';
@@ -148,6 +148,14 @@ export async function runDream(opts) {
148
148
  } catch (err) {
149
149
  groupsReport.push({ groupId, new: newCount, status: 'error', error: err.message });
150
150
  onProgress({ phase: 'triage', groupId, status: 'error', error: err.message });
151
+ // Journal the failure on disk so operators can see WHY dream is
152
+ // not advancing without having to enable `config.debug`. Best-
153
+ // effort — `writeDreamError` swallows its own I/O errors.
154
+ await writeDreamError(opts.root, `group/${groupId}`, {
155
+ phase: 'triage',
156
+ message: err.message,
157
+ stack: err.stack,
158
+ });
151
159
  continue;
152
160
  }
153
161
 
@@ -191,6 +199,14 @@ export async function runDream(opts) {
191
199
  error: err.message,
192
200
  });
193
201
  onProgress({ phase: 'apply', target: merged.target, status: 'error', error: err.message });
202
+ // Journal apply-stage failures into the target scope's directory
203
+ // (`<root>/<merged.target>/.dream-last-error.json`). Same rationale
204
+ // as the triage catch above.
205
+ await writeDreamError(opts.root, merged.target, {
206
+ phase: 'apply',
207
+ message: err.message,
208
+ stack: err.stack,
209
+ });
194
210
  }
195
211
  }
196
212
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dream-v2/state.js.
3
3
  *
4
- * Two pieces of state, tracked separately:
4
+ * Three pieces of state, tracked separately:
5
5
  *
6
6
  * 1. Per-group control state (used to decide whether a group enters
7
7
  * triage and how far to advance the cursor):
@@ -31,13 +31,25 @@
31
31
  * control-flow decision. We update it by replacing the existing
32
32
  * block (if any) or appending a new one to the end of the file.
33
33
  *
34
- * Both helpers are pure I/O; no LLM, no logic beyond parsing.
34
+ * 3. Per-scope dream-error sink (added v0.1.754):
35
+ *
36
+ * ~/.yeaft/memory/<scope>/.dream-last-error.json
37
+ *
38
+ * Most-recent-wins JSON written unconditionally on every triage
39
+ * or apply failure (best-effort — never throws even when the I/O
40
+ * itself fails). The runner used to swallow these exceptions and
41
+ * the only sink was a `config.debug`-gated console.log; this file
42
+ * gives operators on-disk evidence regardless of debug. See
43
+ * `writeDreamError` / `readDreamError` below for the contract.
44
+ *
45
+ * All helpers are pure I/O; no LLM, no logic beyond parsing.
35
46
  */
36
47
 
37
48
  import { promises as fsp, existsSync } from 'fs';
38
49
  import { join, dirname } from 'path';
39
50
 
40
51
  const STATE_FILE = '.dream-state';
52
+ const ERROR_FILE = '.dream-last-error.json';
41
53
  const DREAM_BLOCK_OPEN = '<!-- dream-state -->';
42
54
  const DREAM_BLOCK_CLOSE = '<!-- /dream-state -->';
43
55
 
@@ -101,6 +113,95 @@ function parseGroupState(raw) {
101
113
  return out;
102
114
  }
103
115
 
116
+ // ─── per-scope dream error sink ────────────────────────────────
117
+ //
118
+ // Why: dream-v2 silently swallowed exceptions at the triage / apply
119
+ // catch sites — the only sink was `trace.event('dream_progress', evt)`
120
+ // and a `config.debug`-gated `console.log` in `session-wiring.js`. With
121
+ // `debug=false` (the default), there was no on-disk evidence that a
122
+ // dream pass had ever failed: no `.dream-state` (because we only write
123
+ // it on success), no log file, nothing. The Resident layer's continued
124
+ // regurgitation of the bootstrap seed was the only symptom.
125
+ //
126
+ // `writeDreamError` writes `<memoryRoot>/<scope>/.dream-last-error.json`
127
+ // unconditionally on every catch (best-effort — write failures must not
128
+ // shadow the original error). Operators can then `ls ~/.yeaft/memory/
129
+ // group/<id>/` and see what blew up, without having to re-enable debug.
130
+
131
+ /**
132
+ * Resolve a memoryRoot + scope-string to the scope directory.
133
+ * The scope string is the same shape dream-v2 already uses internally:
134
+ * `'user'`, `'vp/<vpId>'`, `'group/<groupId>'`, `'feature/<id>'`, etc.
135
+ *
136
+ * Pure path-join; does NOT create the directory. The writer creates it.
137
+ *
138
+ * @param {string} root
139
+ * @param {string} scope
140
+ * @returns {string}
141
+ */
142
+ export function scopeDirFor(root, scope) {
143
+ // Defensive: trim leading/trailing slashes so callers can pass either
144
+ // `'group/grp_fun'` or `/group/grp_fun/` — both land on the same dir.
145
+ const clean = String(scope || '').replace(/^\/+|\/+$/g, '');
146
+ return join(root, clean);
147
+ }
148
+
149
+ /**
150
+ * Best-effort write of the dream-error sink. Never throws — a failed
151
+ * write is silently swallowed because the caller is already in an
152
+ * error-handling path and we must not mask the original failure.
153
+ *
154
+ * @param {string} root — memory root, e.g. ~/.yeaft/memory
155
+ * @param {string} scope — `'group/<id>'` for triage failures,
156
+ * `merged.target` for apply failures.
157
+ * @param {{ phase: string, message: string, stack?: string|null, at?: string }} info
158
+ * @returns {Promise<void>}
159
+ */
160
+ export async function writeDreamError(root, scope, info) {
161
+ try {
162
+ const dir = scopeDirFor(root, scope);
163
+ await fsp.mkdir(dir, { recursive: true });
164
+ const abs = join(dir, ERROR_FILE);
165
+ const at = (info && info.at) || new Date().toISOString();
166
+ // Trim stack to the first 5 frames — enough for diagnosis, small
167
+ // enough that the artifact stays human-readable. Missing/empty
168
+ // stack collapses to `null` rather than `""` so the artifact is
169
+ // cleaner for operators.
170
+ const rawStack = info && typeof info.stack === 'string' ? info.stack : '';
171
+ const stackLines = rawStack ? rawStack.split('\n').slice(0, 5) : [];
172
+ const body = JSON.stringify({
173
+ at,
174
+ scope,
175
+ phase: String(info?.phase || 'unknown'),
176
+ message: String(info?.message || ''),
177
+ stack: stackLines.length > 0 ? stackLines.join('\n') : null,
178
+ }, null, 2) + '\n';
179
+ await atomicWrite(abs, body);
180
+ } catch {
181
+ // Best-effort: swallow. The caller is already handling the real
182
+ // error; an inability to journal it must not shadow that.
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Read the last dream error JSON for a scope, or null if absent. Used
188
+ * by the debug panel and by tests. Tolerates a malformed file by
189
+ * returning `{ raw: <body>, parseError: <message> }` instead of
190
+ * throwing.
191
+ *
192
+ * @param {string} root
193
+ * @param {string} scope
194
+ * @returns {Promise<object|null>}
195
+ */
196
+ export async function readDreamError(root, scope) {
197
+ const abs = join(scopeDirFor(root, scope), ERROR_FILE);
198
+ let raw;
199
+ try { raw = await fsp.readFile(abs, 'utf8'); }
200
+ catch (err) { if (err && err.code === 'ENOENT') return null; throw err; }
201
+ try { return JSON.parse(raw); }
202
+ catch (e) { return { raw, parseError: e.message }; }
203
+ }
204
+
104
205
  // ─── per-scope marker (memory.md tail block) ───────────────────
105
206
 
106
207
  /**
package/unify/engine.js CHANGED
@@ -975,13 +975,23 @@ export class Engine {
975
975
 
976
976
  const archiveIds = [];
977
977
 
978
+ // Language-aware summarizer prompts. The orchestrator-track summary
979
+ // ends up in the system prompt as a "previous conversation summary"
980
+ // block, so it needs to match the user's preferred language to avoid
981
+ // a jarring locale flip mid-context.
982
+ const isZh = String(this.#config.language || '').toLowerCase().startsWith('zh');
983
+ const summariserSystem = isZh
984
+ ? '你是对话摘要器。请用中文写出 2–3 段简明摘要,保留决策、事实与上下文。'
985
+ : 'You are a conversation summarizer. Summarize concisely in 2–3 paragraphs, preserving decisions, facts, and context.';
986
+ const summariserPromptPrefix = isZh ? '请概括:\n\n' : 'Summarize:\n\n';
987
+
978
988
  const hooks = {
979
989
  summarise: async () => {
980
990
  try {
981
991
  const result = await adapter.call({
982
992
  model: fastConfig.model,
983
- system: 'You are a conversation summarizer. Summarize concisely in 2–3 paragraphs, preserving decisions, facts, and context.',
984
- messages: [{ role: 'user', content: `Summarize:\n\n${toArchive.map(m => `[${m.role}] ${(m.content || '').slice(0, 500)}`).join('\n\n')}` }],
993
+ system: summariserSystem,
994
+ messages: [{ role: 'user', content: `${summariserPromptPrefix}${toArchive.map(m => `[${m.role}] ${(m.content || '').slice(0, 500)}`).join('\n\n')}` }],
985
995
  maxTokens: 1024,
986
996
  });
987
997
  return (result.text || '').trim();
@@ -328,16 +328,22 @@ export function findCutIndex(messages, keepRecent) {
328
328
  * `server/db/message-db.js`) recognise it.
329
329
  *
330
330
  * @param {string} summary
331
+ * @param {{ language?: string }} [opts]
331
332
  * @returns {{role:'user', content:string, _compactSummary: true}}
332
333
  */
333
- export function wrapSummaryAsUserMessage(summary) {
334
+ export function wrapSummaryAsUserMessage(summary, opts = {}) {
334
335
  const body = (summary || '').trim() || '(no summary produced)';
335
- const content =
336
- 'This session is being continued from a previous conversation. ' +
337
- 'The earlier context has been summarized for efficiency.\n\n' +
338
- 'Summary of conversation so far:\n' +
339
- body +
340
- '\n\nContinue the conversation from where it left off without asking the user any further questions.';
336
+ const isZh = String(opts.language || '').toLowerCase().startsWith('zh');
337
+ const content = isZh
338
+ ? '本会话延续自之前的对话。早期上下文已经被概括以节省空间。\n\n' +
339
+ '至此为止的对话摘要:\n' +
340
+ body +
341
+ '\n\n请从中断处继续对话,不要再向用户重复确认。'
342
+ : 'This session is being continued from a previous conversation. ' +
343
+ 'The earlier context has been summarized for efficiency.\n\n' +
344
+ 'Summary of conversation so far:\n' +
345
+ body +
346
+ '\n\nContinue the conversation from where it left off without asking the user any further questions.';
341
347
  return {
342
348
  role: 'user',
343
349
  content,
@@ -349,24 +355,36 @@ export function wrapSummaryAsUserMessage(summary) {
349
355
  * Build the prompt fed to the fast-model summarizer. Kept in code (not in
350
356
  * a template file) because it's small and lives alongside the call site.
351
357
  *
358
+ * The summarizer prompt itself is language-aware: callers pass the live
359
+ * `config.language` so the produced summary is written in the user's
360
+ * preferred language. JSON-style structural cues stay English so the
361
+ * summary remains easy to splice into the next turn regardless of locale.
362
+ *
352
363
  * @param {Array<{role:string, content:string}>} cleanedMessages
364
+ * @param {{ language?: string }} [opts]
353
365
  * @returns {{system: string, prompt: string}}
354
366
  */
355
- export function buildSummaryPrompt(cleanedMessages) {
367
+ export function buildSummaryPrompt(cleanedMessages, opts = {}) {
356
368
  const transcript = cleanedMessages
357
369
  .map(m => `[${m.role}]\n${m.content}`)
358
370
  .join('\n\n---\n\n');
359
- const system =
360
- 'You are a conversation summarizer for a multi-agent group chat. ' +
361
- 'Produce a concise (4–8 short bullet points) summary of the conversation ' +
362
- 'so far. Preserve: (1) decisions made, (2) facts learned, (3) the user\'s ' +
363
- 'current goal, (4) any open questions or pending actions, (5) which VPs ' +
364
- 'are participating and what each contributed. Do NOT include raw tool ' +
365
- 'output. Do NOT speculate. Be specific.';
366
- const prompt =
367
- 'Summarize the following conversation. Output ONLY the summary, no ' +
368
- 'preamble.\n\n' +
369
- transcript;
371
+ const isZh = String(opts.language || '').toLowerCase().startsWith('zh');
372
+ const system = isZh
373
+ ? '你是多 agent 群聊的对话摘要器。请用中文写出 4–8 条简明 bullet 摘要。' +
374
+ '保留:(1) 已做的决策,(2) 已学到的事实,(3) 用户当前目标,' +
375
+ '(4) 任何未解决的问题或待办事项,(5) 哪些 VP 参与了对话以及各自贡献。' +
376
+ '不要包含原始工具输出。不要臆测。要具体。'
377
+ : 'You are a conversation summarizer for a multi-agent group chat. ' +
378
+ 'Produce a concise (4–8 short bullet points) summary of the conversation ' +
379
+ 'so far. Preserve: (1) decisions made, (2) facts learned, (3) the user\'s ' +
380
+ 'current goal, (4) any open questions or pending actions, (5) which VPs ' +
381
+ 'are participating and what each contributed. Do NOT include raw tool ' +
382
+ 'output. Do NOT speculate. Be specific.';
383
+ const prompt = isZh
384
+ ? '请概括下面的对话。只输出摘要正文,不要前言。\n\n' + transcript
385
+ : 'Summarize the following conversation. Output ONLY the summary, no ' +
386
+ 'preamble.\n\n' +
387
+ transcript;
370
388
  return { system, prompt };
371
389
  }
372
390
 
@@ -384,6 +402,7 @@ export function buildSummaryPrompt(cleanedMessages) {
384
402
  * maxContextTokens?: number,
385
403
  * tokenFraction?: number,
386
404
  * hardTokenCeiling?: number,
405
+ * language?: string,
387
406
  * }} options
388
407
  * @returns {Promise<{
389
408
  * messages: Array<object>,
@@ -407,6 +426,7 @@ export async function compactHistory(messages, options) {
407
426
  maxContextTokens,
408
427
  tokenFraction,
409
428
  hardTokenCeiling,
429
+ language,
410
430
  } = options || {};
411
431
 
412
432
  if (typeof summarize !== 'function') {
@@ -460,7 +480,7 @@ export async function compactHistory(messages, options) {
460
480
 
461
481
  let summaryText = '';
462
482
  if (cleaned.length > 0) {
463
- const { system, prompt } = buildSummaryPrompt(cleaned);
483
+ const { system, prompt } = buildSummaryPrompt(cleaned, { language });
464
484
  try {
465
485
  summaryText = (await summarize({ system, prompt })) || '';
466
486
  } catch (err) {
@@ -498,7 +518,7 @@ export async function compactHistory(messages, options) {
498
518
  }
499
519
  }
500
520
 
501
- const summaryMsg = wrapSummaryAsUserMessage(summaryText);
521
+ const summaryMsg = wrapSummaryAsUserMessage(summaryText, { language });
502
522
 
503
523
  // Defensive pair-sanitize: the cut at `cutIdx` lands at a user-message
504
524
  // boundary so an `[assistant(toolCalls), tool…]` arc is not split, but
package/unify/session.js CHANGED
@@ -315,6 +315,13 @@ export async function loadSession(options = {}) {
315
315
  engine.summarizeForCompact({ system, prompt, maxTokens }),
316
316
  getMaxContextTokens: () =>
317
317
  typeof config.maxContextTokens === 'number' ? config.maxContextTokens : undefined,
318
+ // Live-read: `config.language` is mutated in place by
319
+ // `engine.setLanguage()` (which broadcastLanguageChange fans out to
320
+ // every per-VP engine). The compactor must see the post-broadcast
321
+ // value, not a boot-time snapshot, so the summary prompt + the
322
+ // "session continued" wrapper render in the user's current locale.
323
+ getLanguage: () =>
324
+ typeof config.language === 'string' ? config.language : undefined,
318
325
  });
319
326
 
320
327
  // ─── 9a. Create dream scheduler ────────────
@@ -61,16 +61,44 @@ function localizeVisibleText(value, language, toolName) {
61
61
  ].join('\n');
62
62
  }
63
63
 
64
+ /**
65
+ * Walk a JSON Schema `parameters` object and localize the human-readable
66
+ * `description` strings to the requested language. All other schema bits
67
+ * (`type`, `enum`, `required`, `items`, nested `properties`, etc.) are
68
+ * preserved by value.
69
+ *
70
+ * Critical correctness rule: a JSON Schema can have a *property named*
71
+ * `description` whose value is itself a sub-schema (e.g.
72
+ * `properties: { description: { type: 'string', description: 'Detailed...' }}`).
73
+ * In that case the OUTER `description` key holds the sub-schema and must
74
+ * be recursed into; only the INNER `description: 'Detailed...'` value
75
+ * (which is a string) should be localized.
76
+ *
77
+ * Previous bug: this walker localized ANY value under a `description`
78
+ * key, including sub-schema objects. `localizeVisibleText` then ran
79
+ * `String(value)` on the object, producing the literal string
80
+ * `'[object Object]'`. GPT-5's strict schema validator rejected the
81
+ * resulting `{ description: '[object Object]' }` with
82
+ * `"'[object Object]' is not of type 'object', 'boolean'"`. This made
83
+ * FeatureCreate (and any tool whose schema contains a property named
84
+ * `description`) unusable in zh locale on strict providers.
85
+ *
86
+ * The fix: only treat a `description` value as localizable text when it
87
+ * is actually a string. Object/array values under a `description` key
88
+ * are sub-schemas and must be recursed into normally.
89
+ */
64
90
  function localizeParameters(parameters, language, toolName) {
65
91
  const lang = normalizeLanguage(language);
66
92
  if (lang !== 'zh' || !parameters || typeof parameters !== 'object') return parameters;
67
93
  if (Array.isArray(parameters)) return parameters.map(v => localizeParameters(v, lang, toolName));
68
94
  const out = {};
69
95
  for (const [key, value] of Object.entries(parameters)) {
70
- if (key === 'description') {
96
+ if (key === 'description' && typeof value === 'string') {
71
97
  out[key] = localizeVisibleText(value, lang, toolName);
72
- } else {
98
+ } else if (value && typeof value === 'object') {
73
99
  out[key] = localizeParameters(value, lang, toolName);
100
+ } else {
101
+ out[key] = value;
74
102
  }
75
103
  }
76
104
  return out;
@@ -2418,28 +2418,55 @@ export function __testGetRegisteredThreadIds() {
2418
2418
  export const __testRaceWithEscalation = raceWithEscalation;
2419
2419
 
2420
2420
  /**
2421
- * Manual dream trigger from VP detail page.
2421
+ * Manual dream trigger.
2422
+ *
2423
+ * Two call shapes, both routed through this single handler:
2424
+ *
2425
+ * { type: 'unify_dream_trigger', vpId } — per-VP trigger (legacy
2426
+ * VP-detail page button). Fires an unscoped dream pass; the result
2427
+ * event is tagged with `vpId` so the per-VP store row updates.
2428
+ *
2429
+ * { type: 'unify_dream_trigger', groupId } — per-GROUP trigger (new
2430
+ * in v0.1.754 — added so users can manually kick dream for a group
2431
+ * after seeing the Resident layer stuck on the bootstrap seed).
2432
+ * Fires a scope-filtered pass via `triggerDreamForScopes(['group/X'])`
2433
+ * so unrelated groups don't get processed; the result event is
2434
+ * tagged with `groupId` for the per-group UI row.
2435
+ *
2436
+ * Backwards-compat: when neither field is set, defaults to `vpId='default'`
2437
+ * which matches the pre-v0.1.754 behavior.
2422
2438
  */
2423
2439
  export async function handleUnifyDreamTrigger(msg = {}) {
2440
+ // Resolve tag up-front so EVERY outbound envelope (including the
2441
+ // scheduler-uninitialised early-return below) carries `groupId` /
2442
+ // `vpId`. Without this the frontend's `applyDreamResult` couldn't
2443
+ // route the error event back to the right row and the per-group
2444
+ // "Run dream now" button would stay stuck on "Running…" forever
2445
+ // (review feedback from PR #757).
2446
+ const groupId = typeof msg.groupId === 'string' && msg.groupId ? msg.groupId : null;
2447
+ const vpId = !groupId ? (msg.vpId || 'default') : null;
2448
+ const tag = groupId ? { groupId } : { vpId };
2449
+
2424
2450
  if (!session?.dreamScheduler) {
2425
2451
  sendToServer({
2426
2452
  type: 'unify_dream_result',
2453
+ ...tag,
2427
2454
  success: false,
2428
2455
  error: 'Dream scheduler not initialized — session not loaded.',
2429
2456
  });
2430
2457
  return;
2431
2458
  }
2432
2459
 
2433
- const vpId = msg.vpId || 'default';
2434
-
2435
2460
  try {
2436
2461
  sendToServer({
2437
2462
  type: 'unify_dream_status',
2438
- vpId,
2463
+ ...tag,
2439
2464
  status: 'running',
2440
2465
  });
2441
2466
 
2442
- const result = await session.dreamScheduler.triggerDreamNow();
2467
+ const result = groupId
2468
+ ? await session.dreamScheduler.triggerDreamForScopes([`group/${groupId}`])
2469
+ : await session.dreamScheduler.triggerDreamNow();
2443
2470
 
2444
2471
  // fix/dream-cadence-and-ui-trigger: derive a single "entries
2445
2472
  // created" count for the UI bubble. The runner returns a richer
@@ -2457,7 +2484,7 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2457
2484
  // PR #743.
2458
2485
  sendToServer({
2459
2486
  type: 'unify_dream_result',
2460
- vpId,
2487
+ ...tag,
2461
2488
  ...result,
2462
2489
  success: !result.error && !result.skipped,
2463
2490
  entriesCreated,
@@ -2466,7 +2493,7 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2466
2493
  } catch (err) {
2467
2494
  sendToServer({
2468
2495
  type: 'unify_dream_result',
2469
- vpId,
2496
+ ...tag,
2470
2497
  success: false,
2471
2498
  error: err?.message || String(err),
2472
2499
  });