@yeaft/webchat-agent 0.1.753 → 0.1.755

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.753",
3
+ "version": "0.1.755",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -102,6 +102,11 @@ function serializeMessage(msg) {
102
102
  // in the default group and switching back to the originating group
103
103
  // shows an empty pane.
104
104
  if (msg.groupId) fm.push(`groupId: ${msg.groupId}`);
105
+ // Group-chat attribution: when a VP authors an assistant turn (either
106
+ // its own reply or a route_forward injection from another VP), stamp
107
+ // the speaker so the UI can render the message on the correct VP track.
108
+ // For real user messages this is unset.
109
+ if (msg.speakerVpId) fm.push(`speakerVpId: ${msg.speakerVpId}`);
105
110
 
106
111
  // Token estimate
107
112
  const content = msg.content || '';
@@ -173,6 +178,7 @@ export function parseMessage(raw) {
173
178
  case 'threadId': msg.threadId = value; break;
174
179
  case 'sourceThreadId': msg.sourceThreadId = value; break;
175
180
  case 'groupId': msg.groupId = value; break;
181
+ case 'speakerVpId': msg.speakerVpId = value; break;
176
182
  // toolCalls are multi-line YAML — handled separately below
177
183
  }
178
184
  }
@@ -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
  /**
@@ -64,7 +64,20 @@ export function createCoordinator(group, options = {}) {
64
64
  const meta = group.getMeta();
65
65
  if (!meta) throw new Error('group not initialised (call createGroup first)');
66
66
 
67
- const fromUser = input.from === 'user' || input.role === 'user';
67
+ // `fromUser` drives `selectRespondingVps` when true, the @-mention
68
+ // matrix runs (mention/broadcast/fallback). When false, VPs cannot
69
+ // text-@-route (VP-authored free text is surface noise per arch §6).
70
+ //
71
+ // route_forward injection is a special case: the message is VP-authored
72
+ // (role='assistant') but it MUST trigger dispatch (target VP needs to
73
+ // run). We detect it via `meta.injectedBy === 'route_forward'` and
74
+ // treat it as "user-like" for dispatch purposes only. Persistence still
75
+ // honours the caller's `role` field so the on-disk record correctly
76
+ // attributes the turn to the sending VP, not to the user.
77
+ const isRouteForwardInjection = input?.meta?.injectedBy === 'route_forward';
78
+ const fromUser = input.from === 'user'
79
+ || input.role === 'user'
80
+ || isRouteForwardInjection;
68
81
  const mentions = parseMentions(input.text);
69
82
 
70
83
  // Persist first — audit log / replay works even if dispatch has bugs.
@@ -125,17 +125,12 @@ export function createRouter(deps = {}) {
125
125
  }
126
126
 
127
127
  // Synthesize an injection message — coordinator's `ingest` expects the
128
- // {from, role, text} shape. We set role='user' ONLY because that's the
129
- // code path that triggers @-routing; semantically it is a VP-initiated
130
- // injection. The `from` field preserves the real sender vpId, and we
131
- // stamp meta.synthetic + meta.injectedBy so downstream auditors can
132
- // tell it apart from real user input.
133
- //
134
- // Why not add a third role to Coordinator? Scope discipline: Coordinator
135
- // (334b) owns user vs VP branching. A third branch would force edits
136
- // across both 334b and 334d for one hop. Setting role='user' with
137
- // synthetic meta keeps the Coordinator API frozen — and `from` still
138
- // reflects the real author, which is what the guard keys on anyway.
128
+ // {from, role, text} shape. The forwarded message is semantically the
129
+ // SENDER VP speaking (just delivered to a different VP's inbox), so it
130
+ // persists as role='assistant' attributed to `from`. The `meta.injectedBy`
131
+ // stamp + `synthetic` marker let Coordinator's `selectRespondingVps`
132
+ // still treat this like a routed turn (target VPs need to respond) even
133
+ // though role is now 'assistant'.
139
134
  const injectText = to === 'all'
140
135
  ? `@all ${text}`
141
136
  : `@${to} ${text}`;
@@ -143,7 +138,7 @@ export function createRouter(deps = {}) {
143
138
  const report = coordinator.ingest(
144
139
  {
145
140
  from, // real VP id — preserved for provenance
146
- role: 'user', // triggers coordinator dispatch path
141
+ role: 'assistant', // VP-authored persists as assistant turn
147
142
  text: injectText,
148
143
  taskId: args.taskId ?? null,
149
144
  meta: {
@@ -617,10 +617,22 @@ function ensureDriverRunning(groupId, vpId) {
617
617
  try {
618
618
  const envMsgId = envelope?.msg?.id;
619
619
  if (envMsgId && text) {
620
- persistUserMessageOnceByMsgId({
620
+ // route_forward injection: the envelope text was authored by the
621
+ // sending VP, not the user. Persist as an assistant row attributed
622
+ // to the sender so history replay puts it on the VP track, not on
623
+ // the human "user" track. Non-forward envelopes (real user input)
624
+ // persist as the user row exactly like before.
625
+ const meta = envelope?.msg?.meta || {};
626
+ const isForward = meta.injectedBy === 'route_forward';
627
+ const senderVpId = isForward
628
+ ? (meta.senderVpId || envelope?.msg?.from || null)
629
+ : null;
630
+ persistInboundMessageOnceByMsgId({
621
631
  msgId: envMsgId,
622
632
  text,
623
633
  groupId,
634
+ role: isForward ? 'assistant' : 'user',
635
+ speakerVpId: senderVpId,
624
636
  });
625
637
  }
626
638
  } catch { /* never crash WS pipeline */ }
