@yeaft/webchat-agent 0.1.649 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.649",
3
+ "version": "0.1.652",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1,104 +1,55 @@
1
1
  /**
2
- * pipeline/dispatcher.js — task-310 Phase 2 integration.
2
+ * pipeline/dispatcher.js — H2.f.1 single-thread dispatcher.
3
3
  *
4
- * Composes the three Phase-2 building blocks into a single linear pipeline:
4
+ * H2 retires the multi-thread routing model. The new memory architecture
5
+ * (pre-flow FTS + adjustMemory) does not need an LLM router to pick a
6
+ * thread per turn; one engine, one conversation. This module preserves
7
+ * the historic `submit()` / `drain()` API surface so web-bridge does not
8
+ * change in this PR — it just always routes to MAIN_THREAD_ID and never
9
+ * calls a classifier.
10
+ *
11
+ * The pipeline now collapses to:
5
12
  *
6
13
  * unify_chat input
7
14
  * │
8
15
  * ▼
9
- * ┌──────────────┐ (task-307b)
16
+ * ┌──────────────┐
10
17
  * │ InputQueue │ persistent FIFO of pending user inputs
11
18
  * └──────┬───────┘
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 })
19
+ * │ claim()
23
20
  * ▼
24
21
  * ┌──────────────┐
25
- * │ Engine eventstext_delta / tool_call / tool_end / …
22
+ * │ EngineReg. ensure(MAIN_THREAD_ID) EngineInstance
26
23
  * └──────┬───────┘
27
- * │ each event tagged { ...ev, threadId } by EngineInstance
24
+ * │ inst.query({ prompt })
28
25
  * ▼
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.)
26
+ * web-bridge forwards engine events as `unify_output`.
40
27
  *
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.
28
+ * `routing_decision` is still yielded so the wire protocol is unchanged
29
+ * (action='continue', source='single-thread'). Frontend treats it as a
30
+ * no-op marker.
74
31
  */
75
32
 
76
33
  import { MAIN_THREAD_ID } from '../threads/store.js';
77
- import { getFeatureStore } from '../tools/feature-tools.js';
78
34
 
79
35
  /**
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}}>}
36
+ * Per-entry transient metadata (messageId, queryOpts) lives in a WeakMap
37
+ * keyed by the queue entry object so it is NEVER persisted to disk by
38
+ * InputQueueStore. Entries are GC'd together with the entry once the
39
+ * queue drops the strong reference.
40
+ * @type {WeakMap<object, {messageId?: string, queryOpts?: object}>}
85
41
  */
86
42
  const transientMeta = new WeakMap();
87
43
 
88
44
  /**
89
- * @typedef {'continue'|'interrupt'|'fork'|'switch'} RouterAction
90
- *
91
45
  * @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.
46
+ * @property {string} [messageId]
47
+ * @property {object} [queryOpts]
48
+ * @property {object} [override] accepted for back-compat, ignored.
96
49
  *
97
50
  * @typedef {Object} DispatcherDeps
98
51
  * @property {import('../input-queue/store.js').InputQueueStore} inputQueue
99
- * @property {import('../router/intent-classifier.js').IntentClassifier} router
100
52
  * @property {import('../threads/engine-registry.js').ThreadEngineRegistry} engineRegistry
101
- * @property {import('../threads/store.js').ThreadStore} threadStore
102
53
  * @property {object} [trace]
103
54
  */
104
55
 
