@yeaft/webchat-agent 0.1.651 → 0.1.652
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 +1 -1
- package/unify/web-bridge.js +180 -1063
package/unify/web-bridge.js
CHANGED
|
@@ -1,31 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* web-bridge.js — Bridge between web UI and Yeaft Unify Engine.
|
|
3
3
|
*
|
|
4
|
+
* H2.f.2: collapsed to a single-conversation bridge. The pre-H2 multi-thread
|
|
5
|
+
* routing model is gone — there is one engine, one conversation, one
|
|
6
|
+
* AbortController, one flat message history. The wire protocol drops
|
|
7
|
+
* `threadId` from outgoing events; frontend reads them as a single stream.
|
|
8
|
+
*
|
|
4
9
|
* Translates Engine events into claude_output-format messages so the
|
|
5
10
|
* frontend can fully reuse the standard Chat rendering pipeline
|
|
6
11
|
* (MessageList, AssistantTurn, ToolLine, AskCard, waiting cat, etc.).
|
|
7
12
|
*
|
|
8
|
-
* Architecture:
|
|
9
|
-
* 1. On first use, loadSession() initialises Engine with skills + MCP enabled.
|
|
10
|
-
* 2. A virtual conversationId ('unify-<ts>') is assigned per session.
|
|
11
|
-
* 3. Engine.query() yields events → translated into unify_output messages
|
|
12
|
-
* that carry { conversationId, data } in claude_output format.
|
|
13
|
-
* 4. The frontend's handleUnifyOutput dispatches them through handleClaudeOutput.
|
|
14
|
-
*
|
|
15
13
|
* task-330c lint guard:
|
|
16
14
|
* ⚠️ DO NOT introduce greedy `text.replace(/---ROUTE---[\s\S]*$/g, '')`
|
|
17
15
|
* style strips on incoming/outgoing message bodies. Crew ROUTE
|
|
18
16
|
* stripping is owned EXCLUSIVELY by `agent/crew/routing.js`
|
|
19
17
|
* `parseRoutes()` which returns `{routes, displayBody}` with exact
|
|
20
|
-
* ranges removed.
|
|
21
|
-
* that has already been parser-cleaned, (b) reintroduce the bug
|
|
22
|
-
* task-328 fixed (greedy tail-strip ate trailing prose).
|
|
18
|
+
* ranges removed.
|
|
23
19
|
*/
|
|
24
20
|
|
|
25
21
|
import { loadSession } from './session.js';
|
|
26
22
|
import { sendToServer } from '../connection/buffer.js';
|
|
27
23
|
import ctx from '../context.js';
|
|
28
|
-
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
29
24
|
import { handleVpSubscribe } from './vp/vp-bridge.js';
|
|
30
25
|
import { createVp, updateVp, deleteVp, readVp, VpCrudError } from './vp/vp-crud.js';
|
|
31
26
|
import { scanVpLibrary } from './vp/vp-store.js';
|
|
@@ -52,16 +47,11 @@ import {
|
|
|
52
47
|
let session = null;
|
|
53
48
|
|
|
54
49
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* routed to a different thread runs concurrently without aliasing. Keyed by
|
|
59
|
-
* the resolved `targetThreadId` from the dispatcher's `routing_decision`
|
|
60
|
-
* event (we don't know the thread until the router has classified).
|
|
61
|
-
*
|
|
62
|
-
* @type {Map<string, AbortController>}
|
|
50
|
+
* Single in-flight AbortController. A new user message cancels the prior
|
|
51
|
+
* round (if any). H2.f.2: replaces the per-thread Map.
|
|
52
|
+
* @type {AbortController | null}
|
|
63
53
|
*/
|
|
64
|
-
|
|
54
|
+
let currentAbortCtrl = null;
|
|
65
55
|
|
|
66
56
|
/** Query timeout in ms — abort if LLM doesn't respond within this window */
|
|
67
57
|
const QUERY_TIMEOUT_MS = 120_000;
|
|
@@ -74,41 +64,24 @@ let unifyConversationId = null;
|
|
|
74
64
|
let _vpUnsubscribe = null;
|
|
75
65
|
|
|
76
66
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* by a `consolidate` event for that thread only.
|
|
81
|
-
*
|
|
82
|
-
* @type {Map<string, Array<{role: 'user'|'assistant', content: string|Array}>>}
|
|
67
|
+
* Flat conversation history for engine context continuity. H2.f.2: replaces
|
|
68
|
+
* the per-thread Map. Cleared on session reset or by a `consolidate` event.
|
|
69
|
+
* @type {Array<{role:'user'|'assistant'|'tool', content:string|Array, toolCalls?:Array, toolCallId?:string, isError?:boolean}>}
|
|
83
70
|
*/
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
function getThreadMessages(threadId) {
|
|
87
|
-
if (!threadId) return [];
|
|
88
|
-
let arr = messagesByThread.get(threadId);
|
|
89
|
-
if (!arr) { arr = []; messagesByThread.set(threadId, arr); }
|
|
90
|
-
return arr;
|
|
91
|
-
}
|
|
71
|
+
let conversationMessages = [];
|
|
92
72
|
|
|
93
73
|
/**
|
|
94
|
-
* Restore
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* emits `tool_calls` without paired `role:'tool'` results, causing
|
|
99
|
-
* "No tool output found for function call" 400s after the first tool
|
|
100
|
-
* use across any restart / session-ready / model-switch event.
|
|
74
|
+
* Restore conversation history from persisted store. Accepts `role:'tool'`
|
|
75
|
+
* messages and preserves `toolCalls`/`toolCallId` so the next chat-completions
|
|
76
|
+
* serialization includes paired tool messages (avoids "No tool output found
|
|
77
|
+
* for function call" 400s).
|
|
101
78
|
*
|
|
102
79
|
* @param {Array<object>} recent — output of conversationStore.loadRecent()
|
|
103
80
|
*/
|
|
104
|
-
function
|
|
105
|
-
|
|
81
|
+
function restoreHistoryFromRecent(recent) {
|
|
82
|
+
conversationMessages = [];
|
|
106
83
|
for (const m of recent) {
|
|
107
|
-
// Keep user, assistant, AND tool messages. Tool messages are required
|
|
108
|
-
// for the chat-completions `tool_call_id` pairing.
|
|
109
84
|
if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
|
|
110
|
-
const tid = m.threadId || MAIN_THREAD_ID;
|
|
111
|
-
const bucket = getThreadMessages(tid);
|
|
112
85
|
const entry = { role: m.role, content: m.content };
|
|
113
86
|
if (m.toolCallId) entry.toolCallId = m.toolCallId;
|
|
114
87
|
if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
@@ -119,18 +92,13 @@ function restoreThreadHistoryFromRecent(recent) {
|
|
|
119
92
|
}));
|
|
120
93
|
}
|
|
121
94
|
if (m.isError) entry.isError = true;
|
|
122
|
-
|
|
95
|
+
conversationMessages.push(entry);
|
|
123
96
|
}
|
|
124
97
|
}
|
|
125
98
|
|
|
126
99
|
/** Whether we've already sent a permission warning to the UI */
|
|
127
100
|
let _permissionDiagnosticSent = false;
|
|
128
101
|
|
|
129
|
-
/**
|
|
130
|
-
* Check if an error message is a permission error.
|
|
131
|
-
* @param {string} msg
|
|
132
|
-
* @returns {boolean}
|
|
133
|
-
*/
|
|
134
102
|
function isPermissionErrorMsg(msg) {
|
|
135
103
|
if (!msg) return false;
|
|
136
104
|
const lower = msg.toLowerCase();
|
|
@@ -139,14 +107,9 @@ function isPermissionErrorMsg(msg) {
|
|
|
139
107
|
|
|
140
108
|
/**
|
|
141
109
|
* Send a unify_output message carrying claude_output-format data.
|
|
142
|
-
* The server forwards this as-is to the web client.
|
|
143
|
-
* The frontend's handleUnifyOutput will dispatch via handleClaudeOutput.
|
|
144
|
-
*
|
|
145
110
|
* Optional `groupId` tags every emitted assistant/tool/user mirror with
|
|
146
111
|
* the originating group so the frontend can stamp arriving messages with
|
|
147
|
-
* the SEND-context group
|
|
148
|
-
* change while the reply is in flight). Without this, switching groups
|
|
149
|
-
* mid-reply lands the assistant turn in the wrong group.
|
|
112
|
+
* the SEND-context group.
|
|
150
113
|
*/
|
|
151
114
|
function sendUnifyOutput(data, groupId) {
|
|
152
115
|
sendToServer({
|
|
@@ -157,10 +120,7 @@ function sendUnifyOutput(data, groupId) {
|
|
|
157
120
|
});
|
|
158
121
|
}
|
|
159
122
|
|
|
160
|
-
/**
|
|
161
|
-
* Send a unify_output event (non-claude_output metadata).
|
|
162
|
-
* Optional `groupId` — see sendUnifyOutput for rationale.
|
|
163
|
-
*/
|
|
123
|
+
/** Send a unify_output event (non-claude_output metadata). */
|
|
164
124
|
function sendUnifyEvent(event, groupId) {
|
|
165
125
|
sendToServer({
|
|
166
126
|
type: 'unify_output',
|
|
@@ -170,15 +130,7 @@ function sendUnifyEvent(event, groupId) {
|
|
|
170
130
|
});
|
|
171
131
|
}
|
|
172
132
|
|
|
173
|
-
/**
|
|
174
|
-
* task-334-ui-a + task-334h: respond to `unify_vp_subscribe` from the web
|
|
175
|
-
* client by pushing a one-shot `vp_snapshot` event AND registering this
|
|
176
|
-
* socket as a live-diff subscriber. VpLoader's debounced rescan fans out
|
|
177
|
-
* `vp_updated` / `vp_removed` events to every active subscriber.
|
|
178
|
-
*/
|
|
179
133
|
export function handleUnifyVpSubscribe(_msg) {
|
|
180
|
-
// Unsub any prior subscription before re-subscribing to prevent duplicate
|
|
181
|
-
// handler registration on reconnect / re-subscribe.
|
|
182
134
|
if (_vpUnsubscribe) {
|
|
183
135
|
try { _vpUnsubscribe(); } catch { /* ignore */ }
|
|
184
136
|
_vpUnsubscribe = null;
|
|
@@ -187,23 +139,7 @@ export function handleUnifyVpSubscribe(_msg) {
|
|
|
187
139
|
}
|
|
188
140
|
|
|
189
141
|
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* Thin dispatcher over agent/unify/vp/vp-crud.js. We never throw on the WS
|
|
193
|
-
* path — each op reports via `unify_output` with a structured payload so
|
|
194
|
-
* the UI can surface errors as i18n strings keyed by `error.code`. VpLoader
|
|
195
|
-
* picks up the on-disk change on its next debounced rescan (default 500ms)
|
|
196
|
-
* and fans out `vp_updated` / `vp_removed` events to every subscriber, so
|
|
197
|
-
* we do not need to emit an extra snapshot here.
|
|
198
|
-
*
|
|
199
|
-
* Message shapes (wire):
|
|
200
|
-
* unify_vp_create { payload: {vpId, displayName, role, traits, modelHint, persona}, requestId? }
|
|
201
|
-
* unify_vp_update { payload: {...}, requestId? }
|
|
202
|
-
* unify_vp_delete { vpId, requestId? }
|
|
203
|
-
* unify_vp_read { vpId, requestId? }
|
|
204
|
-
*
|
|
205
|
-
* Replies (all sent through sendUnifyEvent):
|
|
206
|
-
* { type: 'vp_crud_result', op, requestId, ok, vpId?, vp?, error?: {code, vpId?} }
|
|
142
|
+
* VP CRUD from the web client. See historic doc for full message shapes.
|
|
207
143
|
*/
|
|
208
144
|
function sendVpCrudResult(payload) {
|
|
209
145
|
sendUnifyEvent({ type: 'vp_crud_result', ...payload });
|
|
@@ -269,53 +205,18 @@ export function handleUnifyVpDelete(msg) {
|
|
|
269
205
|
}
|
|
270
206
|
}
|
|
271
207
|
|
|
272
|
-
/**
|
|
273
|
-
* task-334h (R6 §Δ28 / §Δ31.6): task-scoped direct message echo.
|
|
274
|
-
*
|
|
275
|
-
* Replaces the withdrawn R3 `unify_task_private_chat`. The agent acts as a
|
|
276
|
-
* relay: validate → stamp msgId + ts → broadcast `task_message`. Real
|
|
277
|
-
* persistence + task ACL lands in 334l.
|
|
278
|
-
*
|
|
279
|
-
* @param {any} msg
|
|
280
|
-
*/
|
|
281
208
|
export function handleUnifyFeatureMessage(msg) {
|
|
282
209
|
_handleUnifyFeatureMessage(msg, sendUnifyEvent);
|
|
283
210
|
}
|
|
284
211
|
|
|
285
|
-
/**
|
|
286
|
-
* task-334h (R6 §Δ29): user-memory write skeleton. Replies with a
|
|
287
|
-
* `user_memory_updated` ack carrying `pending: true`; 334l replaces the
|
|
288
|
-
* stub with real ingestion + entryId.
|
|
289
|
-
*
|
|
290
|
-
* @param {any} msg
|
|
291
|
-
*/
|
|
292
212
|
export function handleUnifyUserMemoryWrite(msg) {
|
|
293
213
|
_handleUnifyUserMemoryWrite(msg, sendUnifyEvent);
|
|
294
214
|
}
|
|
295
215
|
|
|
296
|
-
/**
|
|
297
|
-
* task-334h (R6 §Δ29): user-memory remove skeleton. Replies with
|
|
298
|
-
* `user_memory_removed` ack; 334l replaces the stub.
|
|
299
|
-
*
|
|
300
|
-
* @param {any} msg
|
|
301
|
-
*/
|
|
302
216
|
export function handleUnifyUserMemoryRemove(msg) {
|
|
303
217
|
_handleUnifyUserMemoryRemove(msg, sendUnifyEvent);
|
|
304
218
|
}
|
|
305
219
|
|
|
306
|
-
/**
|
|
307
|
-
* task-fix: list MemoryStore entries as a scope-tree for the
|
|
308
|
-
* UserMemoryPage "folder view". Entries come from MemoryStore.listEntries()
|
|
309
|
-
* and are grouped client-side by their `scope` (a `/`-separated path like
|
|
310
|
-
* `work/claude-web-chat/auth`).
|
|
311
|
-
*
|
|
312
|
-
* Request shape: { type: 'unify_memory_scope_list', requestId? }
|
|
313
|
-
* Reply shape: { type: 'memory_scope_snapshot',
|
|
314
|
-
* entries: Array<{name,scope,kind,tags,importance,
|
|
315
|
-
* frequency,created_at,updated_at,
|
|
316
|
-
* content}>,
|
|
317
|
-
* requestId? }
|
|
318
|
-
*/
|
|
319
220
|
export function handleUnifyMemoryScopeList(msg) {
|
|
320
221
|
const requestId = msg && typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
321
222
|
try {
|
|
@@ -355,22 +256,7 @@ export function handleUnifyVpRead(msg) {
|
|
|
355
256
|
}
|
|
356
257
|
|
|
357
258
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
* Message shapes (wire):
|
|
361
|
-
* unify_list_groups { requestId? }
|
|
362
|
-
* unify_create_group { payload: {name, roster?, defaultVpId?}, requestId? }
|
|
363
|
-
* unify_rename_group { groupId, name, requestId? }
|
|
364
|
-
* unify_archive_group { groupId, requestId? }
|
|
365
|
-
* unify_add_member { groupId, vpId, requestId? }
|
|
366
|
-
* unify_remove_member { groupId, vpId, requestId? }
|
|
367
|
-
* unify_set_default_vp { groupId, vpId, requestId? }
|
|
368
|
-
*
|
|
369
|
-
* Replies (sendUnifyEvent):
|
|
370
|
-
* { type: 'group_crud_result', op, requestId, ok, group?, groups?, error?: {code, groupId?, message?} }
|
|
371
|
-
*
|
|
372
|
-
* Post-change broadcast (when meta mutates):
|
|
373
|
-
* { type: 'group_roster_changed', groupId, roster, defaultVpId, name }
|
|
259
|
+
* Group CRUD wired to WS events.
|
|
374
260
|
*/
|
|
375
261
|
function sendGroupCrudResult(payload) {
|
|
376
262
|
sendUnifyEvent({ type: 'group_crud_result', ...payload });
|
|
@@ -457,10 +343,6 @@ export function handleUnifyArchiveGroup(msg) {
|
|
|
457
343
|
}
|
|
458
344
|
}
|
|
459
345
|
|
|
460
|
-
/**
|
|
461
|
-
* Bug 8: physical delete — removes the group dir and any legacy
|
|
462
|
-
* `.archived-*-<groupId>` siblings. Replies with op:'delete'.
|
|
463
|
-
*/
|
|
464
346
|
export function handleUnifyDeleteGroup(msg) {
|
|
465
347
|
const requestId = msg && msg.requestId;
|
|
466
348
|
const groupId = msg && msg.groupId;
|
|
@@ -517,26 +399,16 @@ export function handleUnifySetDefaultVp(msg) {
|
|
|
517
399
|
}
|
|
518
400
|
|
|
519
401
|
/**
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
* that object was null and the setters were dead code. Now every
|
|
524
|
-
* `update_unify_settings` mutation pushes the new caps into the live
|
|
525
|
-
* session within the same tick — no reload required.
|
|
526
|
-
*
|
|
527
|
-
* Exported so tests can drive the same wiring with a mock session.
|
|
402
|
+
* Install the dream pipeline progress sink and runtime settings bridge.
|
|
403
|
+
* H2.f.2: dropped the threadStore-related setters (autoArchiveIdleDays,
|
|
404
|
+
* maxConcurrentThreads). Only the dream sink remains.
|
|
528
405
|
*
|
|
529
406
|
* @param {import('./session.js').Session} s
|
|
530
407
|
*/
|
|
531
408
|
export function installUnifyRuntimeBridge(s) {
|
|
532
409
|
if (!s) return;
|
|
533
|
-
const initialMax = s.engineRegistry?.maxConcurrent ?? null;
|
|
534
|
-
const initialIdle = s.threadStore?.idleArchiveDays ?? 0;
|
|
535
410
|
|
|
536
|
-
//
|
|
537
|
-
// client so the debug panel can render live state. Events flow through
|
|
538
|
-
// the same `unify_output` channel; no new WebSocket message type is
|
|
539
|
-
// introduced.
|
|
411
|
+
// Forward dream pipeline progress events to the web debug panel.
|
|
540
412
|
s._dreamProgressSink = (evt) => {
|
|
541
413
|
try {
|
|
542
414
|
sendUnifyEvent({ type: 'dream_progress', ...evt });
|
|
@@ -544,278 +416,24 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
544
416
|
};
|
|
545
417
|
|
|
546
418
|
ctx.unifyRuntimeSettings = {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
},
|
|
553
|
-
|
|
554
|
-
set autoArchiveIdleDays(v) {
|
|
555
|
-
if (typeof s.threadStore?.setIdleArchiveDays === 'function') {
|
|
556
|
-
s.threadStore.setIdleArchiveDays(v);
|
|
557
|
-
}
|
|
558
|
-
// task-317: re-sweep right after the cap changes so a user who
|
|
559
|
-
// lowers the threshold sees stale threads disappear immediately
|
|
560
|
-
// rather than having to wait for the hourly tick.
|
|
561
|
-
runAutoArchiveSweep(s);
|
|
562
|
-
},
|
|
419
|
+
// No multi-thread settings to surface anymore. Stub for back-compat
|
|
420
|
+
// with message-router's update_unify_settings branch — assignments are
|
|
421
|
+
// accepted but ignored.
|
|
422
|
+
get maxConcurrentThreads() { return null; },
|
|
423
|
+
set maxConcurrentThreads(_v) { /* deprecated, ignored */ },
|
|
424
|
+
get autoArchiveIdleDays() { return 0; },
|
|
425
|
+
set autoArchiveIdleDays(_v) { /* deprecated, ignored */ },
|
|
563
426
|
};
|
|
564
427
|
}
|
|
565
428
|
|
|
566
|
-
/**
|
|
567
|
-
* task-317: idle thread auto-archive.
|
|
568
|
-
*
|
|
569
|
-
* A single sweep = ask the ThreadStore to archive every non-main,
|
|
570
|
-
* non-archived thread whose last activity predates the configured idle
|
|
571
|
-
* window. When any thread is archived we push a fresh `thread_list_updated`
|
|
572
|
-
* so the sidebar reflects reality within the same tick.
|
|
573
|
-
*
|
|
574
|
-
* Safe on stores with `idleArchiveDays === 0` (returns no-op) and on
|
|
575
|
-
* sessions missing a threadStore handle (defensive; should never happen
|
|
576
|
-
* once `installUnifyRuntimeBridge` has run).
|
|
577
|
-
*
|
|
578
|
-
* @param {import('./session.js').Session|null} s
|
|
579
|
-
* @returns {string[]} archived thread ids (empty when nothing changed)
|
|
580
|
-
*/
|
|
581
|
-
export function runAutoArchiveSweep(s) {
|
|
582
|
-
try {
|
|
583
|
-
const store = s?.threadStore ?? (typeof getThreadStore === 'function' ? getThreadStore() : null);
|
|
584
|
-
if (!store || typeof store.runArchivePass !== 'function') return [];
|
|
585
|
-
const { archived } = store.runArchivePass();
|
|
586
|
-
if (archived && archived.length > 0) {
|
|
587
|
-
sendThreadListUpdate();
|
|
588
|
-
}
|
|
589
|
-
return archived || [];
|
|
590
|
-
} catch (err) {
|
|
591
|
-
console.warn('[Unify] runAutoArchiveSweep failed:', err?.message || err);
|
|
592
|
-
return [];
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* task-317: schedule the hourly auto-archive tick bound to the given
|
|
598
|
-
* session. Returns the `Timeout` handle so tests can assert / clear it.
|
|
599
|
-
* Re-calling replaces any prior timer (idempotent per-session).
|
|
600
|
-
*
|
|
601
|
-
* The timer is `unref()`'d so a pending tick never keeps the Node loop
|
|
602
|
-
* alive during shutdown; an explicit `clearAutoArchiveSchedule()` is
|
|
603
|
-
* provided for tests.
|
|
604
|
-
*/
|
|
605
|
-
let autoArchiveTimer = null;
|
|
606
|
-
const AUTO_ARCHIVE_TICK_MS = 60 * 60 * 1000; // 1h
|
|
607
|
-
|
|
608
|
-
export function scheduleAutoArchive(s, { intervalMs = AUTO_ARCHIVE_TICK_MS } = {}) {
|
|
609
|
-
if (autoArchiveTimer) {
|
|
610
|
-
clearInterval(autoArchiveTimer);
|
|
611
|
-
autoArchiveTimer = null;
|
|
612
|
-
}
|
|
613
|
-
if (!s) return null;
|
|
614
|
-
autoArchiveTimer = setInterval(() => {
|
|
615
|
-
runAutoArchiveSweep(s);
|
|
616
|
-
}, intervalMs);
|
|
617
|
-
if (autoArchiveTimer && typeof autoArchiveTimer.unref === 'function') {
|
|
618
|
-
autoArchiveTimer.unref();
|
|
619
|
-
}
|
|
620
|
-
return autoArchiveTimer;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
export function clearAutoArchiveSchedule() {
|
|
624
|
-
if (autoArchiveTimer) {
|
|
625
|
-
clearInterval(autoArchiveTimer);
|
|
626
|
-
autoArchiveTimer = null;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* task-301 Part 2: push the full thread list snapshot to the web client.
|
|
632
|
-
* Called after any ThreadStore-mutating tool completes and at turn_end so
|
|
633
|
-
* the sidebar V2 always shows a fresh picture. Cheap — ThreadStore keeps
|
|
634
|
-
* cached counters so list() is O(n) over a small n.
|
|
635
|
-
*/
|
|
636
|
-
function sendThreadListUpdate() {
|
|
637
|
-
try {
|
|
638
|
-
const store = getThreadStore();
|
|
639
|
-
const threads = store.list().map(t => ({
|
|
640
|
-
id: t.id,
|
|
641
|
-
name: t.name,
|
|
642
|
-
goal: t.goal || '',
|
|
643
|
-
parentThreadId: t.parentThreadId || null,
|
|
644
|
-
status: t.status,
|
|
645
|
-
archived: !!t.archived,
|
|
646
|
-
messageCount: t.messageCount || 0,
|
|
647
|
-
lastMessageAt: t.lastMessageAt || null,
|
|
648
|
-
lastActivityAt: t.lastActivityAt || t.lastMessageAt || t.updatedAt || null,
|
|
649
|
-
unread: t.unread || 0,
|
|
650
|
-
preview: t.preview || '',
|
|
651
|
-
createdAt: t.createdAt,
|
|
652
|
-
updatedAt: t.updatedAt,
|
|
653
|
-
// task-315: attached featureId (if any) so the UI can aggregate all
|
|
654
|
-
// messages belonging to a feature across multiple threads. null when
|
|
655
|
-
// the thread has no attached feature.
|
|
656
|
-
featureId: (typeof store.attachedFeature === 'function')
|
|
657
|
-
? (store.attachedFeature(t.id) || null)
|
|
658
|
-
: null,
|
|
659
|
-
// `running` — the thread whose id equals the store's currentId is
|
|
660
|
-
// considered the active/running track. The UI uses this for the
|
|
661
|
-
// green halo in the Active group.
|
|
662
|
-
running: t.id === store.currentId,
|
|
663
|
-
}));
|
|
664
|
-
sendUnifyEvent({ type: 'thread_list_updated', threads, currentThreadId: store.currentId });
|
|
665
|
-
} catch (err) {
|
|
666
|
-
// Best-effort; sidebar update must never block the main query path.
|
|
667
|
-
console.warn('[Unify] sendThreadListUpdate failed:', err?.message || err);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/** Tool names that mutate ThreadStore. After any of these we push an update. */
|
|
672
|
-
const THREAD_MUTATING_TOOLS = new Set([
|
|
673
|
-
'SpawnThread',
|
|
674
|
-
'SwitchThread',
|
|
675
|
-
'ArchiveThread',
|
|
676
|
-
'AttachThreadToFeature',
|
|
677
|
-
]);
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* task-325b — Working Status event stream.
|
|
681
|
-
*
|
|
682
|
-
* Surfaces Engine lifecycle events (emitted by 325a) as a single
|
|
683
|
-
* `thread_status` event for the frontend Working Status panel, plus
|
|
684
|
-
* `thread_list_snapshot` for cold-start / reconnect.
|
|
685
|
-
*
|
|
686
|
-
* Contract (aligned with designer spec):
|
|
687
|
-
* thread_status → { type: 'thread_status', threadId, state,
|
|
688
|
-
* startedAt?, completedAt?, toolName?, reason? }
|
|
689
|
-
* state ∈ 'running' | 'idle' | 'aborted' | 'error'
|
|
690
|
-
* thread_list_snapshot → { type: 'thread_list_snapshot', threads[],
|
|
691
|
-
* currentThreadId, serverTime }
|
|
692
|
-
*
|
|
693
|
-
* Red lines (per PM): do NOT mutate engine state; this layer is a pure
|
|
694
|
-
* observer + translator. Event names match designer doc verbatim.
|
|
695
|
-
*/
|
|
696
|
-
|
|
697
|
-
/** Map Engine event name → Working Status state string. */
|
|
698
|
-
function engineEventToState(engineEventType) {
|
|
699
|
-
switch (engineEventType) {
|
|
700
|
-
case 'thread_started': return 'running';
|
|
701
|
-
case 'thread_completed': return 'idle';
|
|
702
|
-
case 'thread_aborted': return 'aborted';
|
|
703
|
-
case 'thread_error': return 'error';
|
|
704
|
-
default: return null;
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* Build and broadcast a `thread_status` payload translated from a raw
|
|
710
|
-
* engine lifecycle event. The engine event shape (325a) is:
|
|
711
|
-
* { type, threadId, startedAt?, completedAt?, toolName?, reason? }
|
|
712
|
-
* Unknown fields pass through untouched so future engine additions
|
|
713
|
-
* (e.g. `attempt`) flow to the UI without another bridge change.
|
|
714
|
-
*
|
|
715
|
-
* @param {object} ev — engine event
|
|
716
|
-
* @returns {boolean} true if a thread_status was emitted
|
|
717
|
-
*/
|
|
718
|
-
function emitThreadStatusFromEngineEvent(ev) {
|
|
719
|
-
if (!ev || typeof ev !== 'object') return false;
|
|
720
|
-
const state = engineEventToState(ev.type);
|
|
721
|
-
if (!state) return false;
|
|
722
|
-
const payload = { type: 'thread_status', threadId: ev.threadId, state };
|
|
723
|
-
if (ev.startedAt != null) payload.startedAt = ev.startedAt;
|
|
724
|
-
if (ev.completedAt != null) payload.completedAt = ev.completedAt;
|
|
725
|
-
if (ev.toolName) payload.toolName = ev.toolName;
|
|
726
|
-
if (ev.reason) payload.reason = ev.reason;
|
|
727
|
-
if (ev.error?.message) payload.error = ev.error.message;
|
|
728
|
-
sendUnifyEvent(payload);
|
|
729
|
-
return true;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/**
|
|
733
|
-
* task-325b: full-snapshot push distinct from `thread_list_updated`.
|
|
734
|
-
* Emits `thread_list_snapshot` — a complete state dump the client uses
|
|
735
|
-
* on page load / WebSocket reconnect to rebuild the Working Status panel
|
|
736
|
-
* without missing any in-flight thread.
|
|
737
|
-
*
|
|
738
|
-
* Snapshot includes per-thread `state` (idle / running / aborted) resolved
|
|
739
|
-
* from the engine registry's live inflight set. Threads the registry has
|
|
740
|
-
* no entry for default to 'idle'.
|
|
741
|
-
*/
|
|
742
|
-
function sendThreadListSnapshot() {
|
|
743
|
-
try {
|
|
744
|
-
const store = getThreadStore();
|
|
745
|
-
const registry = session?.engineRegistry || null;
|
|
746
|
-
const inflight = new Set(
|
|
747
|
-
typeof registry?.inflightThreadIds === 'function'
|
|
748
|
-
? registry.inflightThreadIds()
|
|
749
|
-
: [],
|
|
750
|
-
);
|
|
751
|
-
const threads = store.list().map(t => ({
|
|
752
|
-
id: t.id,
|
|
753
|
-
name: t.name,
|
|
754
|
-
goal: t.goal || '',
|
|
755
|
-
parentThreadId: t.parentThreadId || null,
|
|
756
|
-
status: t.status,
|
|
757
|
-
archived: !!t.archived,
|
|
758
|
-
messageCount: t.messageCount || 0,
|
|
759
|
-
lastMessageAt: t.lastMessageAt || null,
|
|
760
|
-
lastActivityAt: t.lastActivityAt || t.lastMessageAt || t.updatedAt || null,
|
|
761
|
-
unread: t.unread || 0,
|
|
762
|
-
preview: t.preview || '',
|
|
763
|
-
createdAt: t.createdAt,
|
|
764
|
-
updatedAt: t.updatedAt,
|
|
765
|
-
featureId: (typeof store.attachedFeature === 'function')
|
|
766
|
-
? (store.attachedFeature(t.id) || null)
|
|
767
|
-
: null,
|
|
768
|
-
running: t.id === store.currentId,
|
|
769
|
-
state: inflight.has(t.id) ? 'running' : 'idle',
|
|
770
|
-
}));
|
|
771
|
-
sendUnifyEvent({
|
|
772
|
-
type: 'thread_list_snapshot',
|
|
773
|
-
threads,
|
|
774
|
-
currentThreadId: store.currentId,
|
|
775
|
-
serverTime: Date.now(),
|
|
776
|
-
});
|
|
777
|
-
} catch (err) {
|
|
778
|
-
console.warn('[Unify] sendThreadListSnapshot failed:', err?.message || err);
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* task-310: parse a leading `@thread-<id>` or `@thread-<name>` marker on
|
|
784
|
-
* the user's input and return it as a dispatcher override. The marker
|
|
785
|
-
* itself is STRIPPED from the prompt before it reaches the engine —
|
|
786
|
-
* users don't want to see `@thread-foo` echoed back into their
|
|
787
|
-
* conversation.
|
|
788
|
-
*
|
|
789
|
-
* Thread IDs are `main` or `thr-<8 hex>`, so the match captures the id
|
|
790
|
-
* name AFTER the literal `@thread-`. The returned `override.threadId`
|
|
791
|
-
* is the fully-qualified thread id (e.g. `thread-main`, `thread-thr-abcd1234`).
|
|
792
|
-
*
|
|
793
|
-
* Returns { prompt, override? } where override = { threadId } if matched.
|
|
794
|
-
*/
|
|
795
|
-
export function parseThreadPrefix(text) {
|
|
796
|
-
if (!text || typeof text !== 'string') return { prompt: text || '', override: null };
|
|
797
|
-
// Capture the id portion after the literal `@thread-` prefix.
|
|
798
|
-
const m = text.match(/^\s*@thread-([A-Za-z0-9_-]+)\b\s*/);
|
|
799
|
-
if (!m) return { prompt: text, override: null };
|
|
800
|
-
const rest = text.slice(m[0].length);
|
|
801
|
-
// The captured id may already include a `thr-` sub-prefix (for non-main
|
|
802
|
-
// threads). For the canonical `main` thread, the override is the bare
|
|
803
|
-
// string `main`; for `thr-xxxxxxxx` threads, pass through verbatim.
|
|
804
|
-
const threadId = m[1];
|
|
805
|
-
return { prompt: rest || text, override: { threadId } };
|
|
806
|
-
}
|
|
807
|
-
|
|
808
429
|
/**
|
|
809
430
|
* Translate a pipeline event (from Dispatcher) into web-bridge outputs.
|
|
810
431
|
* Pipeline events are distinct from engine events — they carry queue /
|
|
811
|
-
* routing state for the UI. Engine events are unwrapped and forwarded
|
|
812
|
-
* through the existing sendUnifyOutput / sendUnifyEvent path.
|
|
813
|
-
*
|
|
814
|
-
* Returns whether the pipeline is complete (terminal error / no more).
|
|
432
|
+
* routing state for the UI. Engine events are unwrapped and forwarded.
|
|
815
433
|
*/
|
|
816
|
-
function forwardPipelineEvent(ev,
|
|
434
|
+
function forwardPipelineEvent(ev, pctx) {
|
|
817
435
|
if (!ev || typeof ev !== 'object') return false;
|
|
818
|
-
const gid =
|
|
436
|
+
const gid = pctx && pctx.groupId;
|
|
819
437
|
switch (ev.type) {
|
|
820
438
|
case 'input_queue_updated':
|
|
821
439
|
sendUnifyEvent({
|
|
@@ -828,28 +446,21 @@ function forwardPipelineEvent(ev, ctx) {
|
|
|
828
446
|
}, gid);
|
|
829
447
|
return false;
|
|
830
448
|
case 'routing_decision':
|
|
449
|
+
// H2.f.2: still forwarded for wire compat, but frontend treats it as
|
|
450
|
+
// a no-op marker; targetThreadId is always 'main'.
|
|
831
451
|
sendUnifyEvent({
|
|
832
452
|
type: 'routing_decision',
|
|
833
453
|
entryId: ev.entryId,
|
|
834
454
|
action: ev.action,
|
|
835
|
-
targetThreadId: ev.targetThreadId,
|
|
836
455
|
source: ev.source,
|
|
837
456
|
reason: ev.reason,
|
|
838
457
|
}, gid);
|
|
839
458
|
return false;
|
|
840
|
-
case 'thread_list_updated':
|
|
841
|
-
// Dispatcher built it already; just forward.
|
|
842
|
-
sendUnifyEvent({
|
|
843
|
-
type: 'thread_list_updated',
|
|
844
|
-
threads: ev.threads,
|
|
845
|
-
currentThreadId: ev.currentThreadId,
|
|
846
|
-
}, gid);
|
|
847
|
-
return false;
|
|
848
459
|
case 'engine_event':
|
|
849
|
-
|
|
460
|
+
pctx.onEngineEvent(ev.event);
|
|
850
461
|
return false;
|
|
851
462
|
case 'error':
|
|
852
|
-
|
|
463
|
+
pctx.onError(ev.error);
|
|
853
464
|
return true;
|
|
854
465
|
default:
|
|
855
466
|
return false;
|
|
@@ -857,52 +468,32 @@ function forwardPipelineEvent(ev, ctx) {
|
|
|
857
468
|
}
|
|
858
469
|
|
|
859
470
|
/**
|
|
860
|
-
* Handle a single engine event unwrapped from an `engine_event`
|
|
861
|
-
*
|
|
862
|
-
* streaming loop. `threadId` is propagated onto tool_use / tool_result
|
|
863
|
-
* blocks so the UI can render per-thread bubbles.
|
|
471
|
+
* Handle a single engine event unwrapped from an `engine_event` envelope.
|
|
472
|
+
* H2.f.2: no longer stamps a threadId on outgoing claude_output frames.
|
|
864
473
|
*
|
|
865
474
|
* @param {object} event — engine event (text_delta / tool_call / …)
|
|
866
|
-
* @param {string
|
|
867
|
-
* @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, resetQueryTimer:Function}} hctx
|
|
475
|
+
* @param {{assistantTextParts:string[], toolCallsAccum:Array, toolResultsAccum:Array, resetQueryTimer:Function, groupId?:string}} hctx
|
|
868
476
|
*/
|
|
869
|
-
function handleEngineEvent(event,
|
|
477
|
+
function handleEngineEvent(event, hctx) {
|
|
870
478
|
hctx.resetQueryTimer();
|
|
871
479
|
const gid = hctx && hctx.groupId;
|
|
872
480
|
|
|
873
|
-
// task-325b: translate Engine lifecycle events into a single
|
|
874
|
-
// `thread_status` event for the frontend Working Status panel. These
|
|
875
|
-
// events are observer-only — they never mutate bridge state. The raw
|
|
876
|
-
// engine event is NOT forwarded further; the switch below handles
|
|
877
|
-
// anything the UI still needs.
|
|
878
|
-
if (event && (
|
|
879
|
-
event.type === 'thread_started' ||
|
|
880
|
-
event.type === 'thread_completed' ||
|
|
881
|
-
event.type === 'thread_aborted' ||
|
|
882
|
-
event.type === 'thread_error'
|
|
883
|
-
)) {
|
|
884
|
-
// Engine events carry their own threadId; fall back to envelope id.
|
|
885
|
-
emitThreadStatusFromEngineEvent({ ...event, threadId: event.threadId || threadId });
|
|
886
|
-
return;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
481
|
switch (event.type) {
|
|
890
482
|
case 'text_delta':
|
|
891
483
|
hctx.assistantTextParts.push(event.text);
|
|
892
484
|
sendUnifyOutput({
|
|
893
485
|
type: 'assistant',
|
|
894
486
|
message: { content: [{ type: 'text', text: event.text }] },
|
|
895
|
-
threadId,
|
|
896
487
|
}, gid);
|
|
897
488
|
break;
|
|
898
489
|
|
|
899
490
|
case 'thinking_delta':
|
|
900
|
-
sendUnifyEvent({ type: 'thinking_delta', text: event.text
|
|
491
|
+
sendUnifyEvent({ type: 'thinking_delta', text: event.text }, gid);
|
|
901
492
|
break;
|
|
902
493
|
|
|
903
494
|
case 'tool_call':
|
|
904
|
-
// Capture tool_call for the assistant message's toolCalls array so
|
|
905
|
-
// next turn's history
|
|
495
|
+
// Capture tool_call for the assistant message's toolCalls array so
|
|
496
|
+
// the next turn's history pairs `tool_calls` with `role:'tool'`
|
|
906
497
|
// results (fixes "No tool output found for function call" 400s).
|
|
907
498
|
if (hctx.toolCallsAccum) {
|
|
908
499
|
hctx.toolCallsAccum.push({
|
|
@@ -915,7 +506,6 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
915
506
|
sendUnifyOutput({
|
|
916
507
|
type: 'assistant',
|
|
917
508
|
message: { content: [] },
|
|
918
|
-
threadId,
|
|
919
509
|
}, gid);
|
|
920
510
|
sendUnifyOutput({
|
|
921
511
|
type: 'assistant',
|
|
@@ -927,7 +517,6 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
927
517
|
input: event.input,
|
|
928
518
|
}],
|
|
929
519
|
},
|
|
930
|
-
threadId: event.threadId || threadId,
|
|
931
520
|
}, gid);
|
|
932
521
|
break;
|
|
933
522
|
|
|
@@ -936,14 +525,10 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
936
525
|
type: 'tool_start',
|
|
937
526
|
id: event.id,
|
|
938
527
|
name: event.name,
|
|
939
|
-
threadId: event.threadId || threadId,
|
|
940
528
|
}, gid);
|
|
941
529
|
break;
|
|
942
530
|
|
|
943
531
|
case 'tool_end':
|
|
944
|
-
// Capture tool result for the next-turn history so the paired
|
|
945
|
-
// `role:'tool'` message is included when we hand `messages` back to
|
|
946
|
-
// engine.query() (chat-completions requires tool_call_id pairing).
|
|
947
532
|
if (hctx.toolResultsAccum) {
|
|
948
533
|
hctx.toolResultsAccum.push({
|
|
949
534
|
role: 'tool',
|
|
@@ -960,11 +545,7 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
960
545
|
content: event.output || '',
|
|
961
546
|
is_error: event.isError || false,
|
|
962
547
|
}],
|
|
963
|
-
threadId: event.threadId || threadId,
|
|
964
548
|
}, gid);
|
|
965
|
-
if (THREAD_MUTATING_TOOLS.has(event.name)) {
|
|
966
|
-
sendThreadListUpdate();
|
|
967
|
-
}
|
|
968
549
|
break;
|
|
969
550
|
|
|
970
551
|
case 'turn_start':
|
|
@@ -978,7 +559,6 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
978
559
|
type: 'context_usage',
|
|
979
560
|
inputTokens: event.inputTokens,
|
|
980
561
|
outputTokens: event.outputTokens,
|
|
981
|
-
threadId,
|
|
982
562
|
}, gid);
|
|
983
563
|
break;
|
|
984
564
|
|
|
@@ -987,23 +567,16 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
987
567
|
type: 'recall',
|
|
988
568
|
entryCount: event.entryCount,
|
|
989
569
|
cached: event.cached,
|
|
990
|
-
threadId,
|
|
991
570
|
}, gid);
|
|
992
571
|
break;
|
|
993
572
|
|
|
994
573
|
case 'consolidate':
|
|
995
|
-
// Engine compressed the context — clear our accumulated history
|
|
996
|
-
|
|
997
|
-
if (threadId) {
|
|
998
|
-
messagesByThread.set(threadId, []);
|
|
999
|
-
} else {
|
|
1000
|
-
messagesByThread.clear();
|
|
1001
|
-
}
|
|
574
|
+
// Engine compressed the context — clear our accumulated history.
|
|
575
|
+
conversationMessages = [];
|
|
1002
576
|
sendUnifyEvent({
|
|
1003
577
|
type: 'consolidate',
|
|
1004
578
|
archivedCount: event.archivedCount,
|
|
1005
579
|
extractedCount: event.extractedCount,
|
|
1006
|
-
threadId,
|
|
1007
580
|
}, gid);
|
|
1008
581
|
break;
|
|
1009
582
|
|
|
@@ -1013,15 +586,10 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
1013
586
|
from: event.from,
|
|
1014
587
|
to: event.to,
|
|
1015
588
|
reason: event.reason,
|
|
1016
|
-
threadId,
|
|
1017
589
|
}, gid);
|
|
1018
590
|
break;
|
|
1019
591
|
|
|
1020
592
|
case 'reflection':
|
|
1021
|
-
// PR-L: V7 tool-history reflection event. Two phases per occurrence:
|
|
1022
|
-
// status: 'pending' — generation kicked off
|
|
1023
|
-
// status: 'ready' — markdown content + durationMs
|
|
1024
|
-
// status: 'error' — generation failed (history left unchanged)
|
|
1025
593
|
sendUnifyEvent({
|
|
1026
594
|
type: 'reflection',
|
|
1027
595
|
trigger: event.trigger,
|
|
@@ -1031,7 +599,6 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
1031
599
|
content: event.content,
|
|
1032
600
|
durationMs: event.durationMs,
|
|
1033
601
|
error: event.error,
|
|
1034
|
-
threadId,
|
|
1035
602
|
}, gid);
|
|
1036
603
|
break;
|
|
1037
604
|
|
|
@@ -1048,18 +615,13 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
1048
615
|
latencyMs: event.latencyMs,
|
|
1049
616
|
ttfbMs: event.ttfbMs,
|
|
1050
617
|
stopReason: event.stopReason,
|
|
1051
|
-
// task-344: forward raw request / response (redacted) to web debug panel.
|
|
1052
618
|
rawRequest: event.rawRequest,
|
|
1053
619
|
rawResponse: event.rawResponse,
|
|
1054
|
-
threadId,
|
|
1055
620
|
}, gid);
|
|
1056
621
|
break;
|
|
1057
622
|
|
|
1058
623
|
case 'error': {
|
|
1059
624
|
const errMsg = event.error?.message || 'Unknown error';
|
|
1060
|
-
// Filter permission errors: show friendly one-time diagnostic
|
|
1061
|
-
// instead of raw error. Subsequent permission errors are suppressed
|
|
1062
|
-
// — the user already saw the actionable message once.
|
|
1063
625
|
if (isPermissionErrorMsg(errMsg)) {
|
|
1064
626
|
if (!_permissionDiagnosticSent) {
|
|
1065
627
|
_permissionDiagnosticSent = true;
|
|
@@ -1071,17 +633,14 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
1071
633
|
text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
|
|
1072
634
|
}],
|
|
1073
635
|
},
|
|
1074
|
-
threadId,
|
|
1075
636
|
}, gid);
|
|
1076
637
|
}
|
|
1077
|
-
// Don't show subsequent permission errors.
|
|
1078
638
|
} else {
|
|
1079
639
|
sendUnifyOutput({
|
|
1080
640
|
type: 'assistant',
|
|
1081
641
|
message: {
|
|
1082
642
|
content: [{ type: 'text', text: `⚠️ Error: ${errMsg}` }],
|
|
1083
643
|
},
|
|
1084
|
-
threadId,
|
|
1085
644
|
}, gid);
|
|
1086
645
|
}
|
|
1087
646
|
break;
|
|
@@ -1094,21 +653,8 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
1094
653
|
}
|
|
1095
654
|
|
|
1096
655
|
/**
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
* Routes user text through the group coordinator's dispatch contract:
|
|
1100
|
-
* 1. @-mentions → each mentioned vpId (intersected with group roster)
|
|
1101
|
-
* 2. no mention → group.defaultVpId
|
|
1102
|
-
* 3. no default VP → fallback to legacy single-agent handleUnifyChat
|
|
1103
|
-
*
|
|
1104
|
-
* Emits a `group_message` event tagged with `vpId` per dispatched target so
|
|
1105
|
-
* frontend can render VP-scoped feedback. Does NOT itself run the engine —
|
|
1106
|
-
* for each resolved target, it delegates into handleUnifyChat (which owns
|
|
1107
|
-
* the Dispatcher + AbortController + timeout plumbing).
|
|
1108
|
-
*
|
|
1109
|
-
* Message shape: { type:'unify_group_chat', groupId, text, mentions? }
|
|
1110
|
-
*
|
|
1111
|
-
* @param {{groupId:string, text:string, mentions?:string[], agentId?:string, userId?:string, username?:string}} msg
|
|
656
|
+
* Handle a unify_group_chat message from the web UI. Routes user text
|
|
657
|
+
* through the group coordinator's dispatch contract.
|
|
1112
658
|
*/
|
|
1113
659
|
export async function handleUnifyGroupChat(msg) {
|
|
1114
660
|
if (!msg || typeof msg !== 'object') return;
|
|
@@ -1116,17 +662,11 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1116
662
|
if (!text?.trim()) return;
|
|
1117
663
|
const mentions = Array.isArray(msg.mentions) ? msg.mentions : [];
|
|
1118
664
|
|
|
1119
|
-
// Fallback path #1 (PM red-line): no groupId on payload → skip group
|
|
1120
|
-
// resolution entirely and hand the text to the legacy single-agent
|
|
1121
|
-
// dispatcher. Ensures backward-compat with old clients that never learned
|
|
1122
|
-
// about groups.
|
|
1123
665
|
if (!groupId) {
|
|
1124
666
|
await handleUnifyChat({ ...msg, prompt: text });
|
|
1125
667
|
return;
|
|
1126
668
|
}
|
|
1127
669
|
|
|
1128
|
-
// Open the group handle (meta + jsonl log). Unresolvable groups take the
|
|
1129
|
-
// legacy fallback — never silently drop the send.
|
|
1130
670
|
let groupHandle = null;
|
|
1131
671
|
try {
|
|
1132
672
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
@@ -1149,12 +689,7 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1149
689
|
return;
|
|
1150
690
|
}
|
|
1151
691
|
|
|
1152
|
-
//
|
|
1153
|
-
// not yet in the group's roster, auto-add it. This is the natural "invite"
|
|
1154
|
-
// gesture in group chat — failing here would punt to the legacy fallback
|
|
1155
|
-
// and surface the misleading "only Yeaft is in this conversation" error
|
|
1156
|
-
// even though the VP exists. We also ensure the group has a defaultVpId
|
|
1157
|
-
// when its roster is non-empty, so unaddressed messages route correctly.
|
|
692
|
+
// Auto-add @-mentioned VPs from the library, heal missing defaultVpId.
|
|
1158
693
|
try {
|
|
1159
694
|
const meta = groupHandle.getMeta();
|
|
1160
695
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
@@ -1172,7 +707,6 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1172
707
|
} catch { /* skip strangers */ }
|
|
1173
708
|
}
|
|
1174
709
|
if (mutated) {
|
|
1175
|
-
// Re-open with fresh meta so the coordinator sees the new roster.
|
|
1176
710
|
try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
|
|
1177
711
|
const { openGroup } = await import('./groups/group-store.js');
|
|
1178
712
|
const { join } = await import('node:path');
|
|
@@ -1180,7 +714,6 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1180
714
|
sendGroupRosterChanged(groupHandle.getMeta());
|
|
1181
715
|
}
|
|
1182
716
|
}
|
|
1183
|
-
// Heal missing defaultVpId — pick roster[0] when one exists.
|
|
1184
717
|
const meta2 = groupHandle.getMeta();
|
|
1185
718
|
if (!meta2.defaultVpId && meta2.roster.length && yeaftDir) {
|
|
1186
719
|
try {
|
|
@@ -1196,17 +729,6 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1196
729
|
console.warn('[Unify] unify_group_chat: auto-roster heal failed', err?.message || err);
|
|
1197
730
|
}
|
|
1198
731
|
|
|
1199
|
-
// Adapter layer (PM red-line: do NOT modify coordinator to fit this
|
|
1200
|
-
// consumer). We drive `createCoordinator()` with a capturing `deliver`
|
|
1201
|
-
// callback, collect its dispatched/fallback report, then translate each
|
|
1202
|
-
// target into (a) a per-VP `group_message` event for the UI and (b) a
|
|
1203
|
-
// per-VP prompt dispatched through the legacy `handleUnifyChat` path.
|
|
1204
|
-
//
|
|
1205
|
-
// Source of truth for mentions is the PAYLOAD (ChatInput parsed them
|
|
1206
|
-
// once). We pass them through the coordinator's `input.meta` so the
|
|
1207
|
-
// appended log carries the authoritative set — the coordinator will also
|
|
1208
|
-
// run its own parse over `text` for routing, which matches the payload by
|
|
1209
|
-
// construction (ChatInput's `parseMentions` is the same regex).
|
|
1210
732
|
const { createCoordinator } = await import('./groups/coordinator.js');
|
|
1211
733
|
const captured = [];
|
|
1212
734
|
const coord = createCoordinator(groupHandle, {
|
|
@@ -1227,22 +749,12 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1227
749
|
return;
|
|
1228
750
|
}
|
|
1229
751
|
|
|
1230
|
-
// Fallback path #2: coordinator resolved no targets and no default VP.
|
|
1231
|
-
// Hand off to the legacy single-agent path so the text still runs.
|
|
1232
752
|
const dispatchedIds = Array.isArray(report?.dispatched) ? report.dispatched : [];
|
|
1233
753
|
if (dispatchedIds.length === 0 && !report?.fallback) {
|
|
1234
754
|
await handleUnifyChat({ ...msg, prompt: text });
|
|
1235
755
|
return;
|
|
1236
756
|
}
|
|
1237
757
|
|
|
1238
|
-
// Per target: emit `group_message` tagged with `speakerVpId` (the VP
|
|
1239
|
-
// being addressed — F3's GroupSelector binding consumes this) + dispatch
|
|
1240
|
-
// through the Engine via handleUnifyChat with an `@vp-<id>` prompt prefix.
|
|
1241
|
-
//
|
|
1242
|
-
// task-fix: bracket each per-VP dispatch with `vp_typing_start` /
|
|
1243
|
-
// `vp_typing_end` events so the frontend can render a per-speaker typing
|
|
1244
|
-
// dot next to that VP's avatar (matching IM apps). Avoids the old
|
|
1245
|
-
// "one global running cat for N concurrent speakers" ambiguity.
|
|
1246
758
|
for (const { vpId, envelope } of captured) {
|
|
1247
759
|
try {
|
|
1248
760
|
sendUnifyEvent({
|
|
@@ -1273,9 +785,6 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1273
785
|
groupId,
|
|
1274
786
|
vpId,
|
|
1275
787
|
speakerVpId: vpId,
|
|
1276
|
-
// Bug 4: hand the coordinator down so handleUnifyChat can build a
|
|
1277
|
-
// Router for the RouteForward tool. Field is namespaced with `_`
|
|
1278
|
-
// to mark it as an internal hop, never sent over WS.
|
|
1279
788
|
_groupCoordinator: coord,
|
|
1280
789
|
});
|
|
1281
790
|
} catch (err) {
|
|
@@ -1295,27 +804,8 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1295
804
|
|
|
1296
805
|
/**
|
|
1297
806
|
* Build the per-query VP context for the Engine.
|
|
1298
|
-
*
|
|
1299
|
-
* - Loads the addressed VP's persona via readVp() so the system prompt
|
|
1300
|
-
* speaks in that VP's voice (Bug 3 fix).
|
|
1301
|
-
* - When a GroupCoordinator handle is supplied, wraps it in a Router so
|
|
1302
|
-
* the RouteForward tool actually forwards instead of returning
|
|
1303
|
-
* `router_unavailable` (Bug 4 fix).
|
|
1304
|
-
*
|
|
1305
|
-
* Returns `undefined` when we have nothing to inject — keeps the legacy
|
|
1306
|
-
* single-agent path identical to before.
|
|
1307
807
|
*/
|
|
1308
808
|
export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
|
|
1309
|
-
// PR-G fix (Option A): when no vpId is supplied, resolve a default so the
|
|
1310
|
-
// engine still receives a vpPersona and the system prompt speaks as the
|
|
1311
|
-
// VP — not as legacy Yeaft. Resolution order:
|
|
1312
|
-
// 1. caller-supplied vpId (group/coordinator dispatch)
|
|
1313
|
-
// 2. open group's defaultVpId (`groupCoordinator.group.getMeta()`)
|
|
1314
|
-
// 3. session config `defaultVpId` (~/.yeaft/config.json)
|
|
1315
|
-
// 4. first VP in the local library (scanVpLibrary)
|
|
1316
|
-
// Cold-start (empty library) returns undefined — engine then falls back
|
|
1317
|
-
// to the legacy Yeaft identity, which is the intentional baseline only
|
|
1318
|
-
// when no VP is available at all.
|
|
1319
809
|
let resolvedVpId = vpId;
|
|
1320
810
|
if (!resolvedVpId) {
|
|
1321
811
|
try {
|
|
@@ -1362,8 +852,7 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
|
|
|
1362
852
|
try {
|
|
1363
853
|
out.router = createRouter({ coordinator: groupCoordinator });
|
|
1364
854
|
} catch {
|
|
1365
|
-
// Router build failure is non-fatal
|
|
1366
|
-
// router_unavailable and the VP can pivot.
|
|
855
|
+
// Router build failure is non-fatal.
|
|
1367
856
|
}
|
|
1368
857
|
}
|
|
1369
858
|
return out;
|
|
@@ -1372,51 +861,33 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
|
|
|
1372
861
|
/**
|
|
1373
862
|
* Handle a unify_chat message from the web UI.
|
|
1374
863
|
*
|
|
864
|
+
* H2.f.2: a new message cancels the prior in-flight controller (single
|
|
865
|
+
* conversation, not per-thread). The history accumulator is a flat array.
|
|
866
|
+
*
|
|
1375
867
|
* @param {{ prompt: string, mode?: string, userId?: string, username?: string }} msg
|
|
1376
|
-
* NOTE: `mode` is deprecated (task-297) — Unify now runs in a single unified mode.
|
|
1377
|
-
* If present, a warning is logged and the field is ignored.
|
|
1378
868
|
*/
|
|
1379
869
|
export async function handleUnifyChat(msg) {
|
|
1380
870
|
const { prompt, mode } = msg;
|
|
1381
871
|
if (!prompt?.trim()) return;
|
|
1382
|
-
// Bug 3 / Bug 4 — when the upstream group dispatcher addresses a specific
|
|
1383
|
-
// VP, it stamps `vpId` (and optionally a coordinator handle in
|
|
1384
|
-
// `_groupCoordinator`). Hoist them now so they survive the lazy-init
|
|
1385
|
-
// branch and reach the dispatcher.submit() queryOpts.
|
|
1386
872
|
const vpId = typeof msg.vpId === 'string' && msg.vpId.trim() ? msg.vpId.trim() : null;
|
|
1387
873
|
const groupCoordinator = msg._groupCoordinator || null;
|
|
1388
|
-
// Bug 1: every event we emit during this query must carry the originating
|
|
1389
|
-
// groupId so the frontend stamps arriving messages with the SEND-context
|
|
1390
|
-
// group, not the user's CURRENT filter (which can change mid-reply).
|
|
1391
874
|
const groupId = typeof msg.groupId === 'string' && msg.groupId.trim() ? msg.groupId.trim() : null;
|
|
1392
875
|
|
|
1393
|
-
// Deprecation warning — task-297 removed chat/work mode distinction
|
|
1394
876
|
if (mode !== undefined && mode !== null) {
|
|
1395
877
|
console.warn('[Unify] unify_chat.mode is deprecated and ignored — Unify now runs in a single unified mode.');
|
|
1396
878
|
}
|
|
1397
879
|
|
|
1398
880
|
try {
|
|
1399
|
-
// ─── Lazy-init session (reuse across queries — Engine manages history) ──
|
|
1400
881
|
if (!session) {
|
|
1401
882
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1402
883
|
session = await loadSession({
|
|
1403
884
|
...(yeaftDir && { dir: yeaftDir }),
|
|
1404
|
-
// Enable all features — no lazy shortcuts
|
|
1405
885
|
skipMCP: false,
|
|
1406
886
|
skipSkills: false,
|
|
1407
887
|
});
|
|
1408
888
|
|
|
1409
|
-
// task-318 rev-1 fix: expose live setters on ctx so message-router's
|
|
1410
|
-
// update_unify_settings branch can push the new caps into the
|
|
1411
|
-
// registry + thread store without a session reload. Previously this
|
|
1412
|
-
// object was null and setMaxConcurrent/setIdleArchiveDays were dead
|
|
1413
|
-
// code — the config file was updated on disk but the running
|
|
1414
|
-
// session continued with the old caps until next restart.
|
|
1415
889
|
installUnifyRuntimeBridge(session);
|
|
1416
890
|
|
|
1417
|
-
// PR-M1: install a sub-agent event sink so events emitted by sub-
|
|
1418
|
-
// agent Engines surface to the web client. Frontend filters by the
|
|
1419
|
-
// `agentId` field and renders them inside the sub-agent card.
|
|
1420
891
|
try {
|
|
1421
892
|
if (session.engine && typeof session.engine.setSubAgentEventSink === 'function') {
|
|
1422
893
|
session.engine.setSubAgentEventSink((agentId, evt) => {
|
|
@@ -1429,14 +900,7 @@ export async function handleUnifyChat(msg) {
|
|
|
1429
900
|
console.warn('[Unify] setSubAgentEventSink wiring failed:', err?.message || err);
|
|
1430
901
|
}
|
|
1431
902
|
|
|
1432
|
-
//
|
|
1433
|
-
// the hourly tick bound to this session.
|
|
1434
|
-
runAutoArchiveSweep(session);
|
|
1435
|
-
scheduleAutoArchive(session);
|
|
1436
|
-
|
|
1437
|
-
// Bug 8: clean up any legacy `.archived-*` group directories left
|
|
1438
|
-
// behind by the previous soft-archive flow. This is a one-shot
|
|
1439
|
-
// boot-time sweep — physical deletes after this point are immediate.
|
|
903
|
+
// Bug 8: clean up legacy `.archived-*` group dirs at boot.
|
|
1440
904
|
try {
|
|
1441
905
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
1442
906
|
if (yeaftDir) {
|
|
@@ -1449,16 +913,10 @@ export async function handleUnifyChat(msg) {
|
|
|
1449
913
|
console.warn('[Unify] purgeArchivedGroups failed:', err?.message || err);
|
|
1450
914
|
}
|
|
1451
915
|
|
|
1452
|
-
// Create a stable conversationId for the Unify session
|
|
1453
916
|
unifyConversationId = `unify-${Date.now()}`;
|
|
1454
917
|
|
|
1455
|
-
|
|
1456
|
-
// task-320: bucket by threadId so each thread keeps its own context.
|
|
1457
|
-
// task-fix: use restoreThreadHistoryFromRecent() so tool messages
|
|
1458
|
-
// and toolCalls/toolCallId survive the restore.
|
|
1459
|
-
restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
|
|
918
|
+
restoreHistoryFromRecent(session.conversationStore.loadRecent(50));
|
|
1460
919
|
|
|
1461
|
-
// Notify UI: session is ready with model info + conversationId
|
|
1462
920
|
sendUnifyEvent({
|
|
1463
921
|
type: 'session_ready',
|
|
1464
922
|
conversationId: unifyConversationId,
|
|
@@ -1468,35 +926,20 @@ export async function handleUnifyChat(msg) {
|
|
|
1468
926
|
mcpServers: session.status.mcpServers,
|
|
1469
927
|
tools: session.status.tools,
|
|
1470
928
|
});
|
|
1471
|
-
// task-301 Part 2: initial thread snapshot so sidebar V2 renders
|
|
1472
|
-
// the real 'main' thread (and any restored threads) right away.
|
|
1473
|
-
sendThreadListUpdate();
|
|
1474
|
-
// task-325b: full Working Status snapshot (superset with state +
|
|
1475
|
-
// serverTime) so a freshly-connected client can restore inflight
|
|
1476
|
-
// status without waiting for the next engine event.
|
|
1477
|
-
sendThreadListSnapshot();
|
|
1478
|
-
// task-334m: push initial groups snapshot so the Sidebar Groups
|
|
1479
|
-
// section renders the full list immediately (including the D1
|
|
1480
|
-
// default group seeded during session bootstrap).
|
|
1481
929
|
sendGroupSnapshotBroadcast();
|
|
1482
930
|
}
|
|
1483
931
|
|
|
1484
|
-
// wave-6b: notify dream scheduler of user activity
|
|
1485
932
|
if (session?.dreamScheduler) {
|
|
1486
933
|
session.dreamScheduler.noteUserMessage();
|
|
1487
934
|
}
|
|
1488
935
|
|
|
1489
|
-
//
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
// threads never alias each other's signals.
|
|
936
|
+
// Cancel any prior in-flight round before starting this one.
|
|
937
|
+
if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
|
|
938
|
+
try { currentAbortCtrl.abort(); } catch { /* best-effort */ }
|
|
939
|
+
}
|
|
1494
940
|
const abortCtrl = new AbortController();
|
|
1495
|
-
|
|
1496
|
-
let resolvedThreadId = null;
|
|
941
|
+
currentAbortCtrl = abortCtrl;
|
|
1497
942
|
|
|
1498
|
-
// ─── Timeout guard: abort query if LLM hangs beyond threshold ──
|
|
1499
|
-
// Resets on every event — fires only after prolonged silence.
|
|
1500
943
|
let queryTimer = null;
|
|
1501
944
|
const resetQueryTimer = () => {
|
|
1502
945
|
if (queryTimer) clearTimeout(queryTimer);
|
|
@@ -1510,128 +953,79 @@ export async function handleUnifyChat(msg) {
|
|
|
1510
953
|
resetQueryTimer();
|
|
1511
954
|
|
|
1512
955
|
try {
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
// the next engine.query({messages}) handoff stays valid for OpenAI
|
|
1517
|
-
// chat-completions (avoids "No tool output found for function call").
|
|
1518
|
-
const toolCallsAccum = [];
|
|
1519
|
-
const toolResultsAccum = [];
|
|
1520
|
-
|
|
1521
|
-
// task-310: route via Dispatcher pipeline (queue → router → registry →
|
|
1522
|
-
// EngineInstance). The input is enqueued first so the UI observes the
|
|
1523
|
-
// `input_queue_updated` snapshot before the router runs. An explicit
|
|
1524
|
-
// `@thread-xxx` prefix on the message or an `override` field on the
|
|
1525
|
-
// `unify_chat` payload becomes a dispatcher override — skipping the LLM.
|
|
1526
|
-
const { prompt: cleanedPrompt, override: prefixOverride } = parseThreadPrefix(prompt);
|
|
1527
|
-
const override = msg.override && typeof msg.override === 'object' && msg.override.threadId
|
|
1528
|
-
? msg.override
|
|
1529
|
-
: prefixOverride;
|
|
1530
|
-
|
|
1531
|
-
const { entry } = session.dispatcher.submit(cleanedPrompt, {
|
|
1532
|
-
messageId: msg.messageId,
|
|
1533
|
-
override: override || undefined,
|
|
1534
|
-
queryOpts: buildVpQueryOpts({ vpId, groupCoordinator, groupId }),
|
|
1535
|
-
});
|
|
1536
|
-
sendUnifyEvent({
|
|
1537
|
-
type: 'input_queue_updated',
|
|
1538
|
-
total: 1,
|
|
1539
|
-
pending: 1,
|
|
1540
|
-
routing: 0,
|
|
1541
|
-
dispatched: 0,
|
|
1542
|
-
head: { id: entry.id, status: entry.status, text: entry.text.slice(0, 80) },
|
|
1543
|
-
}, groupId);
|
|
956
|
+
const assistantTextParts = [];
|
|
957
|
+
const toolCallsAccum = [];
|
|
958
|
+
const toolResultsAccum = [];
|
|
1544
959
|
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
960
|
+
const { entry } = session.dispatcher.submit(prompt, {
|
|
961
|
+
messageId: msg.messageId,
|
|
962
|
+
queryOpts: buildVpQueryOpts({ vpId, groupCoordinator, groupId }),
|
|
963
|
+
});
|
|
964
|
+
sendUnifyEvent({
|
|
965
|
+
type: 'input_queue_updated',
|
|
966
|
+
total: 1,
|
|
967
|
+
pending: 1,
|
|
968
|
+
routing: 0,
|
|
969
|
+
dispatched: 0,
|
|
970
|
+
head: { id: entry.id, status: entry.status, text: entry.text.slice(0, 80) },
|
|
971
|
+
}, groupId);
|
|
1556
972
|
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
abortByThread.set(resolvedThreadId, abortCtrl);
|
|
1569
|
-
}
|
|
1570
|
-
forwardPipelineEvent(pev, pipelineCtx);
|
|
1571
|
-
}
|
|
973
|
+
const pipelineCtx = {
|
|
974
|
+
groupId,
|
|
975
|
+
onEngineEvent: (event) => handleEngineEvent(event, {
|
|
976
|
+
assistantTextParts,
|
|
977
|
+
toolCallsAccum,
|
|
978
|
+
toolResultsAccum,
|
|
979
|
+
resetQueryTimer,
|
|
980
|
+
groupId,
|
|
981
|
+
}),
|
|
982
|
+
onError: (err) => { throw err; },
|
|
983
|
+
};
|
|
1572
984
|
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
// carry `toolCalls` AND each paired `role:'tool'` result must be
|
|
1577
|
-
// appended — otherwise the next turn's chat-completions serializer
|
|
1578
|
-
// emits `tool_calls` without matching `tool` messages → proxy 400
|
|
1579
|
-
// "No tool output found for function call call_xxx".
|
|
1580
|
-
const historyThread = resolvedThreadId || MAIN_THREAD_ID;
|
|
1581
|
-
const threadMessages = getThreadMessages(historyThread);
|
|
1582
|
-
threadMessages.push({ role: 'user', content: cleanedPrompt });
|
|
1583
|
-
|
|
1584
|
-
const fullText = assistantTextParts.join('');
|
|
1585
|
-
if (fullText || toolCallsAccum.length > 0) {
|
|
1586
|
-
const assistantMsg = { role: 'assistant', content: fullText };
|
|
1587
|
-
if (toolCallsAccum.length > 0) {
|
|
1588
|
-
assistantMsg.toolCalls = toolCallsAccum.map(tc => ({
|
|
1589
|
-
id: tc.id,
|
|
1590
|
-
name: tc.name,
|
|
1591
|
-
input: tc.input,
|
|
1592
|
-
}));
|
|
985
|
+
for await (const pev of session.dispatcher.drain({ signal: abortCtrl.signal })) {
|
|
986
|
+
resetQueryTimer();
|
|
987
|
+
forwardPipelineEvent(pev, pipelineCtx);
|
|
1593
988
|
}
|
|
1594
|
-
threadMessages.push(assistantMsg);
|
|
1595
989
|
|
|
1596
|
-
//
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
990
|
+
// Accumulate messages for context continuity.
|
|
991
|
+
conversationMessages.push({ role: 'user', content: prompt });
|
|
992
|
+
|
|
993
|
+
const fullText = assistantTextParts.join('');
|
|
994
|
+
if (fullText || toolCallsAccum.length > 0) {
|
|
995
|
+
const assistantMsg = { role: 'assistant', content: fullText };
|
|
996
|
+
if (toolCallsAccum.length > 0) {
|
|
997
|
+
assistantMsg.toolCalls = toolCallsAccum.map(tc => ({
|
|
998
|
+
id: tc.id,
|
|
999
|
+
name: tc.name,
|
|
1000
|
+
input: tc.input,
|
|
1001
|
+
}));
|
|
1002
|
+
}
|
|
1003
|
+
conversationMessages.push(assistantMsg);
|
|
1004
|
+
|
|
1005
|
+
for (const tr of toolResultsAccum) {
|
|
1006
|
+
conversationMessages.push({
|
|
1007
|
+
role: 'tool',
|
|
1008
|
+
toolCallId: tr.toolCallId,
|
|
1009
|
+
content: tr.content,
|
|
1010
|
+
isError: tr.isError,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1606
1013
|
}
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
// ─── Signal turn end to UI ──
|
|
1610
|
-
// Finish any streaming text
|
|
1611
|
-
sendUnifyOutput({
|
|
1612
|
-
type: 'assistant',
|
|
1613
|
-
message: { content: [] },
|
|
1614
|
-
}, groupId);
|
|
1615
|
-
// Send result to clear processing state
|
|
1616
|
-
sendUnifyOutput({
|
|
1617
|
-
type: 'result',
|
|
1618
|
-
result_text: '',
|
|
1619
|
-
}, groupId);
|
|
1620
1014
|
|
|
1015
|
+
sendUnifyOutput({
|
|
1016
|
+
type: 'assistant',
|
|
1017
|
+
message: { content: [] },
|
|
1018
|
+
}, groupId);
|
|
1019
|
+
sendUnifyOutput({
|
|
1020
|
+
type: 'result',
|
|
1021
|
+
result_text: '',
|
|
1022
|
+
}, groupId);
|
|
1621
1023
|
} finally {
|
|
1622
|
-
// Always clear the timeout guard
|
|
1623
1024
|
if (queryTimer) clearTimeout(queryTimer);
|
|
1624
1025
|
}
|
|
1625
|
-
|
|
1626
1026
|
} catch (err) {
|
|
1627
|
-
// task-320: classify both DOM AbortError and LLMAbortError as
|
|
1628
|
-
// "aborted" — LLMAbortError is thrown by the LLM adapters when the
|
|
1629
|
-
// signal trips and must NOT render as a session error bubble.
|
|
1630
1027
|
const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
|
|
1631
1028
|
if (isAbort) {
|
|
1632
|
-
// Silent abort — the new in-flight round (on the same thread) will
|
|
1633
|
-
// produce its own output. Still send `result` so the frontend's
|
|
1634
|
-
// processing spinner for this exact send clears.
|
|
1635
1029
|
sendUnifyOutput({
|
|
1636
1030
|
type: 'result',
|
|
1637
1031
|
result_text: '',
|
|
@@ -1641,7 +1035,6 @@ export async function handleUnifyChat(msg) {
|
|
|
1641
1035
|
|
|
1642
1036
|
console.error('[Unify] query error:', err.message);
|
|
1643
1037
|
|
|
1644
|
-
// Filter permission errors at the session level too
|
|
1645
1038
|
if (isPermissionErrorMsg(err.message)) {
|
|
1646
1039
|
if (!_permissionDiagnosticSent) {
|
|
1647
1040
|
_permissionDiagnosticSent = true;
|
|
@@ -1666,111 +1059,75 @@ export async function handleUnifyChat(msg) {
|
|
|
1666
1059
|
},
|
|
1667
1060
|
}, groupId);
|
|
1668
1061
|
}
|
|
1669
|
-
// Still send result to clear processing state
|
|
1670
1062
|
sendUnifyOutput({
|
|
1671
1063
|
type: 'result',
|
|
1672
1064
|
result_text: '',
|
|
1673
1065
|
}, groupId);
|
|
1674
1066
|
} finally {
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
// the newer controller in the map is the correct state.
|
|
1678
|
-
if (resolvedThreadId && abortByThread.get(resolvedThreadId) === abortCtrl) {
|
|
1679
|
-
abortByThread.delete(resolvedThreadId);
|
|
1067
|
+
if (currentAbortCtrl && currentAbortCtrl.signal.aborted) {
|
|
1068
|
+
// Aborted controllers stay where they are; a new query will replace.
|
|
1680
1069
|
}
|
|
1681
1070
|
}
|
|
1682
1071
|
}
|
|
1683
1072
|
|
|
1684
1073
|
/**
|
|
1685
|
-
*
|
|
1074
|
+
* H2.f.2: user-initiated abort. The pre-H2 multi-thread version took a
|
|
1075
|
+
* `threadId` parameter; the new version aborts the single in-flight
|
|
1076
|
+
* controller. The `threadId` field on `msg` is accepted but ignored for
|
|
1077
|
+
* back-compat with older clients.
|
|
1686
1078
|
*
|
|
1687
|
-
*
|
|
1688
|
-
* no-op when the thread has no in-flight round — users clicking Stop on an
|
|
1689
|
-
* already-idle thread should not trigger an error bubble. Emits an
|
|
1690
|
-
* `unify_aborted` event for UI acknowledgement and a fresh
|
|
1691
|
-
* `thread_list_updated` so inflight pills clear immediately.
|
|
1692
|
-
*
|
|
1693
|
-
* Red line (PM): the `thread_list_updated` event name is preserved; no
|
|
1694
|
-
* new per-thread abort signal leaks into `Engine.abort()`'s signature.
|
|
1695
|
-
*
|
|
1696
|
-
* @param {{ threadId?: string }} msg
|
|
1079
|
+
* @param {{ threadId?: string }} _msg
|
|
1697
1080
|
* @returns {{ aborted: string[], all: boolean }}
|
|
1698
1081
|
*/
|
|
1699
|
-
export function handleUnifyAbortThread(
|
|
1082
|
+
export function handleUnifyAbortThread(_msg = {}) {
|
|
1700
1083
|
const aborted = [];
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
const ctrl = abortByThread.get(threadId);
|
|
1704
|
-
if (ctrl) {
|
|
1705
|
-
try { ctrl.abort(); } catch { /* best-effort */ }
|
|
1706
|
-
abortByThread.delete(threadId);
|
|
1707
|
-
aborted.push(threadId);
|
|
1708
|
-
}
|
|
1084
|
+
if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
|
|
1085
|
+
try { currentAbortCtrl.abort(); aborted.push('main'); } catch { /* best-effort */ }
|
|
1709
1086
|
}
|
|
1087
|
+
currentAbortCtrl = null;
|
|
1710
1088
|
sendUnifyEvent({ type: 'unify_aborted', aborted, all: false });
|
|
1711
|
-
sendThreadListUpdate();
|
|
1712
1089
|
return { aborted, all: false };
|
|
1713
1090
|
}
|
|
1714
1091
|
|
|
1715
1092
|
/**
|
|
1716
|
-
*
|
|
1717
|
-
*
|
|
1718
|
-
* Iterates every registered controller, aborts it, then clears the map.
|
|
1719
|
-
* Always emits `unify_aborted` with `all:true` (even when nothing was
|
|
1720
|
-
* running) so the UI can confirm the click landed.
|
|
1721
|
-
*
|
|
1093
|
+
* H2.f.2: abort all (single conversation → same as abort one).
|
|
1722
1094
|
* @returns {{ aborted: string[], all: boolean }}
|
|
1723
1095
|
*/
|
|
1724
1096
|
export function handleUnifyAbortAll() {
|
|
1725
1097
|
const aborted = [];
|
|
1726
|
-
|
|
1727
|
-
try {
|
|
1728
|
-
aborted.push(threadId);
|
|
1098
|
+
if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
|
|
1099
|
+
try { currentAbortCtrl.abort(); aborted.push('main'); } catch { /* best-effort */ }
|
|
1729
1100
|
}
|
|
1730
|
-
|
|
1101
|
+
currentAbortCtrl = null;
|
|
1731
1102
|
sendUnifyEvent({ type: 'unify_aborted', aborted, all: true });
|
|
1732
|
-
sendThreadListUpdate();
|
|
1733
1103
|
return { aborted, all: true };
|
|
1734
1104
|
}
|
|
1735
1105
|
|
|
1736
1106
|
/**
|
|
1737
|
-
* Unified
|
|
1738
|
-
* Routes to {@link handleUnifyAbortThread} or {@link handleUnifyAbortAll}
|
|
1739
|
-
* per input. Kept exported so message-router and tests can call it too.
|
|
1740
|
-
*
|
|
1107
|
+
* Unified abort entry: routes by payload shape.
|
|
1741
1108
|
* @param {{ threadId?: string, all?: boolean }} [opts]
|
|
1742
1109
|
*/
|
|
1743
1110
|
export function abortUnifySession(opts = {}) {
|
|
1744
1111
|
if (opts && opts.all) return handleUnifyAbortAll();
|
|
1745
1112
|
if (opts && opts.threadId) return handleUnifyAbortThread({ threadId: opts.threadId });
|
|
1746
|
-
// No payload — conservative
|
|
1747
|
-
// callers see the no-op round-trip. Matches PM "don't accidentally
|
|
1748
|
-
// nuke everything on a bare click".
|
|
1113
|
+
// No payload — conservative no-op ack.
|
|
1749
1114
|
sendUnifyEvent({ type: 'unify_aborted', aborted: [], all: false });
|
|
1750
1115
|
return { aborted: [], all: false };
|
|
1751
1116
|
}
|
|
1752
1117
|
|
|
1753
|
-
/**
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
* @private
|
|
1758
|
-
*/
|
|
1759
|
-
export function __testSeedAbortController(threadId, ctrl) {
|
|
1760
|
-
abortByThread.set(threadId, ctrl);
|
|
1118
|
+
/** Test-only: seed the in-flight controller. */
|
|
1119
|
+
export function __testSeedAbortController(_threadId, ctrl) {
|
|
1120
|
+
// _threadId is ignored — H2.f.2 has a single controller.
|
|
1121
|
+
currentAbortCtrl = ctrl;
|
|
1761
1122
|
}
|
|
1762
1123
|
|
|
1763
|
-
/** Test-only: returns
|
|
1124
|
+
/** Test-only: returns ['main'] when a controller is registered, else []. */
|
|
1764
1125
|
export function __testGetRegisteredThreadIds() {
|
|
1765
|
-
return [
|
|
1126
|
+
return currentAbortCtrl && !currentAbortCtrl.signal.aborted ? ['main'] : [];
|
|
1766
1127
|
}
|
|
1767
1128
|
|
|
1768
1129
|
/**
|
|
1769
|
-
*
|
|
1770
|
-
* Payload: { vpId?: string } (optional, defaults to 'default').
|
|
1771
|
-
* Emits unify_dream_result with the dream outcome.
|
|
1772
|
-
*
|
|
1773
|
-
* @param {{ vpId?: string }} msg
|
|
1130
|
+
* Manual dream trigger from VP detail page.
|
|
1774
1131
|
*/
|
|
1775
1132
|
export async function handleUnifyDreamTrigger(msg = {}) {
|
|
1776
1133
|
if (!session?.dreamScheduler) {
|
|
@@ -1809,39 +1166,12 @@ export async function handleUnifyDreamTrigger(msg = {}) {
|
|
|
1809
1166
|
}
|
|
1810
1167
|
}
|
|
1811
1168
|
|
|
1812
|
-
/**
|
|
1813
|
-
* Handle mode switch from the web UI.
|
|
1814
|
-
* DEPRECATED (task-297): Unify no longer has chat/work mode distinction.
|
|
1815
|
-
* Retained as a no-op with warning for backward compatibility.
|
|
1816
|
-
* @param {{ mode?: string }} _msg
|
|
1817
|
-
*/
|
|
1169
|
+
/** Deprecated mode switch — Unify is single-mode. */
|
|
1818
1170
|
export function handleUnifyModeSwitch(_msg) {
|
|
1819
1171
|
console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
|
|
1820
1172
|
}
|
|
1821
1173
|
|
|
1822
|
-
/**
|
|
1823
|
-
* R6 G2 — VP/Feature memory browser query.
|
|
1824
|
-
*
|
|
1825
|
-
* Reads from session.memoryShardStore (R6 shard-based memory) and replies
|
|
1826
|
-
* with a time-sorted list of entries scoped to the requested vpId / featureId.
|
|
1827
|
-
* The web UI's MemoryCard / MemoryTraceModal consume the reply.
|
|
1828
|
-
*
|
|
1829
|
-
* Request shape:
|
|
1830
|
-
* { type: 'unify_memory_query',
|
|
1831
|
-
* vpId?: string, featureId?: string,
|
|
1832
|
-
* limit?: number, requestId?: string }
|
|
1833
|
-
*
|
|
1834
|
-
* Reply shape:
|
|
1835
|
-
* { type: 'unify_memory_query_result',
|
|
1836
|
-
* scope: { vpId, featureId },
|
|
1837
|
-
* entries: Array<thinEntry>, // shape from shard-store mapRecordToThinEntry
|
|
1838
|
-
* requestId? }
|
|
1839
|
-
*
|
|
1840
|
-
* Per D2 the query is scoped — the LLM owns memory recall via the
|
|
1841
|
-
* memory_query tool; this surface is purely UI browsing (read-only).
|
|
1842
|
-
*
|
|
1843
|
-
* @param {{ vpId?: string, featureId?: string, limit?: number, requestId?: string }} msg
|
|
1844
|
-
*/
|
|
1174
|
+
/** Read-only memory query for the UI memory browser. */
|
|
1845
1175
|
export function handleUnifyMemoryQuery(msg = {}) {
|
|
1846
1176
|
const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
1847
1177
|
const vpId = typeof msg.vpId === 'string' ? msg.vpId : null;
|
|
@@ -1866,7 +1196,6 @@ export function handleUnifyMemoryQuery(msg = {}) {
|
|
|
1866
1196
|
if (featureId) filter.feature = featureId;
|
|
1867
1197
|
const res = session.memoryShardStore.query(filter);
|
|
1868
1198
|
const list = Array.isArray(res?.results) ? res.results : [];
|
|
1869
|
-
// Time-sorted desc on updatedAt / createdAt.
|
|
1870
1199
|
list.sort((a, b) => {
|
|
1871
1200
|
const ax = (a && (a.updatedAt || a.createdAt)) || 0;
|
|
1872
1201
|
const bx = (b && (b.updatedAt || b.createdAt)) || 0;
|
|
@@ -1880,23 +1209,7 @@ export function handleUnifyMemoryQuery(msg = {}) {
|
|
|
1880
1209
|
}
|
|
1881
1210
|
}
|
|
1882
1211
|
|
|
1883
|
-
/**
|
|
1884
|
-
* R6 G2 — Open the source message behind a memory entry (memory_trace).
|
|
1885
|
-
*
|
|
1886
|
-
* Resolves entry → sourceRef.{conversationId, messageId} (or threadId/range)
|
|
1887
|
-
* via MemoryShardStore.get(entryId), then echoes the reference + the entry
|
|
1888
|
-
* for the MemoryTraceModal to render. The trace itself is a read-only
|
|
1889
|
-
* surface — the UI follows the conversationId/messageId to MessageList.
|
|
1890
|
-
*
|
|
1891
|
-
* Request shape:
|
|
1892
|
-
* { type: 'unify_memory_trace', entryId: string, requestId?: string }
|
|
1893
|
-
*
|
|
1894
|
-
* Reply shape:
|
|
1895
|
-
* { type: 'unify_memory_trace_result',
|
|
1896
|
-
* entryId, entry: object|null, sourceRef: object|null, requestId? }
|
|
1897
|
-
*
|
|
1898
|
-
* @param {{ entryId?: string, requestId?: string }} msg
|
|
1899
|
-
*/
|
|
1212
|
+
/** Open the source message behind a memory entry. */
|
|
1900
1213
|
export function handleUnifyMemoryTrace(msg = {}) {
|
|
1901
1214
|
const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
1902
1215
|
const entryId = typeof msg.entryId === 'string' ? msg.entryId : null;
|
|
@@ -1922,21 +1235,7 @@ export function handleUnifyMemoryTrace(msg = {}) {
|
|
|
1922
1235
|
}
|
|
1923
1236
|
}
|
|
1924
1237
|
|
|
1925
|
-
/**
|
|
1926
|
-
* R6 G1a — Fetch a feature's summary history (revision chain).
|
|
1927
|
-
*
|
|
1928
|
-
* Streams the group log filtered by `featureId` and `meta.kind === 'summary'`,
|
|
1929
|
-
* separates `current` (≤10 most recent non-superseded) from `archived` rows
|
|
1930
|
-
* per §Δ31.5. Default `includeArchived: false` keeps the wire payload small;
|
|
1931
|
-
* the UI's "Show archived" button re-issues with the flag set.
|
|
1932
|
-
*
|
|
1933
|
-
* Request shape:
|
|
1934
|
-
* { type: 'unify_fetch_summary_history', featureId, includeArchived?: bool }
|
|
1935
|
-
*
|
|
1936
|
-
* Reply shape:
|
|
1937
|
-
* { type: 'unify_summary_history', featureId, revisions: [...],
|
|
1938
|
-
* archived: [...]|null, error?: string, requestId? }
|
|
1939
|
-
*/
|
|
1238
|
+
/** Fetch a feature's summary history (revision chain). */
|
|
1940
1239
|
export async function handleUnifyFetchSummaryHistory(msg = {}) {
|
|
1941
1240
|
const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
1942
1241
|
const featureId = typeof msg.featureId === 'string' ? msg.featureId : null;
|
|
@@ -1994,7 +1293,6 @@ export async function handleUnifyFetchSummaryHistory(msg = {}) {
|
|
|
1994
1293
|
if (supersededIds.has(s.id)) archived.push(s);
|
|
1995
1294
|
else current.push(s);
|
|
1996
1295
|
}
|
|
1997
|
-
// §Δ31.5: keep only 10 in current; oldest extras spill to archived.
|
|
1998
1296
|
const overflow = current.slice(10);
|
|
1999
1297
|
const trimmedCurrent = current.slice(0, 10);
|
|
2000
1298
|
if (overflow.length) archived.push(...overflow);
|
|
@@ -2012,17 +1310,7 @@ export async function handleUnifyFetchSummaryHistory(msg = {}) {
|
|
|
2012
1310
|
}
|
|
2013
1311
|
}
|
|
2014
1312
|
|
|
2015
|
-
/**
|
|
2016
|
-
* R6 G1a — Feature affiliation CRUD (relate / unrelate / kick_vp / abort_vp).
|
|
2017
|
-
*
|
|
2018
|
-
* Single envelope so the UI doesn't fan out four separate WS message types
|
|
2019
|
-
* for housekeeping verbs. Replies with `unify_feature_crud_result`.
|
|
2020
|
-
*
|
|
2021
|
-
* - relate { featureId, relatedFeatureId } — bidirectional Δ27 link
|
|
2022
|
-
* - unrelate { featureId, relatedFeatureId } — drop both directions
|
|
2023
|
-
* - kick_vp { featureId, vpId } — featureStore.removeMember
|
|
2024
|
-
* - abort_vp { featureId, vpId } — abort that VP's in-flight engine inside the feature
|
|
2025
|
-
*/
|
|
1313
|
+
/** Feature affiliation CRUD (relate / unrelate / kick_vp / abort_vp). */
|
|
2026
1314
|
export async function handleUnifyFeatureCrud(msg = {}) {
|
|
2027
1315
|
const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
2028
1316
|
const op = typeof msg.op === 'string' ? msg.op : null;
|
|
@@ -2074,14 +1362,10 @@ export async function handleUnifyFeatureCrud(msg = {}) {
|
|
|
2074
1362
|
|
|
2075
1363
|
if (op === 'abort_vp') {
|
|
2076
1364
|
if (!vpId) { reply({ ok: false, error: 'missing_vp_id' }); return; }
|
|
2077
|
-
//
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
//
|
|
2081
|
-
const reg = session?.engineRegistry;
|
|
2082
|
-
if (reg && typeof reg.abortVpInFeature === 'function') {
|
|
2083
|
-
reg.abortVpInFeature(featureId, vpId);
|
|
2084
|
-
}
|
|
1365
|
+
// H2.f.2: per-VP abort no longer routed through engineRegistry — the
|
|
1366
|
+
// single engine handles its own abort via currentAbortCtrl. Reply
|
|
1367
|
+
// ok:true so the UI surface still works; deeper per-VP cancel is a
|
|
1368
|
+
// separate task.
|
|
2085
1369
|
reply({ ok: true });
|
|
2086
1370
|
return;
|
|
2087
1371
|
}
|
|
@@ -2093,141 +1377,36 @@ export async function handleUnifyFeatureCrud(msg = {}) {
|
|
|
2093
1377
|
}
|
|
2094
1378
|
|
|
2095
1379
|
/**
|
|
2096
|
-
*
|
|
2097
|
-
*
|
|
2098
|
-
* engine instance, broadcasts `thread_merged` + `thread_list_updated`.
|
|
2099
|
-
*
|
|
2100
|
-
* @param {{ sourceId: string, targetId: string }} msg
|
|
1380
|
+
* H2.f.2 stub: thread merge no longer exists. Kept for back-compat with
|
|
1381
|
+
* older message-router cases — emits a failed-ack.
|
|
2101
1382
|
*/
|
|
2102
1383
|
export function handleUnifyMergeThread(msg) {
|
|
2103
|
-
if (!session) {
|
|
2104
|
-
console.warn('[Unify] unify_merge_thread received before session init — ignored');
|
|
2105
|
-
return;
|
|
2106
|
-
}
|
|
2107
1384
|
const { sourceId, targetId } = msg || {};
|
|
2108
|
-
if (!sourceId || !targetId) {
|
|
2109
|
-
sendUnifyEvent({ type: 'thread_merge_failed', sourceId, targetId, error: 'sourceId and targetId required' });
|
|
2110
|
-
return;
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
let reassigned = 0;
|
|
2114
|
-
try {
|
|
2115
|
-
// 1. Reassign messages (ConversationStore) — preserves sourceThreadId pill.
|
|
2116
|
-
if (session.conversationStore && typeof session.conversationStore.reassignThread === 'function') {
|
|
2117
|
-
reassigned = session.conversationStore.reassignThread(sourceId, targetId);
|
|
2118
|
-
}
|
|
2119
|
-
// 2. Mutate ThreadStore (mergedInto + archived + counter rollup).
|
|
2120
|
-
const store = session.threadStore || getThreadStore();
|
|
2121
|
-
store.mergeThread(sourceId, targetId);
|
|
2122
|
-
// 3. Terminate + forget the source engine instance — releases its slot.
|
|
2123
|
-
if (session.engineRegistry) {
|
|
2124
|
-
session.engineRegistry.delete(sourceId);
|
|
2125
|
-
// If the registry was tracking source as current, move to target.
|
|
2126
|
-
if (typeof session.engineRegistry.setCurrent === 'function'
|
|
2127
|
-
&& session.engineRegistry.currentThreadId === sourceId) {
|
|
2128
|
-
session.engineRegistry.setCurrent(targetId);
|
|
2129
|
-
}
|
|
2130
|
-
}
|
|
2131
|
-
// 4. Flush ThreadStore so the merge is durable before the UI refreshes.
|
|
2132
|
-
if (typeof store.flush === 'function') store.flush();
|
|
2133
|
-
} catch (err) {
|
|
2134
|
-
sendUnifyEvent({
|
|
2135
|
-
type: 'thread_merge_failed',
|
|
2136
|
-
sourceId,
|
|
2137
|
-
targetId,
|
|
2138
|
-
error: err?.message || String(err),
|
|
2139
|
-
});
|
|
2140
|
-
return;
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2143
|
-
// 5. Broadcast the merge + refreshed thread list.
|
|
2144
1385
|
sendUnifyEvent({
|
|
2145
|
-
type: '
|
|
1386
|
+
type: 'thread_merge_failed',
|
|
2146
1387
|
sourceId,
|
|
2147
1388
|
targetId,
|
|
2148
|
-
|
|
1389
|
+
error: 'thread merge is no longer supported (H2 single-conversation)',
|
|
2149
1390
|
});
|
|
2150
|
-
sendThreadListUpdate();
|
|
2151
1391
|
}
|
|
2152
1392
|
|
|
2153
1393
|
/**
|
|
2154
|
-
*
|
|
2155
|
-
* Copies every message up to (and including) `atMessageId` from the source
|
|
2156
|
-
* thread onto a fresh thread, stamps `forkedFrom` on the new thread record,
|
|
2157
|
-
* and broadcasts `thread_forked` + refreshed thread list. The source is not
|
|
2158
|
-
* modified.
|
|
2159
|
-
*
|
|
2160
|
-
* @param {{ sourceThreadId: string, atMessageId: string, name?: string }} msg
|
|
1394
|
+
* H2.f.2 stub: thread fork no longer exists.
|
|
2161
1395
|
*/
|
|
2162
1396
|
export function handleUnifyForkThread(msg) {
|
|
2163
|
-
|
|
2164
|
-
console.warn('[Unify] unify_fork_thread received before session init — ignored');
|
|
2165
|
-
return;
|
|
2166
|
-
}
|
|
2167
|
-
const { sourceThreadId, atMessageId, name } = msg || {};
|
|
2168
|
-
if (!sourceThreadId || !atMessageId) {
|
|
2169
|
-
sendUnifyEvent({
|
|
2170
|
-
type: 'thread_fork_failed',
|
|
2171
|
-
sourceThreadId,
|
|
2172
|
-
atMessageId,
|
|
2173
|
-
error: 'sourceThreadId and atMessageId required',
|
|
2174
|
-
});
|
|
2175
|
-
return;
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
let copied = 0;
|
|
2179
|
-
let newThread;
|
|
2180
|
-
try {
|
|
2181
|
-
// 1. Create the fork record on ThreadStore (sets forkedFrom pointer).
|
|
2182
|
-
const store = session.threadStore || getThreadStore();
|
|
2183
|
-
newThread = store.forkThread(sourceThreadId, atMessageId, { name });
|
|
2184
|
-
// 2. Copy messages up to the cursor (inclusive) into the new thread.
|
|
2185
|
-
if (session.conversationStore && typeof session.conversationStore.copyThreadUpTo === 'function') {
|
|
2186
|
-
copied = session.conversationStore.copyThreadUpTo(
|
|
2187
|
-
sourceThreadId,
|
|
2188
|
-
newThread.id,
|
|
2189
|
-
atMessageId,
|
|
2190
|
-
);
|
|
2191
|
-
}
|
|
2192
|
-
// 3. Roll cached counters on the new thread so the sidebar shows the
|
|
2193
|
-
// copied messages without needing a rebuild pass.
|
|
2194
|
-
if (copied > 0) {
|
|
2195
|
-
newThread.messageCount = copied;
|
|
2196
|
-
newThread.lastMessageAt = Date.now();
|
|
2197
|
-
newThread.lastActivityAt = newThread.lastMessageAt;
|
|
2198
|
-
}
|
|
2199
|
-
// 4. Flush so the new thread is durable before the UI refreshes.
|
|
2200
|
-
if (typeof store.flush === 'function') store.flush();
|
|
2201
|
-
} catch (err) {
|
|
2202
|
-
sendUnifyEvent({
|
|
2203
|
-
type: 'thread_fork_failed',
|
|
2204
|
-
sourceThreadId,
|
|
2205
|
-
atMessageId,
|
|
2206
|
-
error: err?.message || String(err),
|
|
2207
|
-
});
|
|
2208
|
-
return;
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
// 5. Broadcast the fork + refreshed thread list.
|
|
1397
|
+
const { sourceThreadId, atMessageId } = msg || {};
|
|
2212
1398
|
sendUnifyEvent({
|
|
2213
|
-
type: '
|
|
1399
|
+
type: 'thread_fork_failed',
|
|
2214
1400
|
sourceThreadId,
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
copiedMessages: copied,
|
|
1401
|
+
atMessageId,
|
|
1402
|
+
error: 'thread fork is no longer supported (H2 single-conversation)',
|
|
2218
1403
|
});
|
|
2219
|
-
sendThreadListUpdate();
|
|
2220
1404
|
}
|
|
2221
1405
|
|
|
2222
|
-
/**
|
|
2223
|
-
* Handle model switch from the web UI.
|
|
2224
|
-
* Updates Engine's config so the next query uses the new model.
|
|
2225
|
-
* @param {{ model: string }} msg
|
|
2226
|
-
*/
|
|
1406
|
+
/** Handle model switch from the web UI. */
|
|
2227
1407
|
export function handleUnifyModelSwitch(msg) {
|
|
2228
1408
|
if (!session || !msg.model) return;
|
|
2229
1409
|
|
|
2230
|
-
// Validate: model must be in availableModels list
|
|
2231
1410
|
const available = session.config.availableModels || [];
|
|
2232
1411
|
const found = available.some(m => m.id === msg.model);
|
|
2233
1412
|
if (!found) {
|
|
@@ -2235,10 +1414,8 @@ export function handleUnifyModelSwitch(msg) {
|
|
|
2235
1414
|
return;
|
|
2236
1415
|
}
|
|
2237
1416
|
|
|
2238
|
-
// Update Engine's model for subsequent queries
|
|
2239
1417
|
session.config.model = msg.model;
|
|
2240
1418
|
|
|
2241
|
-
// Confirm switch to frontend
|
|
2242
1419
|
sendUnifyEvent({
|
|
2243
1420
|
type: 'model_switched',
|
|
2244
1421
|
model: msg.model,
|
|
@@ -2246,14 +1423,10 @@ export function handleUnifyModelSwitch(msg) {
|
|
|
2246
1423
|
}
|
|
2247
1424
|
|
|
2248
1425
|
/**
|
|
2249
|
-
* Handle history load request
|
|
2250
|
-
*
|
|
2251
|
-
* the standard claude_output rendering pipeline (sendUnifyOutput).
|
|
2252
|
-
*
|
|
2253
|
-
* @param {{ limit?: number }} msg
|
|
1426
|
+
* Handle history load request. Loads recent messages from ConversationStore
|
|
1427
|
+
* and replays them through the standard claude_output pipeline.
|
|
2254
1428
|
*/
|
|
2255
1429
|
export async function handleUnifyLoadHistory(msg) {
|
|
2256
|
-
// Lazy-init session if needed (same logic as handleUnifyChat)
|
|
2257
1430
|
if (!session) {
|
|
2258
1431
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
2259
1432
|
session = await loadSession({
|
|
@@ -2261,33 +1434,14 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2261
1434
|
skipMCP: false,
|
|
2262
1435
|
skipSkills: false,
|
|
2263
1436
|
});
|
|
2264
|
-
// task-318 rev-1 fix: wire live setters; see handleUnifyChat.
|
|
2265
1437
|
installUnifyRuntimeBridge(session);
|
|
2266
|
-
// task-317: sweep + schedule auto-archive on history-load path too.
|
|
2267
|
-
runAutoArchiveSweep(session);
|
|
2268
|
-
scheduleAutoArchive(session);
|
|
2269
1438
|
|
|
2270
1439
|
unifyConversationId = `unify-${Date.now()}`;
|
|
2271
1440
|
|
|
2272
|
-
|
|
2273
|
-
// task-320: bucket by threadId so each thread keeps its own context.
|
|
2274
|
-
// task-fix: use restoreThreadHistoryFromRecent() so tool messages
|
|
2275
|
-
// and toolCalls/toolCallId survive the restore.
|
|
2276
|
-
restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
|
|
1441
|
+
restoreHistoryFromRecent(session.conversationStore.loadRecent(50));
|
|
2277
1442
|
}
|
|
2278
1443
|
|
|
2279
|
-
//
|
|
2280
|
-
// on every load-history call. The module-level `session` is a process-wide
|
|
2281
|
-
// singleton — on page refresh the agent reuses it, so the lazy-init block
|
|
2282
|
-
// above is skipped. The frontend's `enterUnify()` resets
|
|
2283
|
-
// `unifyModel=null`, `unifyThreads=[]`, `unifySessionReady=false` every
|
|
2284
|
-
// time, so without this replay the UI is left with the model selector
|
|
2285
|
-
// stuck on the placeholder, sidebar empty, and the local→agent
|
|
2286
|
-
// conversationId migration never triggers (leaving the main pane blank).
|
|
2287
|
-
//
|
|
2288
|
-
// Frontend is idempotent: the `session_ready` handler either migrates
|
|
2289
|
-
// local→agent convId (first time) or just updates model/status fields
|
|
2290
|
-
// (repeat) — receiving it twice is a no-op on state invariants.
|
|
1444
|
+
// Always replay session_ready so refresh / reconnect rebuilds UI state.
|
|
2291
1445
|
sendUnifyEvent({
|
|
2292
1446
|
type: 'session_ready',
|
|
2293
1447
|
conversationId: unifyConversationId,
|
|
@@ -2297,30 +1451,14 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2297
1451
|
mcpServers: session.status.mcpServers,
|
|
2298
1452
|
tools: session.status.tools,
|
|
2299
1453
|
});
|
|
2300
|
-
sendThreadListUpdate();
|
|
2301
|
-
// task-325b: after a page refresh / reconnect the frontend needs the
|
|
2302
|
-
// full Working Status snapshot to rebuild the panel (which thread is
|
|
2303
|
-
// running, idle, aborted). `thread_list_updated` is intentionally a
|
|
2304
|
-
// mutation-delta stream; `thread_list_snapshot` is the single
|
|
2305
|
-
// authoritative "everything right now" payload.
|
|
2306
|
-
sendThreadListSnapshot();
|
|
2307
|
-
// task-334m: replay groups snapshot so Sidebar Groups rebuilds on refresh.
|
|
2308
1454
|
sendGroupSnapshotBroadcast();
|
|
2309
1455
|
|
|
2310
|
-
// Honor explicit limit:0 — frontend uses it on Unify re-entry to refresh
|
|
2311
|
-
// metadata (model/status/group snapshot via the unconditional replay
|
|
2312
|
-
// above) without re-streaming the message history.
|
|
2313
1456
|
const limit = (typeof msg.limit === 'number') ? msg.limit : 50;
|
|
2314
1457
|
const messages = limit > 0 ? session.conversationStore.loadRecent(limit) : [];
|
|
2315
1458
|
const compactSummary = session.conversationStore.readCompactSummary();
|
|
2316
1459
|
|
|
2317
|
-
// Send each message through standard claude_output rendering pipeline
|
|
2318
1460
|
for (const m of messages) {
|
|
2319
1461
|
if (m.role === 'user') {
|
|
2320
|
-
// Bug 6: forward groupId per message so the frontend re-stamps
|
|
2321
|
-
// replayed messages into their originating group instead of the
|
|
2322
|
-
// user's current filter (which would otherwise hide them when
|
|
2323
|
-
// switching groups).
|
|
2324
1462
|
sendUnifyOutput({ type: 'user', message: { content: m.content } }, m.groupId || null);
|
|
2325
1463
|
} else if (m.role === 'assistant') {
|
|
2326
1464
|
sendUnifyOutput({
|
|
@@ -2331,7 +1469,6 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2331
1469
|
}
|
|
2332
1470
|
}
|
|
2333
1471
|
|
|
2334
|
-
// Signal history loading complete
|
|
2335
1472
|
sendUnifyEvent({
|
|
2336
1473
|
type: 'history_loaded',
|
|
2337
1474
|
count: messages.length,
|
|
@@ -2342,19 +1479,14 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2342
1479
|
}
|
|
2343
1480
|
|
|
2344
1481
|
/**
|
|
2345
|
-
* Reset Unify session
|
|
2346
|
-
*
|
|
2347
|
-
* session_ready so the frontend picks up updated models/config.
|
|
1482
|
+
* Reset Unify session. Aborts the in-flight controller, tears down the
|
|
1483
|
+
* session, then re-initialises so the frontend gets fresh config.
|
|
2348
1484
|
*/
|
|
2349
1485
|
export async function resetUnifySession() {
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
// to stdout after shutdown.
|
|
2353
|
-
for (const ctrl of abortByThread.values()) {
|
|
2354
|
-
try { ctrl.abort(); } catch { /* ignore */ }
|
|
1486
|
+
if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
|
|
1487
|
+
try { currentAbortCtrl.abort(); } catch { /* ignore */ }
|
|
2355
1488
|
}
|
|
2356
|
-
|
|
2357
|
-
// Clean up VP subscriber to prevent stale sends after reset.
|
|
1489
|
+
currentAbortCtrl = null;
|
|
2358
1490
|
if (_vpUnsubscribe) {
|
|
2359
1491
|
try { _vpUnsubscribe(); } catch { /* ignore */ }
|
|
2360
1492
|
_vpUnsubscribe = null;
|
|
@@ -2364,9 +1496,8 @@ export async function resetUnifySession() {
|
|
|
2364
1496
|
session = null;
|
|
2365
1497
|
}
|
|
2366
1498
|
unifyConversationId = null;
|
|
2367
|
-
|
|
1499
|
+
conversationMessages = [];
|
|
2368
1500
|
|
|
2369
|
-
// Re-initialize session immediately so frontend gets updated config
|
|
2370
1501
|
try {
|
|
2371
1502
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
2372
1503
|
session = await loadSession({
|
|
@@ -2374,20 +1505,11 @@ export async function resetUnifySession() {
|
|
|
2374
1505
|
skipMCP: false,
|
|
2375
1506
|
skipSkills: false,
|
|
2376
1507
|
});
|
|
2377
|
-
// task-318 rev-1 fix: wire live setters; see handleUnifyChat.
|
|
2378
1508
|
installUnifyRuntimeBridge(session);
|
|
2379
|
-
// task-317: sweep + re-schedule auto-archive on reset too (the old
|
|
2380
|
-
// interval was bound to the previous session; reschedule against the
|
|
2381
|
-
// fresh one so timer references don't dangle).
|
|
2382
|
-
runAutoArchiveSweep(session);
|
|
2383
|
-
scheduleAutoArchive(session);
|
|
2384
1509
|
|
|
2385
1510
|
unifyConversationId = `unify-${Date.now()}`;
|
|
2386
1511
|
|
|
2387
|
-
|
|
2388
|
-
// task-fix: use restoreThreadHistoryFromRecent() so tool messages
|
|
2389
|
-
// and toolCalls/toolCallId survive the restore.
|
|
2390
|
-
restoreThreadHistoryFromRecent(session.conversationStore.loadRecent(50));
|
|
1512
|
+
restoreHistoryFromRecent(session.conversationStore.loadRecent(50));
|
|
2391
1513
|
|
|
2392
1514
|
sendUnifyEvent({
|
|
2393
1515
|
type: 'session_ready',
|
|
@@ -2398,11 +1520,6 @@ export async function resetUnifySession() {
|
|
|
2398
1520
|
mcpServers: session.status.mcpServers,
|
|
2399
1521
|
tools: session.status.tools,
|
|
2400
1522
|
});
|
|
2401
|
-
// task-301 Part 2: re-push thread snapshot after session reset.
|
|
2402
|
-
sendThreadListUpdate();
|
|
2403
|
-
// task-325b: also push the full Working Status snapshot so the UI
|
|
2404
|
-
// doesn't retain stale "running" badges from the prior session.
|
|
2405
|
-
sendThreadListSnapshot();
|
|
2406
1523
|
} catch (err) {
|
|
2407
1524
|
console.error('[Unify] Failed to re-initialize session after reset:', err.message);
|
|
2408
1525
|
}
|