@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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/unify/cli.js +5 -28
  3. package/unify/engine.js +8 -20
  4. package/unify/eval/cases/tool-use.js +1 -22
  5. package/unify/pipeline/dispatcher.js +373 -0
  6. package/unify/session.js +21 -0
  7. package/unify/skills.js +15 -9
  8. package/unify/tools/agent.js +0 -1
  9. package/unify/tools/apply-patch.js +0 -1
  10. package/unify/tools/ask-user.js +0 -1
  11. package/unify/tools/bash.js +0 -1
  12. package/unify/tools/close-agent.js +0 -1
  13. package/unify/tools/enter-worktree.js +0 -1
  14. package/unify/tools/exit-worktree.js +0 -1
  15. package/unify/tools/file-edit.js +0 -1
  16. package/unify/tools/file-read.js +0 -1
  17. package/unify/tools/file-write.js +0 -1
  18. package/unify/tools/glob.js +0 -1
  19. package/unify/tools/grep.js +0 -1
  20. package/unify/tools/history-search.js +0 -1
  21. package/unify/tools/image-generation.js +0 -1
  22. package/unify/tools/js-repl.js +0 -2
  23. package/unify/tools/list-agents.js +0 -1
  24. package/unify/tools/list-dir.js +0 -1
  25. package/unify/tools/mcp-tools.js +0 -2
  26. package/unify/tools/memory-query.js +0 -1
  27. package/unify/tools/memory-read.js +0 -1
  28. package/unify/tools/memory-search.js +0 -1
  29. package/unify/tools/memory-write.js +0 -1
  30. package/unify/tools/notebook-edit.js +0 -1
  31. package/unify/tools/request-permissions.js +0 -1
  32. package/unify/tools/send-message.js +0 -1
  33. package/unify/tools/skill.js +0 -1
  34. package/unify/tools/task-tools.js +0 -8
  35. package/unify/tools/thread-tools.js +0 -7
  36. package/unify/tools/tool-search.js +47 -56
  37. package/unify/tools/types.js +3 -6
  38. package/unify/tools/view-image.js +0 -1
  39. package/unify/tools/wait-agent.js +0 -1
  40. package/unify/tools/web-fetch.js +0 -1
  41. package/unify/tools/web-search.js +0 -1
  42. package/unify/tools/write-stdin.js +0 -1
  43. package/unify/web-bridge.js +281 -181
@@ -63,7 +63,6 @@ Supports PNG, JPEG, GIF, BMP, WebP, SVG, and ICO.`,
63
63
  },
64
64
  required: ['file_path'],
65
65
  },
66
- modes: ['chat', 'work'],
67
66
  isConcurrencySafe: () => true,
68
67
  isReadOnly: () => true,
69
68
  async execute(input, ctx) {
@@ -25,7 +25,6 @@ Use after sending a task to an agent via SendMessage.`,
25
25
  },
26
26
  required: ['agent_id'],
27
27
  },
28
- modes: ['work'],
29
28
  isConcurrencySafe: () => true,
30
29
  isReadOnly: () => true,
31
30
  async execute(input, ctx) {
@@ -62,7 +62,6 @@ Guidelines:
62
62
  },
63
63
  required: ['url'],
64
64
  },
65
- modes: ['chat', 'work'],
66
65
  isConcurrencySafe: () => true,
67
66
  isReadOnly: () => true,
68
67
  async execute(input, ctx) {
@@ -32,7 +32,6 @@ Guidelines:
32
32
  },
33
33
  required: ['query'],
34
34
  },
35
- modes: ['chat', 'work'],
36
35
  isConcurrencySafe: () => true,
37
36
  isReadOnly: () => true,
38
37
  async execute(input, ctx) {
@@ -34,7 +34,6 @@ Note: For most use cases, pipe input via Bash: echo "input" | command`,
34
34
  },
35
35
  required: ['data'],
36
36
  },
37
- modes: ['chat', 'work'],
38
37
  isConcurrencySafe: () => false,
39
38
  isReadOnly: () => false,
40
39
  async execute(input, ctx) {
@@ -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
- // ─── Stream Engine eventsclaude_output format ──
197
- for await (const event of session.engine.query({
198
- prompt,
199
- messages: conversationMessages,
200
- signal: currentAbort.signal,
201
- })) {
202
- // Reset timeout on every event activity means the query is alive
203
- resetQueryTimer();
204
- switch (event.type) {
205
- // ── Text streaming ──
206
- case 'text_delta':
207
- assistantTextParts.push(event.text);
208
- sendUnifyOutput({
209
- type: 'assistant',
210
- message: {
211
- content: [{ type: 'text', text: event.text }],
212
- },
213
- });
214
- break;
215
-
216
- // ── Thinking streaming (extended thinking) ──
217
- case 'thinking_delta':
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
- // ── Tool call announced by LLM ──
223
- case 'tool_call':
224
- // Finish any in-progress text streaming so UI shows typing dots
225
- sendUnifyOutput({
226
- type: 'assistant',
227
- message: { content: [] },
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
- default:
375
- // Silently consume unknown events
376
- break;
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: prompt });
481
+ conversationMessages.push({ role: 'user', content: cleanedPrompt });
382
482
 
383
483
  const fullText = assistantTextParts.join('');
384
484
  if (fullText) {