@@ -107,19 +58,13 @@ export class Dispatcher {
107
58
  #deps;
108
59
 
109
60
  constructor(deps) {
110
- const { inputQueue, router, engineRegistry, threadStore } = deps || {};
61
+ const { inputQueue, engineRegistry } = deps || {};
111
62
  if (!inputQueue || typeof inputQueue.enqueue !== 'function') {
112
63
  throw new Error('Dispatcher: inputQueue is required');
113
64
  }
114
- if (!router || typeof router.classify !== 'function') {
115
- throw new Error('Dispatcher: router is required');
116
- }
117
65
  if (!engineRegistry || typeof engineRegistry.ensure !== 'function') {
118
66
  throw new Error('Dispatcher: engineRegistry is required');
119
67
  }
120
- if (!threadStore || typeof threadStore.list !== 'function') {
121
- throw new Error('Dispatcher: threadStore is required');
122
- }
123
68
  this.#deps = deps;
124
69
  }
125
70
 
@@ -143,8 +88,7 @@ export class Dispatcher {
143
88
 
144
89
  /**
145
90
  * 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.
91
+ * `dispatch(entry)` next.
148
92
  *
149
93
  * @param {string} text
150
94
  * @param {SubmitOptions} [opts]
@@ -156,14 +100,8 @@ export class Dispatcher {
156
100
  }
157
101
  const { inputQueue } = this.#deps;
158
102
  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
103
  transientMeta.set(entry, {
165
104
  messageId: opts.messageId || undefined,
166
- override: opts.override || undefined,
167
105
  queryOpts: opts.queryOpts || undefined,
168
106
  });
169
107
  const snapshot = this.#queueSnapshot();
@@ -172,7 +110,6 @@ export class Dispatcher {
172
110
 
173
111
  /**
174
112
  * Drain the queue: claim → dispatch in a loop until empty.
175
- * Yields the union of every `dispatch()`'s events, interleaved naturally.
176
113
  *
177
114
  * @param {{ signal?: AbortSignal }} [opts]
178
115
  * @yields {object} bridge events
@@ -188,22 +125,18 @@ export class Dispatcher {
188
125
  }
189
126
 
190
127
  /**
191
- * Dispatch one queue entry through router + engine. Transitions the
192
- * entry: pending routing (on claim) dispatched (on success) or back
193
- * to pending (on router exception, which is already guarded inside the
194
- * classifier — so in practice this branch is very rare).
128
+ * Dispatch one queue entry to the single thread engine. Always routes
129
+ * to MAIN_THREAD_ID no LLM classification, no fork/switch/interrupt.
195
130
  *
196
131
  * @param {object} entry — from inputQueue.peek() or inputQueue.enqueue()
197
132
  * @param {{ signal?: AbortSignal }} [opts]
198
133
  * @yields {object} bridge events
199
134
  */
200
135
  async *dispatch(entry, opts = {}) {
201
- const { inputQueue, router, engineRegistry, threadStore } = this.#deps;
136
+ const { inputQueue, engineRegistry } = this.#deps;
202
137
  const { signal } = opts;
203
138
 
204
139
  // ── Step 1: claim (pending → routing) ──
205
- // Note: we assume the caller already found `entry` as the head. A
206
- // concurrent dispatcher would have claimed it first; we check for that.
207
140
  let claimed = entry;
208
141
  if (entry.status === 'pending') {
209
142
  claimed = inputQueue.claim();
@@ -215,80 +148,19 @@ export class Dispatcher {
215
148
  }
216
149
  yield this.#queueSnapshot();
217
150
 
218
- // ── Step 2: gather router context ──
219
- const currentThreadId = threadStore.currentId || MAIN_THREAD_ID;
220
- const allThreads = threadStore.list().map(t => ({
221
- id: t.id, name: t.name, goal: t.goal, status: t.status,
222
- }));
223
- const pendingFeatures = this.#listPendingFeatures();
224
-
225
- // ── Step 3: classify (explicit override > classifier) ──
226
- /** @type {import('../router/intent-classifier.js').RouterDecision} */
227
- let decision;
228
- const meta = transientMeta.get(entry) || {};
229
- const ov = meta.override;
230
- if (ov && typeof ov.threadId === 'string' && ov.threadId) {
231
- const known = allThreads.some(t => t.id === ov.threadId) || ov.threadId === currentThreadId;
232
- if (known) {
233
- const action = ov.threadId === currentThreadId ? 'continue' : 'switch';
234
- decision = {
235
- action,
236
- targetThreadId: ov.threadId,
237
- reason: 'ui_override',
238
- source: 'override',
239
- };
240
- }
241
- }
242
- if (!decision) {
243
- try {
244
- decision = await router.classify({
245
- userMessage: claimed.text,
246
- currentThreadId,
247
- allThreads,
248
- pendingFeatures,
249
- messageId: meta.messageId || undefined,
250
- });
251
- } catch (err) {
252
- // Classifier is wrapped in its own try/catch already; reaching here
253
- // means a truly unexpected failure. Degrade to continue.
254
- decision = {
255
- action: 'continue',
256
- targetThreadId: currentThreadId,
257
- reason: `dispatcher_classify_exception: ${err.message}`,
258
- source: 'fallback',
259
- };
260
- }
261
- }
262
-
151
+ // ── Step 2: synthesize a "continue to main" routing decision so the
152
+ // wire protocol stays compatible with old clients. ──
153
+ const targetThreadId = MAIN_THREAD_ID;
263
154
  yield {
264
155
  type: 'routing_decision',
265
156
  entryId: claimed.id,
266
- action: decision.action,
267
- targetThreadId: decision.targetThreadId,
268
- source: decision.source || 'llm',
269
- reason: decision.reason || '',
157
+ action: 'continue',
158
+ targetThreadId,
159
+ source: 'single-thread',
160
+ reason: 'single-thread-dispatcher',
270
161
  };
271
162
 
272
- // ── Step 4: resolve target thread (fork may create a new one) ──
273
- let targetThreadId = decision.targetThreadId;
274
- if (decision.action === 'fork') {
275
- const parentId = decision.targetThreadId || currentThreadId;
276
- const forked = this.#spawnForkedThread(parentId, claimed.text);
277
- if (forked) {
278
- targetThreadId = forked.id;
279
- yield this.#threadListSnapshot();
280
- }
281
- } else if (decision.action === 'switch') {
282
- // Move the ThreadStore cursor so subsequent tool-originated events
283
- // see the right thread for persistence hooks. Guard: not every
284
- // ThreadStore implementation exposes has() (e.g. historic mocks).
285
- const hasFn = typeof threadStore.has === 'function' ? (id) => threadStore.has(id) : () => true;
286
- if (hasFn(targetThreadId)) {
287
- try { threadStore.switch(targetThreadId); } catch { /* ignore */ }
288
- }
289
- }
290
-
291
- // ── Step 5: dispatch to EngineInstance ──
163
+ // ── Step 3: dispatch to the single EngineInstance ──
292
164
  let instance;
293
165
  try {
294
166
  instance = engineRegistry.ensure(targetThreadId);
@@ -312,61 +184,10 @@ export class Dispatcher {
312
184
  yield { type: 'error', error: err, retryable: true };
313
185
  }
314
186
  }
315
-
316
- // ──────────────────────────────────────────────────────────────
317
-
318
- #threadListSnapshot() {
319
- const { threadStore } = this.#deps;
320
- const threads = threadStore.list().map(t => ({
321
- id: t.id,
322
- name: t.name,
323
- goal: t.goal || '',
324
- parentThreadId: t.parentThreadId || null,
325
- status: t.status,
326
- archived: !!t.archived,
327
- messageCount: t.messageCount || 0,
328
- lastMessageAt: t.lastMessageAt || null,
329
- }));
330
- return {
331
- type: 'thread_list_updated',
332
- threads,
333
- currentThreadId: threadStore.currentId,
334
- };
335
- }
336
-
337
- #spawnForkedThread(parentId, promptText) {
338
- const { threadStore } = this.#deps;
339
- if (!threadStore.create) return null;
340
- // Short label from first non-empty line, capped at 40 chars.
341
- const firstLine = (promptText || '').split(/\r?\n/).find(l => l.trim()) || 'fork';
342
- const name = firstLine.trim().slice(0, 40);
343
- try {
344
- return threadStore.create({ name, parentThreadId: parentId });
345
- } catch {
346
- return null;
347
- }
348
- }
349
-
350
- #listPendingFeatures() {
351
- // Best-effort: the FeatureStore is a singleton initialised in loadSession().
352
- // If the store isn't available (e.g. unit tests without a session) we
353
- // just return []. Never let a FeatureStore exception break routing.
354
- try {
355
- const store = getFeatureStore();
356
- if (!store || typeof store.list !== 'function') return [];
357
- const pending = store.list({ status: 'pending' }) || [];
358
- return pending.map(t => ({
359
- id: t.id,
360
- title: t.title || '',
361
- threadId: t.threadId || null,
362
- }));
363
- } catch { /* ignore */ }
364
- return [];
365
- }
366
187
  }
