@yeaft/webchat-agent 0.1.487 → 0.1.488
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/pipeline/dispatcher.js +373 -0
- package/unify/session.js +21 -0
- package/unify/web-bridge.js +281 -181
package/package.json
CHANGED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline/dispatcher.js — task-310 Phase 2 integration.
|
|
3
|
+
*
|
|
4
|
+
* Composes the three Phase-2 building blocks into a single linear pipeline:
|
|
5
|
+
*
|
|
6
|
+
* unify_chat input
|
|
7
|
+
* │
|
|
8
|
+
* ▼
|
|
9
|
+
* ┌──────────────┐ (task-307b)
|
|
10
|
+
* │ InputQueue │ persistent FIFO of pending user inputs
|
|
11
|
+
* └──────┬───────┘
|
|
12
|
+
* │ claim() (transition pending → routing)
|
|
13
|
+
* ▼
|
|
14
|
+
* ┌──────────────┐ (task-309)
|
|
15
|
+
* │ IntentClass. │ explicit @prefix / override / LLM / fallback
|
|
16
|
+
* └──────┬───────┘
|
|
17
|
+
* │ { action, targetThreadId, source, reason }
|
|
18
|
+
* ▼
|
|
19
|
+
* ┌──────────────┐ (task-308)
|
|
20
|
+
* │ EngineReg. │ ensure(threadId) → EngineInstance
|
|
21
|
+
* └──────┬───────┘
|
|
22
|
+
* │ inst.query({ prompt })
|
|
23
|
+
* ▼
|
|
24
|
+
* ┌──────────────┐
|
|
25
|
+
* │ Engine events│ text_delta / tool_call / tool_end / …
|
|
26
|
+
* └──────┬───────┘
|
|
27
|
+
* │ each event tagged { ...ev, threadId } by EngineInstance
|
|
28
|
+
* ▼
|
|
29
|
+
* web-bridge forwards to `unify_output`
|
|
30
|
+
*
|
|
31
|
+
* ### Responsibilities
|
|
32
|
+
*
|
|
33
|
+
* This module OWNS the pipeline's control-flow decisions:
|
|
34
|
+
*
|
|
35
|
+
* - `submit(input)` — enqueue + return the queue entry, non-streaming.
|
|
36
|
+
* The caller either invokes `drain()` to actually dispatch, or lets a
|
|
37
|
+
* future background worker pick it up. (We go with the simple "drain
|
|
38
|
+
* on submit" default because the web-bridge is the sole producer and
|
|
39
|
+
* cannot afford a stuck pending entry.)
|
|
40
|
+
*
|
|
41
|
+
* - `dispatch(entry)` async-generator — runs one entry through router +
|
|
42
|
+
* engine. Yields a stream of bridge events:
|
|
43
|
+
*
|
|
44
|
+
* { type: 'input_queue_updated', pending, routing, … }
|
|
45
|
+
* { type: 'routing_decision', entryId, action, targetThreadId, source, reason }
|
|
46
|
+
* { type: 'thread_list_updated', threads, currentThreadId } (on fork)
|
|
47
|
+
* { type: 'engine_event', threadId, event } // raw Engine event
|
|
48
|
+
* { type: 'error', error: Error, retryable }
|
|
49
|
+
*
|
|
50
|
+
* The web-bridge translates `engine_event`s into claude_output (the
|
|
51
|
+
* existing code path) and forwards the pipeline-level events as
|
|
52
|
+
* `unify_output.event`.
|
|
53
|
+
*
|
|
54
|
+
* - `drain()` — convenience: claim + dispatch repeatedly until the queue
|
|
55
|
+
* is empty. Web-bridge calls this after every submit().
|
|
56
|
+
*
|
|
57
|
+
* ### What this module does NOT own
|
|
58
|
+
*
|
|
59
|
+
* - Persistence of messages (Engine / EngineInstance).
|
|
60
|
+
* - ThreadStore mutations beyond incrementing currentId on 'switch' /
|
|
61
|
+
* creating a fork thread on 'fork'.
|
|
62
|
+
* - WebSocket framing (web-bridge does that).
|
|
63
|
+
* - Abort semantics — each caller wraps `dispatch()` with its own
|
|
64
|
+
* AbortController / signal (we forward it to EngineInstance.query).
|
|
65
|
+
*
|
|
66
|
+
* ### Concurrent reflow (spec point 5)
|
|
67
|
+
*
|
|
68
|
+
* Node's single-thread event loop means two concurrent `dispatch()` calls
|
|
69
|
+
* interleave at await points. Every yielded `engine_event` carries a
|
|
70
|
+
* `threadId` (EngineInstance re-tags), so the web-bridge can render events
|
|
71
|
+
* into the correct UI bubble. The dispatcher itself holds NO per-turn
|
|
72
|
+
* state on `this` — all state lives in the async generator's closure, so
|
|
73
|
+
* two pipelines can be in-flight at the same time without aliasing.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
import { MAIN_THREAD_ID } from '../threads/store.js';
|
|
77
|
+
import { getTaskStore } from '../tools/task-tools.js';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Per-entry transient metadata (messageId, override) lives here — a
|
|
81
|
+
* WeakMap keyed by the queue entry object so it is NEVER persisted to
|
|
82
|
+
* disk by InputQueueStore.#writeEntry. Entries are GC'd together with
|
|
83
|
+
* the entry once the queue drops the strong reference.
|
|
84
|
+
* @type {WeakMap<object, {messageId?: string, override?: {threadId: string}}>}
|
|
85
|
+
*/
|
|
86
|
+
const transientMeta = new WeakMap();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {'continue'|'interrupt'|'fork'|'switch'} RouterAction
|
|
90
|
+
*
|
|
91
|
+
* @typedef {Object} SubmitOptions
|
|
92
|
+
* @property {string} [messageId] — optional stable id for override()
|
|
93
|
+
* @property {{ threadId: string }} [override] — UI-side `@thread-name` hint
|
|
94
|
+
* or user correction: skip router, go straight to `switch`/`continue`
|
|
95
|
+
* targeting the given threadId.
|
|
96
|
+
*
|
|
97
|
+
* @typedef {Object} DispatcherDeps
|
|
98
|
+
* @property {import('../input-queue/store.js').InputQueueStore} inputQueue
|
|
99
|
+
* @property {import('../router/intent-classifier.js').IntentClassifier} router
|
|
100
|
+
* @property {import('../threads/engine-registry.js').ThreadEngineRegistry} engineRegistry
|
|
101
|
+
* @property {import('../threads/store.js').ThreadStore} threadStore
|
|
102
|
+
* @property {object} [trace]
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
export class Dispatcher {
|
|
106
|
+
/** @type {DispatcherDeps} */
|
|
107
|
+
#deps;
|
|
108
|
+
|
|
109
|
+
constructor(deps) {
|
|
110
|
+
const { inputQueue, router, engineRegistry, threadStore } = deps || {};
|
|
111
|
+
if (!inputQueue || typeof inputQueue.enqueue !== 'function') {
|
|
112
|
+
throw new Error('Dispatcher: inputQueue is required');
|
|
113
|
+
}
|
|
114
|
+
if (!router || typeof router.classify !== 'function') {
|
|
115
|
+
throw new Error('Dispatcher: router is required');
|
|
116
|
+
}
|
|
117
|
+
if (!engineRegistry || typeof engineRegistry.ensure !== 'function') {
|
|
118
|
+
throw new Error('Dispatcher: engineRegistry is required');
|
|
119
|
+
}
|
|
120
|
+
if (!threadStore || typeof threadStore.list !== 'function') {
|
|
121
|
+
throw new Error('Dispatcher: threadStore is required');
|
|
122
|
+
}
|
|
123
|
+
this.#deps = deps;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Snapshot of queue counters for the UI, post-mutation. */
|
|
127
|
+
#queueSnapshot() {
|
|
128
|
+
const { inputQueue } = this.#deps;
|
|
129
|
+
const entries = inputQueue.list();
|
|
130
|
+
const counts = { pending: 0, routing: 0, dispatched: 0 };
|
|
131
|
+
for (const e of entries) {
|
|
132
|
+
if (counts[e.status] !== undefined) counts[e.status] += 1;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
type: 'input_queue_updated',
|
|
136
|
+
total: entries.length,
|
|
137
|
+
pending: counts.pending,
|
|
138
|
+
routing: counts.routing,
|
|
139
|
+
dispatched: counts.dispatched,
|
|
140
|
+
head: entries[0] ? { id: entries[0].id, status: entries[0].status, text: entries[0].text.slice(0, 80) } : null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Enqueue a user input. Does NOT dispatch — caller invokes `drain()` or
|
|
146
|
+
* `dispatch(entry)` next. Separated so callers can atomically observe
|
|
147
|
+
* the `input_queue_updated` snapshot before the first router call fires.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} text
|
|
150
|
+
* @param {SubmitOptions} [opts]
|
|
151
|
+
* @returns {{ entry: object, snapshot: object }}
|
|
152
|
+
*/
|
|
153
|
+
submit(text, opts = {}) {
|
|
154
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
155
|
+
throw new Error('Dispatcher.submit: text required');
|
|
156
|
+
}
|
|
157
|
+
const { inputQueue } = this.#deps;
|
|
158
|
+
const entry = inputQueue.enqueue(text);
|
|
159
|
+
// Transient metadata lives in a WeakMap keyed by the entry — it is
|
|
160
|
+
// intentionally off the entry object itself so InputQueueStore's
|
|
161
|
+
// JSON.stringify write path does NOT leak `_messageId`/`_override`
|
|
162
|
+
// to disk. The WeakMap entry is dropped when the queue releases the
|
|
163
|
+
// entry reference (after markRouted removes it from memory).
|
|
164
|
+
transientMeta.set(entry, {
|
|
165
|
+
messageId: opts.messageId || undefined,
|
|
166
|
+
override: opts.override || undefined,
|
|
167
|
+
});
|
|
168
|
+
const snapshot = this.#queueSnapshot();
|
|
169
|
+
return { entry, snapshot };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Drain the queue: claim → dispatch in a loop until empty.
|
|
174
|
+
* Yields the union of every `dispatch()`'s events, interleaved naturally.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
177
|
+
* @yields {object} bridge events
|
|
178
|
+
*/
|
|
179
|
+
async *drain(opts = {}) {
|
|
180
|
+
const { inputQueue } = this.#deps;
|
|
181
|
+
while (true) {
|
|
182
|
+
const head = inputQueue.peek();
|
|
183
|
+
if (!head) return;
|
|
184
|
+
if (head.status !== 'pending') return; // another dispatcher holds it
|
|
185
|
+
for await (const ev of this.dispatch(head, opts)) yield ev;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Dispatch one queue entry through router + engine. Transitions the
|
|
191
|
+
* entry: pending → routing (on claim) → dispatched (on success) or back
|
|
192
|
+
* to pending (on router exception, which is already guarded inside the
|
|
193
|
+
* classifier — so in practice this branch is very rare).
|
|
194
|
+
*
|
|
195
|
+
* @param {object} entry — from inputQueue.peek() or inputQueue.enqueue()
|
|
196
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
197
|
+
* @yields {object} bridge events
|
|
198
|
+
*/
|
|
199
|
+
async *dispatch(entry, opts = {}) {
|
|
200
|
+
const { inputQueue, router, engineRegistry, threadStore } = this.#deps;
|
|
201
|
+
const { signal } = opts;
|
|
202
|
+
|
|
203
|
+
// ── Step 1: claim (pending → routing) ──
|
|
204
|
+
// Note: we assume the caller already found `entry` as the head. A
|
|
205
|
+
// concurrent dispatcher would have claimed it first; we check for that.
|
|
206
|
+
let claimed = entry;
|
|
207
|
+
if (entry.status === 'pending') {
|
|
208
|
+
claimed = inputQueue.claim();
|
|
209
|
+
if (!claimed || claimed.id !== entry.id) {
|
|
210
|
+
// Another worker took it. Treat as a no-op success.
|
|
211
|
+
yield this.#queueSnapshot();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
yield this.#queueSnapshot();
|
|
216
|
+
|
|
217
|
+
// ── Step 2: gather router context ──
|
|
218
|
+
const currentThreadId = threadStore.currentId || MAIN_THREAD_ID;
|
|
219
|
+
const allThreads = threadStore.list().map(t => ({
|
|
220
|
+
id: t.id, name: t.name, goal: t.goal, status: t.status,
|
|
221
|
+
}));
|
|
222
|
+
const pendingTasks = this.#listPendingTasks();
|
|
223
|
+
|
|
224
|
+
// ── Step 3: classify (explicit override > classifier) ──
|
|
225
|
+
/** @type {import('../router/intent-classifier.js').RouterDecision} */
|
|
226
|
+
let decision;
|
|
227
|
+
const meta = transientMeta.get(entry) || {};
|
|
228
|
+
const ov = meta.override;
|
|
229
|
+
if (ov && typeof ov.threadId === 'string' && ov.threadId) {
|
|
230
|
+
const known = allThreads.some(t => t.id === ov.threadId) || ov.threadId === currentThreadId;
|
|
231
|
+
if (known) {
|
|
232
|
+
const action = ov.threadId === currentThreadId ? 'continue' : 'switch';
|
|
233
|
+
decision = {
|
|
234
|
+
action,
|
|
235
|
+
targetThreadId: ov.threadId,
|
|
236
|
+
reason: 'ui_override',
|
|
237
|
+
source: 'override',
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!decision) {
|
|
242
|
+
try {
|
|
243
|
+
decision = await router.classify({
|
|
244
|
+
userMessage: claimed.text,
|
|
245
|
+
currentThreadId,
|
|
246
|
+
allThreads,
|
|
247
|
+
pendingTasks,
|
|
248
|
+
messageId: meta.messageId || undefined,
|
|
249
|
+
});
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// Classifier is wrapped in its own try/catch already; reaching here
|
|
252
|
+
// means a truly unexpected failure. Degrade to continue.
|
|
253
|
+
decision = {
|
|
254
|
+
action: 'continue',
|
|
255
|
+
targetThreadId: currentThreadId,
|
|
256
|
+
reason: `dispatcher_classify_exception: ${err.message}`,
|
|
257
|
+
source: 'fallback',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
yield {
|
|
263
|
+
type: 'routing_decision',
|
|
264
|
+
entryId: claimed.id,
|
|
265
|
+
action: decision.action,
|
|
266
|
+
targetThreadId: decision.targetThreadId,
|
|
267
|
+
source: decision.source || 'llm',
|
|
268
|
+
reason: decision.reason || '',
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// ── Step 4: resolve target thread (fork may create a new one) ──
|
|
272
|
+
let targetThreadId = decision.targetThreadId;
|
|
273
|
+
if (decision.action === 'fork') {
|
|
274
|
+
const parentId = decision.targetThreadId || currentThreadId;
|
|
275
|
+
const forked = this.#spawnForkedThread(parentId, claimed.text);
|
|
276
|
+
if (forked) {
|
|
277
|
+
targetThreadId = forked.id;
|
|
278
|
+
yield this.#threadListSnapshot();
|
|
279
|
+
}
|
|
280
|
+
} else if (decision.action === 'switch') {
|
|
281
|
+
// Move the ThreadStore cursor so subsequent tool-originated events
|
|
282
|
+
// see the right thread for persistence hooks. Guard: not every
|
|
283
|
+
// ThreadStore implementation exposes has() (e.g. historic mocks).
|
|
284
|
+
const hasFn = typeof threadStore.has === 'function' ? (id) => threadStore.has(id) : () => true;
|
|
285
|
+
if (hasFn(targetThreadId)) {
|
|
286
|
+
try { threadStore.switch(targetThreadId); } catch { /* ignore */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Step 5: dispatch to EngineInstance ──
|
|
291
|
+
let instance;
|
|
292
|
+
try {
|
|
293
|
+
instance = engineRegistry.ensure(targetThreadId);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
inputQueue.markFailed(claimed.id, err);
|
|
296
|
+
yield this.#queueSnapshot();
|
|
297
|
+
yield { type: 'error', error: err, retryable: false };
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
for await (const event of instance.query({ prompt: claimed.text, signal })) {
|
|
303
|
+
yield { type: 'engine_event', threadId: targetThreadId, event };
|
|
304
|
+
}
|
|
305
|
+
inputQueue.markRouted(claimed.id, targetThreadId);
|
|
306
|
+
yield this.#queueSnapshot();
|
|
307
|
+
} catch (err) {
|
|
308
|
+
inputQueue.markFailed(claimed.id, err);
|
|
309
|
+
yield this.#queueSnapshot();
|
|
310
|
+
yield { type: 'error', error: err, retryable: true };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ──────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
#threadListSnapshot() {
|
|
317
|
+
const { threadStore } = this.#deps;
|
|
318
|
+
const threads = threadStore.list().map(t => ({
|
|
319
|
+
id: t.id,
|
|
320
|
+
name: t.name,
|
|
321
|
+
goal: t.goal || '',
|
|
322
|
+
parentThreadId: t.parentThreadId || null,
|
|
323
|
+
status: t.status,
|
|
324
|
+
archived: !!t.archived,
|
|
325
|
+
messageCount: t.messageCount || 0,
|
|
326
|
+
lastMessageAt: t.lastMessageAt || null,
|
|
327
|
+
}));
|
|
328
|
+
return {
|
|
329
|
+
type: 'thread_list_updated',
|
|
330
|
+
threads,
|
|
331
|
+
currentThreadId: threadStore.currentId,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#spawnForkedThread(parentId, promptText) {
|
|
336
|
+
const { threadStore } = this.#deps;
|
|
337
|
+
if (!threadStore.create) return null;
|
|
338
|
+
// Short label from first non-empty line, capped at 40 chars.
|
|
339
|
+
const firstLine = (promptText || '').split(/\r?\n/).find(l => l.trim()) || 'fork';
|
|
340
|
+
const name = firstLine.trim().slice(0, 40);
|
|
341
|
+
try {
|
|
342
|
+
return threadStore.create({ name, parentThreadId: parentId });
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#listPendingTasks() {
|
|
349
|
+
// Best-effort: the TaskStore is a singleton initialised in loadSession().
|
|
350
|
+
// If the store isn't available (e.g. unit tests without a session) we
|
|
351
|
+
// just return []. Never let a TaskStore exception break routing.
|
|
352
|
+
try {
|
|
353
|
+
const store = getTaskStore();
|
|
354
|
+
if (!store || typeof store.list !== 'function') return [];
|
|
355
|
+
const pending = store.list({ status: 'pending' }) || [];
|
|
356
|
+
return pending.map(t => ({
|
|
357
|
+
id: t.id,
|
|
358
|
+
title: t.title || '',
|
|
359
|
+
threadId: t.threadId || null,
|
|
360
|
+
}));
|
|
361
|
+
} catch { /* ignore */ }
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Build a Dispatcher from session-level deps.
|
|
368
|
+
* @param {DispatcherDeps} deps
|
|
369
|
+
* @returns {Dispatcher}
|
|
370
|
+
*/
|
|
371
|
+
export function createDispatcher(deps) {
|
|
372
|
+
return new Dispatcher(deps);
|
|
373
|
+
}
|
package/unify/session.js
CHANGED
|
@@ -27,7 +27,10 @@ import { initThreadStore } from './threads/store.js';
|
|
|
27
27
|
import { Engine } from './engine.js';
|
|
28
28
|
import { createThreadEngineRegistry } from './threads/engine-registry.js';
|
|
29
29
|
import { MAIN_THREAD_ID } from './threads/store.js';
|
|
30
|
+
import { getThreadStore } from './threads/store.js';
|
|
30
31
|
import { createIntentClassifier } from './router/intent-classifier.js';
|
|
32
|
+
import { initInputQueueStore } from './input-queue/store.js';
|
|
33
|
+
import { createDispatcher } from './pipeline/dispatcher.js';
|
|
31
34
|
import { join } from 'path';
|
|
32
35
|
|
|
33
36
|
/**
|
|
@@ -193,6 +196,22 @@ export async function loadSession(options = {}) {
|
|
|
193
196
|
// config as the engines so it can use primaryModel for classification.
|
|
194
197
|
const router = createIntentClassifier({ adapter, trace, config });
|
|
195
198
|
|
|
199
|
+
// task-310 Phase 2 integration: wire InputQueue + Dispatcher so the
|
|
200
|
+
// web-bridge can submit `unify_chat` inputs through the unified pipeline
|
|
201
|
+
// (queue → router → engineRegistry → EngineInstance). In read-only mode
|
|
202
|
+
// the queue is memory-only (no disk writes).
|
|
203
|
+
const inputQueue = initInputQueueStore({
|
|
204
|
+
yeaftDir: config._readOnly ? null : yeaftDir,
|
|
205
|
+
force: true,
|
|
206
|
+
});
|
|
207
|
+
const dispatcher = createDispatcher({
|
|
208
|
+
inputQueue,
|
|
209
|
+
router,
|
|
210
|
+
engineRegistry,
|
|
211
|
+
threadStore: getThreadStore(),
|
|
212
|
+
trace,
|
|
213
|
+
});
|
|
214
|
+
|
|
196
215
|
// ─── 10. Build session ─────────────────────────────────
|
|
197
216
|
const status = {
|
|
198
217
|
skills: skillManager.size,
|
|
@@ -224,6 +243,8 @@ export async function loadSession(options = {}) {
|
|
|
224
243
|
engine,
|
|
225
244
|
engineRegistry,
|
|
226
245
|
router,
|
|
246
|
+
inputQueue,
|
|
247
|
+
dispatcher,
|
|
227
248
|
adapter,
|
|
228
249
|
config,
|
|
229
250
|
conversationStore,
|
package/unify/web-bridge.js
CHANGED
|
@@ -116,6 +116,254 @@ const THREAD_MUTATING_TOOLS = new Set([
|
|
|
116
116
|
'AttachThreadToTask',
|
|
117
117
|
]);
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* task-310: parse a leading `@thread-<id>` or `@thread-<name>` marker on
|
|
121
|
+
* the user's input and return it as a dispatcher override. The marker
|
|
122
|
+
* itself is STRIPPED from the prompt before it reaches the engine —
|
|
123
|
+
* users don't want to see `@thread-foo` echoed back into their
|
|
124
|
+
* conversation.
|
|
125
|
+
*
|
|
126
|
+
* Thread IDs are `main` or `thr-<8 hex>`, so the match captures the id
|
|
127
|
+
* name AFTER the literal `@thread-`. The returned `override.threadId`
|
|
128
|
+
* is the fully-qualified thread id (e.g. `thread-main`, `thread-thr-abcd1234`).
|
|
129
|
+
*
|
|
130
|
+
* Returns { prompt, override? } where override = { threadId } if matched.
|
|
131
|
+
*/
|
|
132
|
+
export function parseThreadPrefix(text) {
|
|
133
|
+
if (!text || typeof text !== 'string') return { prompt: text || '', override: null };
|
|
134
|
+
// Capture the id portion after the literal `@thread-` prefix.
|
|
135
|
+
const m = text.match(/^\s*@thread-([A-Za-z0-9_-]+)\b\s*/);
|
|
136
|
+
if (!m) return { prompt: text, override: null };
|
|
137
|
+
const rest = text.slice(m[0].length);
|
|
138
|
+
// The captured id may already include a `thr-` sub-prefix (for non-main
|
|
139
|
+
// threads). For the canonical `main` thread, the override is the bare
|
|
140
|
+
// string `main`; for `thr-xxxxxxxx` threads, pass through verbatim.
|
|
141
|
+
const threadId = m[1];
|
|
142
|
+
return { prompt: rest || text, override: { threadId } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Translate a pipeline event (from Dispatcher) into web-bridge outputs.
|
|
147
|
+
* Pipeline events are distinct from engine events — they carry queue /
|
|
148
|
+
* routing state for the UI. Engine events are unwrapped and forwarded
|
|
149
|
+
* through the existing sendUnifyOutput / sendUnifyEvent path.
|
|
150
|
+
*
|
|
151
|
+
* Returns whether the pipeline is complete (terminal error / no more).
|
|
152
|
+
*/
|
|
153
|
+
function forwardPipelineEvent(ev, ctx) {
|
|
154
|
+
if (!ev || typeof ev !== 'object') return false;
|
|
155
|
+
switch (ev.type) {
|
|
156
|
+
case 'input_queue_updated':
|
|
157
|
+
sendUnifyEvent({
|
|
158
|
+
type: 'input_queue_updated',
|
|
159
|
+
total: ev.total,
|
|
160
|
+
pending: ev.pending,
|
|
161
|
+
routing: ev.routing,
|
|
162
|
+
dispatched: ev.dispatched,
|
|
163
|
+
head: ev.head,
|
|
164
|
+
});
|
|
165
|
+
return false;
|
|
166
|
+
case 'routing_decision':
|
|
167
|
+
sendUnifyEvent({
|
|
168
|
+
type: 'routing_decision',
|
|
169
|
+
entryId: ev.entryId,
|
|
170
|
+
action: ev.action,
|
|
171
|
+
targetThreadId: ev.targetThreadId,
|
|
172
|
+
source: ev.source,
|
|
173
|
+
reason: ev.reason,
|
|
174
|
+
});
|
|
175
|
+
return false;
|
|
176
|
+
case 'thread_list_updated':
|
|
177
|
+
// Dispatcher built it already; just forward.
|
|
178
|
+
sendUnifyEvent({
|
|
179
|
+
type: 'thread_list_updated',
|
|
180
|
+
threads: ev.threads,
|
|
181
|
+
currentThreadId: ev.currentThreadId,
|
|
182
|
+
});
|
|
183
|
+
return false;
|
|
184
|
+
case 'engine_event':
|
|
185
|
+
ctx.onEngineEvent(ev.event, ev.threadId);
|
|
186
|
+
return false;
|
|
187
|
+
case 'error':
|
|
188
|
+
ctx.onError(ev.error);
|
|
189
|
+
return true;
|
|
190
|
+
default:
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Handle a single engine event unwrapped from an `engine_event` pipeline
|
|
197
|
+
* envelope. Contains the event-type switch previously inlined in the
|
|
198
|
+
* streaming loop. `threadId` is propagated onto tool_use / tool_result
|
|
199
|
+
* blocks so the UI can render per-thread bubbles.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} event — engine event (text_delta / tool_call / …)
|
|
202
|
+
* @param {string} threadId — owning thread id (from envelope)
|
|
203
|
+
* @param {{assistantTextParts:string[], resetQueryTimer:Function}} hctx
|
|
204
|
+
*/
|
|
205
|
+
function handleEngineEvent(event, threadId, hctx) {
|
|
206
|
+
hctx.resetQueryTimer();
|
|
207
|
+
switch (event.type) {
|
|
208
|
+
case 'text_delta':
|
|
209
|
+
hctx.assistantTextParts.push(event.text);
|
|
210
|
+
sendUnifyOutput({
|
|
211
|
+
type: 'assistant',
|
|
212
|
+
message: { content: [{ type: 'text', text: event.text }] },
|
|
213
|
+
threadId,
|
|
214
|
+
});
|
|
215
|
+
break;
|
|
216
|
+
|
|
217
|
+
case 'thinking_delta':
|
|
218
|
+
sendUnifyEvent({ type: 'thinking_delta', text: event.text, threadId });
|
|
219
|
+
break;
|
|
220
|
+
|
|
221
|
+
case 'tool_call':
|
|
222
|
+
// Finish any in-progress text streaming so UI shows typing dots
|
|
223
|
+
sendUnifyOutput({
|
|
224
|
+
type: 'assistant',
|
|
225
|
+
message: { content: [] },
|
|
226
|
+
threadId,
|
|
227
|
+
});
|
|
228
|
+
sendUnifyOutput({
|
|
229
|
+
type: 'assistant',
|
|
230
|
+
message: {
|
|
231
|
+
content: [{
|
|
232
|
+
type: 'tool_use',
|
|
233
|
+
id: event.id,
|
|
234
|
+
name: event.name,
|
|
235
|
+
input: event.input,
|
|
236
|
+
}],
|
|
237
|
+
},
|
|
238
|
+
threadId: event.threadId || threadId,
|
|
239
|
+
});
|
|
240
|
+
break;
|
|
241
|
+
|
|
242
|
+
case 'tool_start':
|
|
243
|
+
sendUnifyEvent({
|
|
244
|
+
type: 'tool_start',
|
|
245
|
+
id: event.id,
|
|
246
|
+
name: event.name,
|
|
247
|
+
threadId: event.threadId || threadId,
|
|
248
|
+
});
|
|
249
|
+
break;
|
|
250
|
+
|
|
251
|
+
case 'tool_end':
|
|
252
|
+
sendUnifyOutput({
|
|
253
|
+
type: 'user',
|
|
254
|
+
tool_use_result: [{
|
|
255
|
+
type: 'tool_result',
|
|
256
|
+
tool_use_id: event.id,
|
|
257
|
+
content: event.output || '',
|
|
258
|
+
is_error: event.isError || false,
|
|
259
|
+
}],
|
|
260
|
+
threadId: event.threadId || threadId,
|
|
261
|
+
});
|
|
262
|
+
if (THREAD_MUTATING_TOOLS.has(event.name)) {
|
|
263
|
+
sendThreadListUpdate();
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
|
|
267
|
+
case 'turn_start':
|
|
268
|
+
case 'turn_end':
|
|
269
|
+
case 'stop':
|
|
270
|
+
// No UI action needed; outer loop sends the final result.
|
|
271
|
+
break;
|
|
272
|
+
|
|
273
|
+
case 'usage':
|
|
274
|
+
sendUnifyEvent({
|
|
275
|
+
type: 'context_usage',
|
|
276
|
+
inputTokens: event.inputTokens,
|
|
277
|
+
outputTokens: event.outputTokens,
|
|
278
|
+
threadId,
|
|
279
|
+
});
|
|
280
|
+
break;
|
|
281
|
+
|
|
282
|
+
case 'recall':
|
|
283
|
+
sendUnifyEvent({
|
|
284
|
+
type: 'recall',
|
|
285
|
+
entryCount: event.entryCount,
|
|
286
|
+
cached: event.cached,
|
|
287
|
+
threadId,
|
|
288
|
+
});
|
|
289
|
+
break;
|
|
290
|
+
|
|
291
|
+
case 'consolidate':
|
|
292
|
+
// Engine compressed the context — clear our accumulated history.
|
|
293
|
+
conversationMessages = [];
|
|
294
|
+
sendUnifyEvent({
|
|
295
|
+
type: 'consolidate',
|
|
296
|
+
archivedCount: event.archivedCount,
|
|
297
|
+
extractedCount: event.extractedCount,
|
|
298
|
+
threadId,
|
|
299
|
+
});
|
|
300
|
+
break;
|
|
301
|
+
|
|
302
|
+
case 'fallback':
|
|
303
|
+
sendUnifyEvent({
|
|
304
|
+
type: 'fallback',
|
|
305
|
+
from: event.from,
|
|
306
|
+
to: event.to,
|
|
307
|
+
reason: event.reason,
|
|
308
|
+
threadId,
|
|
309
|
+
});
|
|
310
|
+
break;
|
|
311
|
+
|
|
312
|
+
case 'debug_turn':
|
|
313
|
+
sendUnifyEvent({
|
|
314
|
+
type: 'debug_turn',
|
|
315
|
+
turnNumber: event.turnNumber,
|
|
316
|
+
model: event.model,
|
|
317
|
+
systemPrompt: event.systemPrompt,
|
|
318
|
+
messages: event.messages,
|
|
319
|
+
response: event.response,
|
|
320
|
+
toolCalls: event.toolCalls,
|
|
321
|
+
usage: event.usage,
|
|
322
|
+
latencyMs: event.latencyMs,
|
|
323
|
+
ttfbMs: event.ttfbMs,
|
|
324
|
+
stopReason: event.stopReason,
|
|
325
|
+
threadId,
|
|
326
|
+
});
|
|
327
|
+
break;
|
|
328
|
+
|
|
329
|
+
case 'error': {
|
|
330
|
+
const errMsg = event.error?.message || 'Unknown error';
|
|
331
|
+
// Filter permission errors: show friendly one-time diagnostic
|
|
332
|
+
// instead of raw error. Subsequent permission errors are suppressed
|
|
333
|
+
// — the user already saw the actionable message once.
|
|
334
|
+
if (isPermissionErrorMsg(errMsg)) {
|
|
335
|
+
if (!_permissionDiagnosticSent) {
|
|
336
|
+
_permissionDiagnosticSent = true;
|
|
337
|
+
sendUnifyOutput({
|
|
338
|
+
type: 'assistant',
|
|
339
|
+
message: {
|
|
340
|
+
content: [{
|
|
341
|
+
type: 'text',
|
|
342
|
+
text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
|
|
343
|
+
}],
|
|
344
|
+
},
|
|
345
|
+
threadId,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
// Don't show subsequent permission errors.
|
|
349
|
+
} else {
|
|
350
|
+
sendUnifyOutput({
|
|
351
|
+
type: 'assistant',
|
|
352
|
+
message: {
|
|
353
|
+
content: [{ type: 'text', text: `⚠️ Error: ${errMsg}` }],
|
|
354
|
+
},
|
|
355
|
+
threadId,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
default:
|
|
362
|
+
// Silently consume unknown events.
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
119
367
|
/**
|
|
120
368
|
* Handle a unify_chat message from the web UI.
|
|
121
369
|
*
|
|
@@ -193,192 +441,44 @@ export async function handleUnifyChat(msg) {
|
|
|
193
441
|
// ─── Collect assistant response for conversation history ──
|
|
194
442
|
let assistantTextParts = [];
|
|
195
443
|
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// Currently not rendered in UI, but forward for future use
|
|
219
|
-
sendUnifyEvent({ type: 'thinking_delta', text: event.text });
|
|
220
|
-
break;
|
|
444
|
+
// task-310: route via Dispatcher pipeline (queue → router → registry →
|
|
445
|
+
// EngineInstance). The input is enqueued first so the UI observes the
|
|
446
|
+
// `input_queue_updated` snapshot before the router runs. An explicit
|
|
447
|
+
// `@thread-xxx` prefix on the message or an `override` field on the
|
|
448
|
+
// `unify_chat` payload becomes a dispatcher override — skipping the LLM.
|
|
449
|
+
const { prompt: cleanedPrompt, override: prefixOverride } = parseThreadPrefix(prompt);
|
|
450
|
+
const override = msg.override && typeof msg.override === 'object' && msg.override.threadId
|
|
451
|
+
? msg.override
|
|
452
|
+
: prefixOverride;
|
|
453
|
+
|
|
454
|
+
const { entry } = session.dispatcher.submit(cleanedPrompt, {
|
|
455
|
+
messageId: msg.messageId,
|
|
456
|
+
override: override || undefined,
|
|
457
|
+
});
|
|
458
|
+
sendUnifyEvent({
|
|
459
|
+
type: 'input_queue_updated',
|
|
460
|
+
total: 1,
|
|
461
|
+
pending: 1,
|
|
462
|
+
routing: 0,
|
|
463
|
+
dispatched: 0,
|
|
464
|
+
head: { id: entry.id, status: entry.status, text: entry.text.slice(0, 80) },
|
|
465
|
+
});
|
|
221
466
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
// Send tool_use block
|
|
230
|
-
sendUnifyOutput({
|
|
231
|
-
type: 'assistant',
|
|
232
|
-
message: {
|
|
233
|
-
content: [{
|
|
234
|
-
type: 'tool_use',
|
|
235
|
-
id: event.id,
|
|
236
|
-
name: event.name,
|
|
237
|
-
input: event.input,
|
|
238
|
-
}],
|
|
239
|
-
},
|
|
240
|
-
threadId: event.threadId,
|
|
241
|
-
});
|
|
242
|
-
break;
|
|
243
|
-
|
|
244
|
-
// ── Tool execution started ──
|
|
245
|
-
case 'tool_start':
|
|
246
|
-
// Tool is running — the UI already shows it from tool_use block above.
|
|
247
|
-
// Forward threadId so the UI can group tool activity by thread (Phase 1).
|
|
248
|
-
sendUnifyEvent({ type: 'tool_start', id: event.id, name: event.name, threadId: event.threadId });
|
|
249
|
-
break;
|
|
250
|
-
|
|
251
|
-
// ── Tool execution completed ──
|
|
252
|
-
case 'tool_end':
|
|
253
|
-
// Send tool_result as a user message (matches Claude CLI format)
|
|
254
|
-
sendUnifyOutput({
|
|
255
|
-
type: 'user',
|
|
256
|
-
tool_use_result: [{
|
|
257
|
-
type: 'tool_result',
|
|
258
|
-
tool_use_id: event.id,
|
|
259
|
-
content: event.output || '',
|
|
260
|
-
is_error: event.isError || false,
|
|
261
|
-
}],
|
|
262
|
-
threadId: event.threadId,
|
|
263
|
-
});
|
|
264
|
-
// task-301 Part 2: if this tool mutates ThreadStore, push a
|
|
265
|
-
// fresh snapshot to the sidebar immediately.
|
|
266
|
-
if (THREAD_MUTATING_TOOLS.has(event.name)) {
|
|
267
|
-
sendThreadListUpdate();
|
|
268
|
-
}
|
|
269
|
-
break;
|
|
270
|
-
|
|
271
|
-
// ── Turn boundaries ──
|
|
272
|
-
case 'turn_start':
|
|
273
|
-
// No UI action needed
|
|
274
|
-
break;
|
|
275
|
-
|
|
276
|
-
case 'turn_end':
|
|
277
|
-
// Don't send result/done here — wait for the outermost loop to finish
|
|
278
|
-
break;
|
|
279
|
-
|
|
280
|
-
// ── Token usage ──
|
|
281
|
-
case 'usage':
|
|
282
|
-
sendUnifyEvent({
|
|
283
|
-
type: 'context_usage',
|
|
284
|
-
inputTokens: event.inputTokens,
|
|
285
|
-
outputTokens: event.outputTokens,
|
|
286
|
-
});
|
|
287
|
-
break;
|
|
288
|
-
|
|
289
|
-
// ── Stop reason from LLM ──
|
|
290
|
-
case 'stop':
|
|
291
|
-
// Intermediate signal — final done is sent after the loop
|
|
292
|
-
break;
|
|
293
|
-
|
|
294
|
-
// ── Memory recall ──
|
|
295
|
-
case 'recall':
|
|
296
|
-
sendUnifyEvent({
|
|
297
|
-
type: 'recall',
|
|
298
|
-
entryCount: event.entryCount,
|
|
299
|
-
cached: event.cached,
|
|
300
|
-
});
|
|
301
|
-
break;
|
|
302
|
-
|
|
303
|
-
// ── Context consolidation ──
|
|
304
|
-
case 'consolidate':
|
|
305
|
-
// Engine has compressed the context — clear our accumulated history.
|
|
306
|
-
// The engine's compactSummary will provide context on next query.
|
|
307
|
-
conversationMessages = [];
|
|
308
|
-
sendUnifyEvent({
|
|
309
|
-
type: 'consolidate',
|
|
310
|
-
archivedCount: event.archivedCount,
|
|
311
|
-
extractedCount: event.extractedCount,
|
|
312
|
-
});
|
|
313
|
-
break;
|
|
314
|
-
|
|
315
|
-
// ── Model fallback ──
|
|
316
|
-
case 'fallback':
|
|
317
|
-
sendUnifyEvent({
|
|
318
|
-
type: 'fallback',
|
|
319
|
-
from: event.from,
|
|
320
|
-
to: event.to,
|
|
321
|
-
reason: event.reason,
|
|
322
|
-
});
|
|
323
|
-
break;
|
|
324
|
-
|
|
325
|
-
// ── Debug turn data for web debug panel ──
|
|
326
|
-
case 'debug_turn':
|
|
327
|
-
sendUnifyEvent({
|
|
328
|
-
type: 'debug_turn',
|
|
329
|
-
turnNumber: event.turnNumber,
|
|
330
|
-
model: event.model,
|
|
331
|
-
systemPrompt: event.systemPrompt,
|
|
332
|
-
messages: event.messages,
|
|
333
|
-
response: event.response,
|
|
334
|
-
toolCalls: event.toolCalls,
|
|
335
|
-
usage: event.usage,
|
|
336
|
-
latencyMs: event.latencyMs,
|
|
337
|
-
ttfbMs: event.ttfbMs,
|
|
338
|
-
stopReason: event.stopReason,
|
|
339
|
-
});
|
|
340
|
-
break;
|
|
341
|
-
|
|
342
|
-
// ── Errors ──
|
|
343
|
-
case 'error': {
|
|
344
|
-
const errMsg = event.error?.message || 'Unknown error';
|
|
345
|
-
// Filter permission errors: show friendly one-time diagnostic instead of raw error
|
|
346
|
-
if (isPermissionErrorMsg(errMsg)) {
|
|
347
|
-
if (!_permissionDiagnosticSent) {
|
|
348
|
-
_permissionDiagnosticSent = true;
|
|
349
|
-
sendUnifyOutput({
|
|
350
|
-
type: 'assistant',
|
|
351
|
-
message: {
|
|
352
|
-
content: [{
|
|
353
|
-
type: 'text',
|
|
354
|
-
text: '⚠️ Cannot write to ~/.yeaft/ directory — some features (memory, history) are unavailable. Please check directory permissions: `chmod -R u+rw ~/.yeaft/`',
|
|
355
|
-
}],
|
|
356
|
-
},
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
// Don't show subsequent permission errors
|
|
360
|
-
} else {
|
|
361
|
-
sendUnifyOutput({
|
|
362
|
-
type: 'assistant',
|
|
363
|
-
message: {
|
|
364
|
-
content: [{
|
|
365
|
-
type: 'text',
|
|
366
|
-
text: `⚠️ Error: ${errMsg}`,
|
|
367
|
-
}],
|
|
368
|
-
},
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
break;
|
|
372
|
-
}
|
|
467
|
+
const pipelineCtx = {
|
|
468
|
+
onEngineEvent: (event, threadId) => handleEngineEvent(event, threadId, {
|
|
469
|
+
assistantTextParts,
|
|
470
|
+
resetQueryTimer,
|
|
471
|
+
}),
|
|
472
|
+
onError: (err) => { throw err; },
|
|
473
|
+
};
|
|
373
474
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
475
|
+
for await (const pev of session.dispatcher.drain({ signal: currentAbort.signal })) {
|
|
476
|
+
resetQueryTimer();
|
|
477
|
+
forwardPipelineEvent(pev, pipelineCtx);
|
|
378
478
|
}
|
|
379
479
|
|
|
380
480
|
// ─── Query complete — accumulate messages for context continuity ──
|
|
381
|
-
conversationMessages.push({ role: 'user', content:
|
|
481
|
+
conversationMessages.push({ role: 'user', content: cleanedPrompt });
|
|
382
482
|
|
|
383
483
|
const fullText = assistantTextParts.join('');
|
|
384
484
|
if (fullText) {
|