@@ -1685,7 +1697,7 @@ export async function handleUnifyGroupChat(msg) {
1685
1697
  try {
1686
1698
  const persistedMsgId = report?.message?.id;
1687
1699
  if (persistedMsgId) {
1688
- persistUserMessageOnceByMsgId({
1700
+ persistInboundMessageOnceByMsgId({
1689
1701
  msgId: persistedMsgId,
1690
1702
  text,
1691
1703
  groupId,
@@ -1693,7 +1705,7 @@ export async function handleUnifyGroupChat(msg) {
1693
1705
  }
1694
1706
  } catch (err) {
1695
1707
  console.warn(
1696
- '[Unify] unify_group_chat: persistUserMessageOnceByMsgId failed',
1708
+ '[Unify] unify_group_chat: persistInboundMessageOnceByMsgId failed',
1697
1709
  err?.message || err,
1698
1710
  );
1699
1711
  }
@@ -2210,11 +2222,13 @@ function appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCalls
2210
2222
  }
2211
2223
 
2212
2224
  /**
2213
- * Persist the user row to disk EXACTLY ONCE per coordinator-ingest call,
2214
- * keyed by the coordinator-assigned `msgId`. Both `handleUnifyGroupChat`
2215
- * (real user input) and `enqueueForVp`'s driver loop (route_forward
2216
- * synthetic injections) call this the Set guard makes either path
2217
- * the writer, whichever runs first, while the other becomes a no-op.
2225
+ * Persist an inbound message row to disk EXACTLY ONCE per
2226
+ * coordinator-ingest call, keyed by the coordinator-assigned `msgId`.
2227
+ * Both `handleUnifyGroupChat` (real user input, persists as
2228
+ * role='user') and `enqueueForVp`'s driver loop (route_forward
2229
+ * synthetic injections, persists as role='assistant' attributed via
2230
+ * `speakerVpId`) call this — the Set guard makes either path the
2231
+ * writer, whichever runs first, while the other becomes a no-op.
2218
2232
  *
2219
2233
  * Without this dedup, a 2-VP group prompt produces TWO `m{NNNN}.md`
2220
2234
  * user rows (one per engine) — `handleUnifyLoadHistory` then replays
@@ -2232,11 +2246,11 @@ function appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCalls
2232
2246
  * schema today (the engine never wrote them either) — they live on the
2233
2247
  * coordinator's jsonl-log under group meta.
2234
2248
  *
2235
- * @param {{ msgId:string, text:string, groupId:string }} args
2249
+ * @param {{ msgId:string, text:string, groupId:string, role?:string, speakerVpId?:string|null }} args
2236
2250
  * @returns {boolean} true if this call wrote the row, false if a prior
2237
2251
  * call already wrote it (dedup hit).
2238
2252
  */
2239
- function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
2253
+ function persistInboundMessageOnceByMsgId({ msgId, text, groupId, role, speakerVpId }) {
2240
2254
  if (!session?.conversationStore) return false;
2241
2255
  // No msgId means no dedup key — caller is responsible for guarding.
2242
2256
  // Both call sites already do (`if (envMsgId && text)` and
@@ -2267,17 +2281,30 @@ function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
2267
2281
  }
2268
2282
  }
2269
2283
  try {
2284
+ // role defaults to 'user' for back-compat: handleUnifyGroupChat's
2285
+ // real-user call site passes no role and gets a user row. The driver
2286
+ // loop passes role='assistant' + speakerVpId for route_forward
2287
+ // injections so the on-disk record correctly attributes the text to
2288
+ // the sending VP.
2289
+ const persistRole = role === 'assistant' ? 'assistant' : 'user';
2270
2290
  const record = {
2271
- role: 'user',
2291
+ role: persistRole,
2272
2292
  content: text,
2273
2293
  threadId: 'main',
2274
2294
  };
2275
2295
  if (groupId) record.groupId = groupId;
2296
+ // Stamp speakerVpId so the UI's loadHistory replay can route the row
2297
+ // to the correct VP block. Only meaningful when role='assistant'; for
2298
+ // a real user message we leave it unset (the UI's user track is
2299
+ // unattributed).
2300
+ if (persistRole === 'assistant' && speakerVpId && typeof speakerVpId === 'string') {
2301
+ record.speakerVpId = speakerVpId;
2302
+ }
2276
2303
  session.conversationStore.append(record);
2277
2304
  return true;
2278
2305
  } catch (err) {
2279
2306
  console.warn(
2280
- '[Unify] persistUserMessageOnceByMsgId failed (non-fatal):',
2307
+ '[Unify] persistInboundMessageOnceByMsgId failed (non-fatal):',
2281
2308
  err?.message || err,
2282
2309
  );
2283
2310
  return false;
@@ -2418,28 +2445,55 @@ export function __testGetRegisteredThreadIds() {
2418
2445
  export const __testRaceWithEscalation = raceWithEscalation;
2419
2446
 
2420
2447
  /**
2421
- * Manual dream trigger from VP detail page.
2448
+ * Manual dream trigger.
2449
+ *
2450
+ * Two call shapes, both routed through this single handler:
2451
+ *
2452
+ * { type: 'unify_dream_trigger', vpId } — per-VP trigger (legacy
2453
+ * VP-detail page button). Fires an unscoped dream pass; the result
2454
+ * event is tagged with `vpId` so the per-VP store row updates.
2455
+ *
2456
+ * { type: 'unify_dream_trigger', groupId } — per-GROUP trigger (new
2457
+ * in v0.1.754 — added so users can manually kick dream for a group
2458
+ * after seeing the Resident layer stuck on the bootstrap seed).
2459
+ * Fires a scope-filtered pass via `triggerDreamForScopes(['group/X'])`
2460
+ * so unrelated groups don't get processed; the result event is
2461
+ * tagged with `groupId` for the per-group UI row.
2462
+ *
2463
+ * Backwards-compat: when neither field is set, defaults to `vpId='default'`
2464
+ * which matches the pre-v0.1.754 behavior.
2422
2465
  */
2423
2466
  export async function handleUnifyDreamTrigger(msg = {}) {
2467
+ // Resolve tag up-front so EVERY outbound envelope (including the
2468
+ // scheduler-uninitialised early-return below) carries `groupId` /
2469
+ // `vpId`. Without this the frontend's `applyDreamResult` couldn't
2470
+ // route the error event back to the right row and the per-group
2471
+ // "Run dream now" button would stay stuck on "Running…" forever
2472
+ // (review feedback from PR #757).
2473
+ const groupId = typeof msg.groupId === 'string' && msg.groupId ? msg.groupId : null;
2474
+ const vpId = !groupId ? (msg.vpId || 'default') : null;
2475
+ const tag = groupId ? { groupId } : { vpId };
2476
+
2424
2477
  if (!session?.dreamScheduler) {
2425
2478
  sendToServer({
2426
2479
  type: 'unify_dream_result',
2480
+ ...tag,
2427
2481
  success: false,
2428
2482
  error: 'Dream scheduler not initialized — session not loaded.',
2429
2483
  });
2430
2484
  return;
2431
2485
  }
2432
2486
 
2433
- const vpId = msg.vpId || 'default';
2434
-
2435
2487
  try {
2436
2488
  sendToServer({
2437
2489
  type: 'unify_dream_status',
2438
- vpId,
2490
+ ...tag,
2439
2491
  status: 'running',
2440
2492
  });
2441
2493
 
2442
- const result = await session.dreamScheduler.triggerDreamNow();
2494
+ const result = groupId
2495
+ ? await session.dreamScheduler.triggerDreamForScopes([`group/${groupId}`])
2496
+ : await session.dreamScheduler.triggerDreamNow();
2443
2497
 
2444
2498
  // fix/dream-cadence-and-ui-trigger: derive a single "entries
2445
2499
  // created" count for the UI bubble. The runner returns a richer
@@ -2457,7 +2511,7 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2457
2511
  // PR #743.
2458
2512
  sendToServer({
2459
2513
  type: 'unify_dream_result',
2460
- vpId,
2514
+ ...tag,
2461
2515
  ...result,
2462
2516
  success: !result.error && !result.skipped,
2463
2517
  entriesCreated,
@@ -2466,7 +2520,7 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2466
2520
  } catch (err) {
2467
2521
  sendToServer({
2468
2522
  type: 'unify_dream_result',
2469
- vpId,
2523
+ ...tag,
2470
2524
  success: false,
2471
2525
  error: err?.message || String(err),
2472
2526
  });
@@ -2704,11 +2758,19 @@ export async function handleUnifyLoadHistory(msg) {
2704
2758
  if (m.role === 'user') {
2705
2759
  sendUnifyOutput({ type: 'user', message: { content: m.content } }, { groupId: m.groupId || null });
2706
2760
  } else if (m.role === 'assistant') {
2761
+ // speakerVpId rides on the envelope so the frontend can route this
2762
+ // replayed assistant text to the correct VP track. Without it, the
2763
+ // history replay would merge replies from different VPs onto one
2764
+ // anonymous assistant turn.
2765
+ const envelopeOpts = {
2766
+ groupId: m.groupId || null,
2767
+ };
2768
+ if (m.speakerVpId) envelopeOpts.vpId = m.speakerVpId;
2707
2769
  sendUnifyOutput({
2708
2770
  type: 'assistant',
2709
2771
  message: { content: [{ type: 'text', text: m.content }] },
2710
- }, { groupId: m.groupId || null });
2711
- sendUnifyOutput({ type: 'result', result_text: '' }, { groupId: m.groupId || null });
2772
+ }, envelopeOpts);
2773
+ sendUnifyOutput({ type: 'result', result_text: '' }, envelopeOpts);
2712
2774
  }
2713
2775
  }
2714
2776