@yeaft/webchat-agent 0.1.486 → 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/cli.js +5 -28
- package/unify/engine.js +8 -20
- package/unify/eval/cases/tool-use.js +1 -22
- package/unify/pipeline/dispatcher.js +373 -0
- package/unify/session.js +21 -0
- package/unify/skills.js +15 -9
- package/unify/tools/agent.js +0 -1
- package/unify/tools/apply-patch.js +0 -1
- package/unify/tools/ask-user.js +0 -1
- package/unify/tools/bash.js +0 -1
- package/unify/tools/close-agent.js +0 -1
- package/unify/tools/enter-worktree.js +0 -1
- package/unify/tools/exit-worktree.js +0 -1
- package/unify/tools/file-edit.js +0 -1
- package/unify/tools/file-read.js +0 -1
- package/unify/tools/file-write.js +0 -1
- package/unify/tools/glob.js +0 -1
- package/unify/tools/grep.js +0 -1
- package/unify/tools/history-search.js +0 -1
- package/unify/tools/image-generation.js +0 -1
- package/unify/tools/js-repl.js +0 -2
- package/unify/tools/list-agents.js +0 -1
- package/unify/tools/list-dir.js +0 -1
- package/unify/tools/mcp-tools.js +0 -2
- package/unify/tools/memory-query.js +0 -1
- package/unify/tools/memory-read.js +0 -1
- package/unify/tools/memory-search.js +0 -1
- package/unify/tools/memory-write.js +0 -1
- package/unify/tools/notebook-edit.js +0 -1
- package/unify/tools/request-permissions.js +0 -1
- package/unify/tools/send-message.js +0 -1
- package/unify/tools/skill.js +0 -1
- package/unify/tools/task-tools.js +0 -8
- package/unify/tools/thread-tools.js +0 -7
- package/unify/tools/tool-search.js +47 -56
- package/unify/tools/types.js +3 -6
- package/unify/tools/view-image.js +0 -1
- package/unify/tools/wait-agent.js +0 -1
- package/unify/tools/web-fetch.js +0 -1
- package/unify/tools/web-search.js +0 -1
- package/unify/tools/write-stdin.js +0 -1
- package/unify/web-bridge.js +281 -181
package/package.json
CHANGED
package/unify/cli.js
CHANGED
|
@@ -37,7 +37,6 @@ import { consolidate } from './memory/consolidate.js';
|
|
|
37
37
|
|
|
38
38
|
function parseArgs(argv) {
|
|
39
39
|
const args = {
|
|
40
|
-
mode: 'chat',
|
|
41
40
|
debug: false,
|
|
42
41
|
interactive: false,
|
|
43
42
|
verbose: false,
|
|
@@ -57,10 +56,6 @@ function parseArgs(argv) {
|
|
|
57
56
|
while (i < rest.length) {
|
|
58
57
|
const arg = rest[i];
|
|
59
58
|
switch (arg) {
|
|
60
|
-
case '-m':
|
|
61
|
-
case '--mode':
|
|
62
|
-
args.mode = rest[++i] || 'chat';
|
|
63
|
-
break;
|
|
64
59
|
case '-d':
|
|
65
60
|
case '--debug':
|
|
66
61
|
args.debug = true;
|
|
@@ -176,7 +171,7 @@ function handleTraceQuery(args, config) {
|
|
|
176
171
|
// ─── Dry-run handler ───────────────────────────────────────────
|
|
177
172
|
|
|
178
173
|
function handleDryRun(args, config) {
|
|
179
|
-
const systemPrompt = buildSystemPrompt({ language: config.language
|
|
174
|
+
const systemPrompt = buildSystemPrompt({ language: config.language });
|
|
180
175
|
const messages = [];
|
|
181
176
|
|
|
182
177
|
if (args.prompt) {
|
|
@@ -188,7 +183,6 @@ function handleDryRun(args, config) {
|
|
|
188
183
|
console.log('--- Config ---');
|
|
189
184
|
console.log(` Model: ${config.model}`);
|
|
190
185
|
console.log(` Adapter: ${config.adapter || 'auto'}`);
|
|
191
|
-
console.log(` Mode: ${args.mode}`);
|
|
192
186
|
console.log(` Debug: ${config.debug}`);
|
|
193
187
|
console.log();
|
|
194
188
|
console.log('--- System Prompt ---');
|
|
@@ -218,7 +212,6 @@ async function runREPL(config, args) {
|
|
|
218
212
|
});
|
|
219
213
|
|
|
220
214
|
const { engine, conversationStore, memoryStore, trace, skillManager, mcpManager, toolRegistry } = session;
|
|
221
|
-
let currentMode = args.mode;
|
|
222
215
|
|
|
223
216
|
// Load persisted conversation as initial messages
|
|
224
217
|
let conversationMessages = conversationStore.loadRecent(50).map(m => ({
|
|
@@ -232,7 +225,7 @@ async function runREPL(config, args) {
|
|
|
232
225
|
const coldCount = conversationStore.countCold();
|
|
233
226
|
const memStats = memoryStore.stats();
|
|
234
227
|
|
|
235
|
-
console.log(`Yeaft Unify REPL (model: ${session.config.model}
|
|
228
|
+
console.log(`Yeaft Unify REPL (model: ${session.config.model})`);
|
|
236
229
|
console.log(`Conversation: ${hotCount} hot, ${coldCount} cold | Memory: ${memStats.entryCount} entries`);
|
|
237
230
|
console.log(`Tools: ${session.status.tools} | Skills: ${session.status.skills}`);
|
|
238
231
|
if (session.status.mcpServers.length > 0) {
|
|
@@ -247,7 +240,7 @@ async function runREPL(config, args) {
|
|
|
247
240
|
const rl = createInterface({
|
|
248
241
|
input: process.stdin,
|
|
249
242
|
output: process.stdout,
|
|
250
|
-
prompt: `yeaft
|
|
243
|
+
prompt: `yeaft> `,
|
|
251
244
|
});
|
|
252
245
|
|
|
253
246
|
rl.prompt();
|
|
@@ -265,7 +258,6 @@ async function runREPL(config, args) {
|
|
|
265
258
|
switch (cmd) {
|
|
266
259
|
case 'help':
|
|
267
260
|
console.log('Commands:');
|
|
268
|
-
console.log(' /mode <chat|work|dream> — Switch mode');
|
|
269
261
|
console.log(' /debug — Toggle debug mode');
|
|
270
262
|
console.log(' /trace <stats|recent> — Query debug trace');
|
|
271
263
|
console.log(' /memory [add|clear|stats] — Memory management');
|
|
@@ -285,16 +277,6 @@ async function runREPL(config, args) {
|
|
|
285
277
|
console.log(' /quit — Exit');
|
|
286
278
|
break;
|
|
287
279
|
|
|
288
|
-
case 'mode':
|
|
289
|
-
if (cmdArgs[0]) {
|
|
290
|
-
currentMode = cmdArgs[0];
|
|
291
|
-
rl.setPrompt(`yeaft:${currentMode}> `);
|
|
292
|
-
console.log(`Mode switched to: ${currentMode}`);
|
|
293
|
-
} else {
|
|
294
|
-
console.log(`Current mode: ${currentMode}`);
|
|
295
|
-
}
|
|
296
|
-
break;
|
|
297
|
-
|
|
298
280
|
case 'debug':
|
|
299
281
|
session.config.debug = !session.config.debug;
|
|
300
282
|
console.log(`Debug mode: ${session.config.debug ? 'ON' : 'OFF'}`);
|
|
@@ -433,10 +415,9 @@ async function runREPL(config, args) {
|
|
|
433
415
|
case 'context':
|
|
434
416
|
console.log(`Context info:`);
|
|
435
417
|
console.log(` Model: ${session.config.model}`);
|
|
436
|
-
console.log(` Mode: ${currentMode}`);
|
|
437
418
|
console.log(` Language: ${session.config.language}`);
|
|
438
419
|
console.log(` Max context: ${session.config.maxContextTokens} tokens`);
|
|
439
|
-
console.log(` System prompt: ${buildSystemPrompt({ language: session.config.language
|
|
420
|
+
console.log(` System prompt: ${buildSystemPrompt({ language: session.config.language }).length} chars`);
|
|
440
421
|
console.log(` Hot messages: ${conversationStore.countHot()}`);
|
|
441
422
|
console.log(` Hot tokens: ${conversationStore.hotTokens()}`);
|
|
442
423
|
console.log(` Cold messages: ${conversationStore.countCold()}`);
|
|
@@ -446,13 +427,12 @@ async function runREPL(config, args) {
|
|
|
446
427
|
break;
|
|
447
428
|
|
|
448
429
|
case 'dry-run':
|
|
449
|
-
handleDryRun({ ...args,
|
|
430
|
+
handleDryRun({ ...args, prompt: cmdArgs.join(' ') || null }, session.config);
|
|
450
431
|
break;
|
|
451
432
|
|
|
452
433
|
case 'stats': {
|
|
453
434
|
const s = trace.stats();
|
|
454
435
|
console.log(`Session stats:`);
|
|
455
|
-
console.log(` Mode: ${currentMode}`);
|
|
456
436
|
console.log(` Debug: ${session.config.debug}`);
|
|
457
437
|
console.log(` Turns: ${s.turnCount}`);
|
|
458
438
|
console.log(` Tools: ${s.toolCount}`);
|
|
@@ -607,7 +587,6 @@ async function runREPL(config, args) {
|
|
|
607
587
|
|
|
608
588
|
for await (const event of engine.query({
|
|
609
589
|
prompt: input,
|
|
610
|
-
mode: currentMode,
|
|
611
590
|
messages: conversationMessages,
|
|
612
591
|
})) {
|
|
613
592
|
switch (event.type) {
|
|
@@ -709,7 +688,6 @@ async function runOnce(config, args) {
|
|
|
709
688
|
|
|
710
689
|
for await (const event of engine.query({
|
|
711
690
|
prompt: args.prompt,
|
|
712
|
-
mode: args.mode,
|
|
713
691
|
messages: priorMessages,
|
|
714
692
|
})) {
|
|
715
693
|
switch (event.type) {
|
|
@@ -819,7 +797,6 @@ async function main() {
|
|
|
819
797
|
console.log(' node cli.js --trace search "keyword" — Search traces');
|
|
820
798
|
console.log();
|
|
821
799
|
console.log('Options:');
|
|
822
|
-
console.log(' -m, --mode <mode> Mode: chat, work, dream (default: chat)');
|
|
823
800
|
console.log(' -d, --debug Enable debug tracing');
|
|
824
801
|
console.log(' -i, --interactive Start REPL');
|
|
825
802
|
console.log(' -v, --verbose Verbose output');
|
package/unify/engine.js
CHANGED
|
@@ -167,18 +167,17 @@ export class Engine {
|
|
|
167
167
|
/**
|
|
168
168
|
* Build the system prompt with memory, compact summary, and skill content.
|
|
169
169
|
*
|
|
170
|
-
* @param {string} mode
|
|
171
170
|
* @param {{ profile?: string, entries?: object[] }} [memory]
|
|
172
171
|
* @param {string} [compactSummary]
|
|
173
172
|
* @param {string} [prompt] — user prompt (for skill relevance matching)
|
|
174
173
|
* @param {string} [memoryInjection] — task-287: prebuilt memory block (index + prefs + project)
|
|
175
174
|
* @returns {string}
|
|
176
175
|
*/
|
|
177
|
-
#buildSystemPrompt(
|
|
176
|
+
#buildSystemPrompt(memory, compactSummary, prompt, memoryInjection) {
|
|
178
177
|
// Get relevant skill content if SkillManager is wired
|
|
179
178
|
let skillContent = '';
|
|
180
179
|
if (this.#skillManager && prompt) {
|
|
181
|
-
skillContent = this.#skillManager.getRelevantPromptContent(prompt
|
|
180
|
+
skillContent = this.#skillManager.getRelevantPromptContent(prompt);
|
|
182
181
|
}
|
|
183
182
|
|
|
184
183
|
// Get tool names from the appropriate source
|
|
@@ -188,7 +187,6 @@ export class Engine {
|
|
|
188
187
|
|
|
189
188
|
return buildSystemPrompt({
|
|
190
189
|
language: this.#config.language || 'en',
|
|
191
|
-
mode,
|
|
192
190
|
toolNames,
|
|
193
191
|
memory,
|
|
194
192
|
memoryInjection,
|
|
@@ -201,10 +199,9 @@ export class Engine {
|
|
|
201
199
|
* Build the full tool context for Phase 5 tools.
|
|
202
200
|
*
|
|
203
201
|
* @param {AbortSignal} [signal]
|
|
204
|
-
* @param {string} [mode]
|
|
205
202
|
* @returns {object}
|
|
206
203
|
*/
|
|
207
|
-
#buildToolContext(signal
|
|
204
|
+
#buildToolContext(signal) {
|
|
208
205
|
return {
|
|
209
206
|
signal,
|
|
210
207
|
yeaftDir: this.#yeaftDir,
|
|
@@ -215,7 +212,6 @@ export class Engine {
|
|
|
215
212
|
conversationStore: this.#conversationStore,
|
|
216
213
|
adapter: this.#adapter,
|
|
217
214
|
config: this.#config,
|
|
218
|
-
mode,
|
|
219
215
|
};
|
|
220
216
|
}
|
|
221
217
|
|
|
@@ -265,10 +261,9 @@ export class Engine {
|
|
|
265
261
|
*
|
|
266
262
|
* @param {string} userContent
|
|
267
263
|
* @param {string} assistantContent
|
|
268
|
-
* @param {string} mode
|
|
269
264
|
* @param {object[]} [toolCalls]
|
|
270
265
|
*/
|
|
271
|
-
#persistMessages(userContent, assistantContent,
|
|
266
|
+
#persistMessages(userContent, assistantContent, toolCalls) {
|
|
272
267
|
if (!this.#conversationStore) return;
|
|
273
268
|
if (this.#config._readOnly) return;
|
|
274
269
|
|
|
@@ -288,7 +283,6 @@ export class Engine {
|
|
|
288
283
|
this.#conversationStore.append({
|
|
289
284
|
role: 'user',
|
|
290
285
|
content: userContent,
|
|
291
|
-
mode,
|
|
292
286
|
threadId,
|
|
293
287
|
});
|
|
294
288
|
|
|
@@ -296,7 +290,6 @@ export class Engine {
|
|
|
296
290
|
const assistantMsg = {
|
|
297
291
|
role: 'assistant',
|
|
298
292
|
content: assistantContent,
|
|
299
|
-
mode,
|
|
300
293
|
model: this.#config.model,
|
|
301
294
|
threadId,
|
|
302
295
|
};
|
|
@@ -354,14 +347,11 @@ export class Engine {
|
|
|
354
347
|
*
|
|
355
348
|
* @param {object} params
|
|
356
349
|
* @param {string} params.prompt - The user prompt (required, non-empty).
|
|
357
|
-
* @param {'dream'} [params.mode] - Optional mode flag. Since task-297 the only
|
|
358
|
-
* value accepted / acted on is `'dream'` (memory maintenance system prompt).
|
|
359
|
-
* Any other value is ignored and falls through to the unified system prompt.
|
|
360
350
|
* @param {Array} [params.messages] - Prior conversation messages.
|
|
361
351
|
* @param {AbortSignal} [params.signal] - Abort signal.
|
|
362
352
|
* @yields {EngineEvent}
|
|
363
353
|
*/
|
|
364
|
-
async *query({ prompt,
|
|
354
|
+
async *query({ prompt, messages = [], signal }) {
|
|
365
355
|
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
366
356
|
yield {
|
|
367
357
|
type: 'error',
|
|
@@ -394,7 +384,7 @@ export class Engine {
|
|
|
394
384
|
}
|
|
395
385
|
|
|
396
386
|
const compactSummary = this.#getCompactSummary();
|
|
397
|
-
const systemPrompt = this.#buildSystemPrompt(
|
|
387
|
+
const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection);
|
|
398
388
|
|
|
399
389
|
// Build conversation: existing messages + new user message
|
|
400
390
|
const conversationMessages = [
|
|
@@ -423,7 +413,6 @@ export class Engine {
|
|
|
423
413
|
|
|
424
414
|
const turnId = this.#trace.startTurn({
|
|
425
415
|
traceId: this.#traceId,
|
|
426
|
-
mode,
|
|
427
416
|
turnNumber,
|
|
428
417
|
});
|
|
429
418
|
|
|
@@ -590,7 +579,6 @@ export class Engine {
|
|
|
590
579
|
// but receives both configs — messages are persisted with primary model name
|
|
591
580
|
const hookResult = await runStopHooks({
|
|
592
581
|
yeaftDir: this.#yeaftDir,
|
|
593
|
-
mode,
|
|
594
582
|
conversationStore: this.#conversationStore,
|
|
595
583
|
memoryStore: this.#memoryStore,
|
|
596
584
|
adapter: this.#adapter,
|
|
@@ -608,7 +596,7 @@ export class Engine {
|
|
|
608
596
|
}
|
|
609
597
|
} else {
|
|
610
598
|
// Legacy path (no yeaftDir → use old behavior)
|
|
611
|
-
this.#persistMessages(prompt, fullResponseText,
|
|
599
|
+
this.#persistMessages(prompt, fullResponseText, assistantMsg.toolCalls);
|
|
612
600
|
|
|
613
601
|
const consolidated = await this.#maybeConsolidate();
|
|
614
602
|
if (consolidated && consolidated.archivedCount > 0) {
|
|
@@ -620,7 +608,7 @@ export class Engine {
|
|
|
620
608
|
}
|
|
621
609
|
|
|
622
610
|
// Execute tool calls and feed results back
|
|
623
|
-
const toolCtx = this.#buildToolContext(signal
|
|
611
|
+
const toolCtx = this.#buildToolContext(signal);
|
|
624
612
|
|
|
625
613
|
for (const tc of toolCalls) {
|
|
626
614
|
const toolStartTime = Date.now();
|
|
@@ -242,7 +242,6 @@ export const toolUseCases = [
|
|
|
242
242
|
suite: 'tools',
|
|
243
243
|
description: 'Model should read a file then modify it (sequential tools)',
|
|
244
244
|
prompt: 'Read src/index.js and add a health check endpoint at /health',
|
|
245
|
-
mode: 'work',
|
|
246
245
|
registryTools: allTools,
|
|
247
246
|
criteria: [
|
|
248
247
|
noError,
|
|
@@ -268,9 +267,8 @@ export const toolUseCases = [
|
|
|
268
267
|
{
|
|
269
268
|
id: 'tool-multi-bash-workflow',
|
|
270
269
|
suite: 'tools',
|
|
271
|
-
description: 'Model should run git status and npm test
|
|
270
|
+
description: 'Model should run git status and npm test',
|
|
272
271
|
prompt: 'Check the git status and run the tests',
|
|
273
|
-
mode: 'work',
|
|
274
272
|
registryTools: allTools,
|
|
275
273
|
criteria: [
|
|
276
274
|
noError,
|
|
@@ -290,25 +288,6 @@ export const toolUseCases = [
|
|
|
290
288
|
],
|
|
291
289
|
},
|
|
292
290
|
|
|
293
|
-
// ─── Mode Awareness ───────────────────────────────────
|
|
294
|
-
|
|
295
|
-
{
|
|
296
|
-
id: 'tool-mode-chat-no-write',
|
|
297
|
-
suite: 'tools',
|
|
298
|
-
description: 'In chat mode, write_file should not be available (work-only tool)',
|
|
299
|
-
prompt: 'Write "hello" to a file called greeting.txt',
|
|
300
|
-
mode: 'chat',
|
|
301
|
-
registryTools: allTools,
|
|
302
|
-
criteria: [
|
|
303
|
-
noError,
|
|
304
|
-
toolNotCalled('write_file', {
|
|
305
|
-
weight: 10,
|
|
306
|
-
id: 'no-write-in-chat',
|
|
307
|
-
description: 'write_file is work-only and should not be called in chat mode',
|
|
308
|
-
}),
|
|
309
|
-
],
|
|
310
|
-
},
|
|
311
|
-
|
|
312
291
|
// ─── Error Handling ───────────────────────────────────
|
|
313
292
|
|
|
314
293
|
{
|
|
@@ -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
|
+
}
|