367
188
 
368
189
  /**
369
- * Build a Dispatcher from session-level deps.
190
+ * Build a single-thread Dispatcher from session-level deps.
370
191
  * @param {DispatcherDeps} deps
371
192
  * @returns {Dispatcher}
372
193
  */
package/unify/session.js CHANGED
@@ -29,7 +29,10 @@ import { Engine } from './engine.js';
29
29
  import { createThreadEngineRegistry } from './threads/engine-registry.js';
30
30
  import { MAIN_THREAD_ID } from './threads/store.js';
31
31
  import { getThreadStore } from './threads/store.js';
32
- import { createIntentClassifier } from './router/intent-classifier.js';
32
+ // H2.f.1: intent-classifier (LLM router) is retired. Memory recall now
33
+ // runs through pre-flow (memory/preflow.js) + post-turn adjustMemory
34
+ // (memory/adjust.js); the dispatcher routes every input to the single
35
+ // MAIN_THREAD_ID engine instance.
33
36
  import { initInputQueueStore } from './input-queue/store.js';
34
37
  import { createDispatcher } from './pipeline/dispatcher.js';
35
38
  import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
@@ -360,24 +363,22 @@ export async function loadSession(options = {}) {
360
363
  // Seed the main-thread instance so listActive() is non-empty from T=0.
361
364
  engineRegistry.ensure(MAIN_THREAD_ID);
362
365
 
363
- // task-309 Phase 2 router: intent classifier that routes incoming user
364
- // messages to the right EngineInstance. Shares the same adapter/trace/
365
- // config as the engines so it can use primaryModel for classification.
366
- const router = createIntentClassifier({ adapter, trace, config });
366
+ // H2.f.1: the LLM intent-classifier is retired. The dispatcher now
367
+ // unconditionally routes every input to the MAIN_THREAD_ID engine
368
+ // instance. Memory recall happens via memory/preflow.js (pre-turn)
369
+ // and memory/adjust.js (post-turn).
367
370
 
368
371
  // task-310 Phase 2 integration: wire InputQueue + Dispatcher so the
369
372
  // web-bridge can submit `unify_chat` inputs through the unified pipeline
370
- // (queue → router → engineRegistry → EngineInstance). In read-only mode
371
- // the queue is memory-only (no disk writes).
373
+ // (queue → engineRegistry → EngineInstance). In read-only mode the
374
+ // queue is memory-only (no disk writes).
372
375
  const inputQueue = initInputQueueStore({
373
376
  yeaftDir: config._readOnly ? null : yeaftDir,
374
377
  force: true,
375
378
  });
376
379
  const dispatcher = createDispatcher({
377
380
  inputQueue,
378
- router,
379
381
  engineRegistry,
380
- threadStore: getThreadStore(),
381
382
  trace,
382
383
  });
383
384
 
@@ -416,7 +417,10 @@ export async function loadSession(options = {}) {
416
417
  return {
417
418
  engine,
418
419
  engineRegistry,
419
- router,
420
+ // H2.f.1: `router` removed (intent classifier retired). Kept the
421
+ // property as `null` for any caller doing back-compat existence
422
+ // checks; the dispatcher now always routes to MAIN_THREAD_ID.
423
+ router: null,
420
424
  inputQueue,
421
425
  dispatcher,
422
426
  adapter,