@yeaft/webchat-agent 0.1.648 → 0.1.651
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/dream-v2/prompts/index.js +26 -0
- package/unify/pipeline/dispatcher.js +39 -218
- package/unify/session.js +14 -10
- package/unify/router/intent-classifier.js +0 -444
package/package.json
CHANGED
|
@@ -17,8 +17,34 @@ const FILES = {
|
|
|
17
17
|
triagePass2: 'triage-pass2.md',
|
|
18
18
|
update: 'update.md',
|
|
19
19
|
create: 'create.md',
|
|
20
|
+
// H2.e — per-scope segment extraction prompts (one per scope family)
|
|
21
|
+
extractUser: 'extract-user.md',
|
|
22
|
+
extractVp: 'extract-vp.md',
|
|
23
|
+
extractGroup: 'extract-group.md',
|
|
24
|
+
extractFeature: 'extract-feature.md',
|
|
25
|
+
extractTopic: 'extract-topic.md',
|
|
26
|
+
// H2.e — per-scope summary compression
|
|
27
|
+
summarizeScope: 'summarize-scope.md',
|
|
20
28
|
};
|
|
21
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Map a scope string (e.g. "user", "vp/alice", "topic/auth/jwt") to the
|
|
32
|
+
* extraction template name. Unknown scopes fall back to `extractTopic`
|
|
33
|
+
* (the most generic template) so we never throw at extraction time.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} scope
|
|
36
|
+
* @returns {keyof typeof FILES}
|
|
37
|
+
*/
|
|
38
|
+
export function extractTemplateForScope(scope) {
|
|
39
|
+
if (!scope || typeof scope !== 'string') return 'extractTopic';
|
|
40
|
+
if (scope === 'user') return 'extractUser';
|
|
41
|
+
if (scope.startsWith('vp/')) return 'extractVp';
|
|
42
|
+
if (scope.startsWith('group/')) return 'extractGroup';
|
|
43
|
+
if (scope.startsWith('feature/')) return 'extractFeature';
|
|
44
|
+
if (scope.startsWith('topic/')) return 'extractTopic';
|
|
45
|
+
return 'extractTopic';
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
/** @type {Record<string, string>} */
|
|
23
49
|
const cache = {};
|
|
24
50
|
|
|
@@ -1,104 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pipeline/dispatcher.js —
|
|
2
|
+
* pipeline/dispatcher.js — H2.f.1 single-thread dispatcher.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
* ┌──────────────┐
|
|
16
|
+
* ┌──────────────┐
|
|
10
17
|
* │ InputQueue │ persistent FIFO of pending user inputs
|
|
11
18
|
* └──────┬───────┘
|
|
12
|
-
* │ claim()
|
|
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
|
-
* │
|
|
22
|
+
* │ EngineReg. │ ensure(MAIN_THREAD_ID) → EngineInstance
|
|
26
23
|
* └──────┬───────┘
|
|
27
|
-
* │
|
|
24
|
+
* │ inst.query({ prompt })
|
|
28
25
|
* ▼
|
|
29
|
-
* web-bridge forwards
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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,
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* @type {WeakMap<object, {messageId?: 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]
|
|
93
|
-
* @property {
|
|
94
|
-
*
|
|
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,
|
|
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.
|
|
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
|
|
192
|
-
*
|
|
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,
|
|
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:
|
|
219
|
-
|
|
220
|
-
const
|
|
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:
|
|
267
|
-
targetThreadId
|
|
268
|
-
source:
|
|
269
|
-
reason:
|
|
157
|
+
action: 'continue',
|
|
158
|
+
targetThreadId,
|
|
159
|
+
source: 'single-thread',
|
|
160
|
+
reason: 'single-thread-dispatcher',
|
|
270
161
|
};
|
|
271
162
|
|
|
272
|
-
// ── Step
|
|
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
|
-
|
|
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
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
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 →
|
|
371
|
-
//
|
|
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,
|
|
@@ -1,444 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* intent-classifier.js — task-309 (Phase 2 Router).
|
|
3
|
-
*
|
|
4
|
-
* Routes an incoming user message to one of four intents relative to the
|
|
5
|
-
* current set of live threads + pending features:
|
|
6
|
-
*
|
|
7
|
-
* - 'continue' — append to currentThreadId (default / most common)
|
|
8
|
-
* - 'interrupt' — steal focus on another LIVE thread (e.g. user replies
|
|
9
|
-
* while another thread is mid-stream)
|
|
10
|
-
* - 'fork' — spawn a NEW thread from the current one
|
|
11
|
-
* - 'switch' — re-focus on a different existing thread
|
|
12
|
-
*
|
|
13
|
-
* ### Routing pipeline
|
|
14
|
-
*
|
|
15
|
-
* 1. **Explicit signal parse** (no LLM):
|
|
16
|
-
* - Prefix `@thread-<id>` → switch/interrupt that thread (direct).
|
|
17
|
-
* - Prefix `@feat-<nnn>` → switch to the thread attached to that task,
|
|
18
|
-
* if any; otherwise fall through to LLM.
|
|
19
|
-
* 2. **User override lookup**: if UI previously called `.override(msgId,…)`
|
|
20
|
-
* for this message, return that decision verbatim.
|
|
21
|
-
* 3. **LLM classification**: one call to `primaryModel` (Q2 — router also
|
|
22
|
-
* uses primary; fast-model route disabled for this phase) with a small
|
|
23
|
-
* JSON-only prompt. Parse `{action, targetThreadId, reason}`.
|
|
24
|
-
* 4. **Fallback**: on ANY exception, unknown action, or unknown
|
|
25
|
-
* targetThreadId → degrade to `continue` on the current thread and
|
|
26
|
-
* record a `router.failure` trace event.
|
|
27
|
-
*
|
|
28
|
-
* ### Out of scope (task-310)
|
|
29
|
-
*
|
|
30
|
-
* - user_input_queue storage of pending messages.
|
|
31
|
-
* - Actual dispatch to an EngineInstance (the router just decides WHERE;
|
|
32
|
-
* task-310 owns the WHO/WHEN).
|
|
33
|
-
* - Concurrent stream flush-back semantics.
|
|
34
|
-
*
|
|
35
|
-
* This module only exposes `classify()` + `override()`; the caller owns the
|
|
36
|
-
* registry routing after the decision is returned.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* @typedef {'continue'|'interrupt'|'fork'|'switch'} RouterAction
|
|
41
|
-
*
|
|
42
|
-
* @typedef {Object} RouterDecision
|
|
43
|
-
* @property {RouterAction} action
|
|
44
|
-
* @property {string} targetThreadId — resolved thread (always defined; for
|
|
45
|
-
* 'fork' this is the PARENT thread, the actual new-thread id is chosen
|
|
46
|
-
* by the caller when it creates the thread)
|
|
47
|
-
* @property {string} reason — short human-readable explanation
|
|
48
|
-
* @property {'explicit'|'override'|'llm'|'fallback'} [source]
|
|
49
|
-
*
|
|
50
|
-
* @typedef {Object} ThreadSummary — minimum info the classifier needs
|
|
51
|
-
* @property {string} id
|
|
52
|
-
* @property {string} [name]
|
|
53
|
-
* @property {string} [goal]
|
|
54
|
-
* @property {string} [status]
|
|
55
|
-
*
|
|
56
|
-
* @typedef {Object} PendingFeature
|
|
57
|
-
* @property {string} id
|
|
58
|
-
* @property {string} [title]
|
|
59
|
-
* @property {string} [threadId] — attached thread, if any
|
|
60
|
-
* @property {string} [status]
|
|
61
|
-
*
|
|
62
|
-
* @typedef {Object} ClassifyInput
|
|
63
|
-
* @property {string} userMessage
|
|
64
|
-
* @property {string} currentThreadId
|
|
65
|
-
* @property {Array<ThreadSummary>} [allThreads]
|
|
66
|
-
* @property {Array<PendingFeature>} [pendingFeatures]
|
|
67
|
-
* @property {string} [messageId] — if provided, any stored override for this
|
|
68
|
-
* id is consulted before the LLM path
|
|
69
|
-
*/
|
|
70
|
-
|
|
71
|
-
const VALID_ACTIONS = ['continue', 'interrupt', 'fork', 'switch'];
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Match a leading `@thread-xxx` marker. Captures the thread id WITHOUT the
|
|
75
|
-
* `@` prefix. Case-sensitive (thread ids are canonical).
|
|
76
|
-
* Example matches: "@thread-main ...", "@thread-abcd1234 ..."
|
|
77
|
-
*/
|
|
78
|
-
const THREAD_PREFIX_RE = /^@(thread-[A-Za-z0-9_-]+)\b\s*/;
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Match a leading `@feat-NNN` marker. Captures the feature id WITHOUT the `@`.
|
|
82
|
-
* Example matches: "@feat-309 ...", "@feat-abc ..."
|
|
83
|
-
*/
|
|
84
|
-
const FEATURE_PREFIX_RE = /^@(feat-[A-Za-z0-9_-]+)\b\s*/;
|
|
85
|
-
|
|
86
|
-
export class IntentClassifier {
|
|
87
|
-
/** @type {object} */ #adapter;
|
|
88
|
-
/** @type {object} */ #trace;
|
|
89
|
-
/** @type {object} */ #config;
|
|
90
|
-
/** @type {Map<string, RouterDecision>} */ #overrides;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* @param {{
|
|
94
|
-
* adapter: object,
|
|
95
|
-
* trace?: object,
|
|
96
|
-
* config: object,
|
|
97
|
-
* }} deps
|
|
98
|
-
*/
|
|
99
|
-
constructor({ adapter, trace, config } = {}) {
|
|
100
|
-
if (!adapter || typeof adapter.stream !== 'function') {
|
|
101
|
-
throw new Error('IntentClassifier: adapter with .stream() is required');
|
|
102
|
-
}
|
|
103
|
-
if (!config || typeof config !== 'object') {
|
|
104
|
-
throw new Error('IntentClassifier: config is required');
|
|
105
|
-
}
|
|
106
|
-
this.#adapter = adapter;
|
|
107
|
-
this.#trace = trace || null;
|
|
108
|
-
this.#config = config;
|
|
109
|
-
this.#overrides = new Map();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Store a user correction for a specific messageId. Next `.classify()`
|
|
114
|
-
* call with the matching `messageId` will return this decision verbatim
|
|
115
|
-
* (and consume it, so a second call re-enters normal routing).
|
|
116
|
-
*
|
|
117
|
-
* Used by the UI "不对,我是问 X" affordance.
|
|
118
|
-
*
|
|
119
|
-
* @param {string} messageId
|
|
120
|
-
* @param {{ action: RouterAction, targetThreadId: string, reason?: string }} decision
|
|
121
|
-
* @returns {void}
|
|
122
|
-
*/
|
|
123
|
-
override(messageId, decision) {
|
|
124
|
-
if (!messageId || typeof messageId !== 'string') {
|
|
125
|
-
throw new Error('IntentClassifier.override: messageId required');
|
|
126
|
-
}
|
|
127
|
-
if (!decision || !VALID_ACTIONS.includes(decision.action)) {
|
|
128
|
-
throw new Error(`IntentClassifier.override: invalid action ${decision && decision.action}`);
|
|
129
|
-
}
|
|
130
|
-
if (!decision.targetThreadId || typeof decision.targetThreadId !== 'string') {
|
|
131
|
-
throw new Error('IntentClassifier.override: targetThreadId required');
|
|
132
|
-
}
|
|
133
|
-
this.#overrides.set(messageId, {
|
|
134
|
-
action: decision.action,
|
|
135
|
-
targetThreadId: decision.targetThreadId,
|
|
136
|
-
reason: decision.reason || 'user_override',
|
|
137
|
-
source: 'override',
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Whether an override is currently stored for a given messageId. */
|
|
142
|
-
hasOverride(messageId) {
|
|
143
|
-
return this.#overrides.has(messageId);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Test-only / admin — drop all stored overrides. */
|
|
147
|
-
clearOverrides() {
|
|
148
|
-
this.#overrides.clear();
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Classify the user message into a router decision.
|
|
153
|
-
*
|
|
154
|
-
* Resolution order: override → explicit @prefix → LLM → fallback.
|
|
155
|
-
*
|
|
156
|
-
* @param {ClassifyInput} input
|
|
157
|
-
* @returns {Promise<RouterDecision>}
|
|
158
|
-
*/
|
|
159
|
-
async classify(input) {
|
|
160
|
-
const {
|
|
161
|
-
userMessage,
|
|
162
|
-
currentThreadId,
|
|
163
|
-
allThreads = [],
|
|
164
|
-
pendingFeatures = [],
|
|
165
|
-
messageId,
|
|
166
|
-
} = input || {};
|
|
167
|
-
|
|
168
|
-
if (!userMessage || typeof userMessage !== 'string') {
|
|
169
|
-
throw new Error('classify: userMessage is required');
|
|
170
|
-
}
|
|
171
|
-
if (!currentThreadId || typeof currentThreadId !== 'string') {
|
|
172
|
-
throw new Error('classify: currentThreadId is required');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// 1. User override takes precedence over everything else.
|
|
176
|
-
if (messageId && this.#overrides.has(messageId)) {
|
|
177
|
-
const decision = this.#overrides.get(messageId);
|
|
178
|
-
this.#overrides.delete(messageId);
|
|
179
|
-
return { ...decision, source: 'override' };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// 2. Explicit signals (no LLM call).
|
|
183
|
-
const explicit = this.#parseExplicit(userMessage, {
|
|
184
|
-
currentThreadId, allThreads, pendingFeatures,
|
|
185
|
-
});
|
|
186
|
-
if (explicit) return explicit;
|
|
187
|
-
|
|
188
|
-
// 3. LLM classification (best-effort).
|
|
189
|
-
try {
|
|
190
|
-
const decision = await this.#classifyWithLLM({
|
|
191
|
-
userMessage, currentThreadId, allThreads, pendingFeatures,
|
|
192
|
-
});
|
|
193
|
-
return this.#validateOrFallback(decision, {
|
|
194
|
-
currentThreadId, allThreads, reason: 'llm',
|
|
195
|
-
});
|
|
196
|
-
} catch (err) {
|
|
197
|
-
this.#traceFailure(err, { userMessage, currentThreadId });
|
|
198
|
-
return this.#fallback(currentThreadId, `classifier_exception: ${err.message}`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ──────────────────────────────────────────────────────────────
|
|
203
|
-
// Explicit-signal parser
|
|
204
|
-
// ──────────────────────────────────────────────────────────────
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* @param {string} msg
|
|
208
|
-
* @param {{ currentThreadId: string, allThreads: Array<ThreadSummary>, pendingFeatures: Array<PendingFeature> }} ctx
|
|
209
|
-
* @returns {RouterDecision|null}
|
|
210
|
-
*/
|
|
211
|
-
#parseExplicit(msg, { currentThreadId, allThreads, pendingFeatures }) {
|
|
212
|
-
const trimmed = msg.replace(/^\s+/, '');
|
|
213
|
-
|
|
214
|
-
// @thread-xxx
|
|
215
|
-
const tm = trimmed.match(THREAD_PREFIX_RE);
|
|
216
|
-
if (tm) {
|
|
217
|
-
const targetId = tm[1];
|
|
218
|
-
const known = allThreads.some(t => t && t.id === targetId);
|
|
219
|
-
if (!known) {
|
|
220
|
-
// Unknown thread — silently degrade. Record trace so ops can see it.
|
|
221
|
-
this.#traceFailure(
|
|
222
|
-
new Error(`unknown thread in @prefix: ${targetId}`),
|
|
223
|
-
{ userMessage: msg, currentThreadId },
|
|
224
|
-
);
|
|
225
|
-
return this.#fallback(currentThreadId, `unknown_thread:${targetId}`);
|
|
226
|
-
}
|
|
227
|
-
const action = targetId === currentThreadId ? 'continue' : 'switch';
|
|
228
|
-
return {
|
|
229
|
-
action,
|
|
230
|
-
targetThreadId: targetId,
|
|
231
|
-
reason: `explicit @${targetId}`,
|
|
232
|
-
source: 'explicit',
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// @feat-NNN
|
|
237
|
-
const mt = trimmed.match(FEATURE_PREFIX_RE);
|
|
238
|
-
if (mt) {
|
|
239
|
-
const featureId = mt[1];
|
|
240
|
-
const feature = pendingFeatures.find(t => t && t.id === featureId);
|
|
241
|
-
if (feature && feature.threadId) {
|
|
242
|
-
const action = feature.threadId === currentThreadId ? 'continue' : 'switch';
|
|
243
|
-
return {
|
|
244
|
-
action,
|
|
245
|
-
targetThreadId: feature.threadId,
|
|
246
|
-
reason: `explicit @${featureId} → ${feature.threadId}`,
|
|
247
|
-
source: 'explicit',
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
// Feature unknown or not attached — fall through to LLM.
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ──────────────────────────────────────────────────────────────
|
|
258
|
-
// LLM classification path
|
|
259
|
-
// ──────────────────────────────────────────────────────────────
|
|
260
|
-
|
|
261
|
-
/** Build the prompt/messages for the router LLM call. */
|
|
262
|
-
#buildMessages({ userMessage, currentThreadId, allThreads, pendingFeatures }) {
|
|
263
|
-
const system = [
|
|
264
|
-
'You are a thread-routing classifier for a multi-thread AI chat.',
|
|
265
|
-
'Given the user message and current thread context, pick exactly one action:',
|
|
266
|
-
" - 'continue' — the message belongs to the current thread",
|
|
267
|
-
" - 'interrupt' — it answers / redirects a DIFFERENT live thread",
|
|
268
|
-
" - 'fork' — it starts a new tangent that should be its own thread",
|
|
269
|
-
" - 'switch' — it explicitly re-focuses on another existing thread",
|
|
270
|
-
'',
|
|
271
|
-
'Respond with ONE LINE of JSON, nothing else:',
|
|
272
|
-
'{"action":"<action>","targetThreadId":"<id>","reason":"<short>"}',
|
|
273
|
-
'',
|
|
274
|
-
'Rules:',
|
|
275
|
-
'- For fork, set targetThreadId to the CURRENT thread (it is the parent).',
|
|
276
|
-
'- For continue, set targetThreadId to the CURRENT thread.',
|
|
277
|
-
'- For switch/interrupt, targetThreadId MUST be one of the known thread ids.',
|
|
278
|
-
'- If uncertain, pick continue.',
|
|
279
|
-
].join('\n');
|
|
280
|
-
|
|
281
|
-
const ctx = {
|
|
282
|
-
currentThreadId,
|
|
283
|
-
threads: (allThreads || []).map(t => ({
|
|
284
|
-
id: t.id,
|
|
285
|
-
name: t.name || '',
|
|
286
|
-
goal: t.goal || '',
|
|
287
|
-
status: t.status || 'active',
|
|
288
|
-
})),
|
|
289
|
-
pendingFeatures: (pendingFeatures || []).map(t => ({
|
|
290
|
-
id: t.id,
|
|
291
|
-
title: t.title || '',
|
|
292
|
-
threadId: t.threadId || null,
|
|
293
|
-
})),
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
const user = [
|
|
297
|
-
'Context:',
|
|
298
|
-
JSON.stringify(ctx),
|
|
299
|
-
'',
|
|
300
|
-
'User message:',
|
|
301
|
-
userMessage,
|
|
302
|
-
].join('\n');
|
|
303
|
-
|
|
304
|
-
return { system, messages: [{ role: 'user', content: user }] };
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/** @returns {Promise<RouterDecision>} */
|
|
308
|
-
async #classifyWithLLM({ userMessage, currentThreadId, allThreads, pendingFeatures }) {
|
|
309
|
-
const { system, messages } = this.#buildMessages({
|
|
310
|
-
userMessage, currentThreadId, allThreads, pendingFeatures,
|
|
311
|
-
});
|
|
312
|
-
// Q2: router uses primaryModel (no fast-model split yet).
|
|
313
|
-
const model = this.#config.primaryModel || this.#config.model;
|
|
314
|
-
if (!model) {
|
|
315
|
-
throw new Error('router: no primaryModel configured');
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
let text = '';
|
|
319
|
-
for await (const event of this.#adapter.stream({
|
|
320
|
-
model,
|
|
321
|
-
system,
|
|
322
|
-
messages,
|
|
323
|
-
maxTokens: 256,
|
|
324
|
-
})) {
|
|
325
|
-
if (event && event.type === 'text_delta' && typeof event.text === 'string') {
|
|
326
|
-
text += event.text;
|
|
327
|
-
} else if (event && event.type === 'error') {
|
|
328
|
-
throw event.error || new Error('router stream error');
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
return parseLLMDecision(text);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// ──────────────────────────────────────────────────────────────
|
|
335
|
-
// Validation & fallback
|
|
336
|
-
// ──────────────────────────────────────────────────────────────
|
|
337
|
-
|
|
338
|
-
#validateOrFallback(decision, { currentThreadId, allThreads, reason }) {
|
|
339
|
-
if (!decision || !VALID_ACTIONS.includes(decision.action)) {
|
|
340
|
-
this.#traceFailure(
|
|
341
|
-
new Error(`invalid action from classifier: ${decision && decision.action}`),
|
|
342
|
-
{ currentThreadId },
|
|
343
|
-
);
|
|
344
|
-
return this.#fallback(currentThreadId, 'invalid_action');
|
|
345
|
-
}
|
|
346
|
-
const known = new Set((allThreads || []).map(t => t && t.id).filter(Boolean));
|
|
347
|
-
known.add(currentThreadId);
|
|
348
|
-
|
|
349
|
-
// For fork/continue the target MUST be the current thread parent (we
|
|
350
|
-
// allow any known thread since callers may want to fork from a
|
|
351
|
-
// non-current parent, but continue MUST land on current).
|
|
352
|
-
if (decision.action === 'continue' && decision.targetThreadId !== currentThreadId) {
|
|
353
|
-
decision.targetThreadId = currentThreadId;
|
|
354
|
-
}
|
|
355
|
-
if (!decision.targetThreadId || !known.has(decision.targetThreadId)) {
|
|
356
|
-
this.#traceFailure(
|
|
357
|
-
new Error(`unknown targetThreadId: ${decision.targetThreadId}`),
|
|
358
|
-
{ currentThreadId },
|
|
359
|
-
);
|
|
360
|
-
return this.#fallback(currentThreadId, 'unknown_target');
|
|
361
|
-
}
|
|
362
|
-
return {
|
|
363
|
-
action: decision.action,
|
|
364
|
-
targetThreadId: decision.targetThreadId,
|
|
365
|
-
reason: decision.reason || reason,
|
|
366
|
-
source: 'llm',
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** @returns {RouterDecision} */
|
|
371
|
-
#fallback(currentThreadId, reason) {
|
|
372
|
-
return {
|
|
373
|
-
action: 'continue',
|
|
374
|
-
targetThreadId: currentThreadId,
|
|
375
|
-
reason,
|
|
376
|
-
source: 'fallback',
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
#traceFailure(err, ctx) {
|
|
381
|
-
if (!this.#trace || typeof this.#trace.logEvent !== 'function') return;
|
|
382
|
-
try {
|
|
383
|
-
this.#trace.logEvent({
|
|
384
|
-
traceId: 'router',
|
|
385
|
-
eventType: 'router.failure',
|
|
386
|
-
eventData: {
|
|
387
|
-
error: err && err.message ? err.message : String(err),
|
|
388
|
-
currentThreadId: ctx && ctx.currentThreadId,
|
|
389
|
-
userMessage: ctx && typeof ctx.userMessage === 'string'
|
|
390
|
-
? ctx.userMessage.slice(0, 200)
|
|
391
|
-
: undefined,
|
|
392
|
-
},
|
|
393
|
-
});
|
|
394
|
-
} catch {
|
|
395
|
-
// Trace must never propagate errors into the router path.
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Parse the LLM's single-line JSON response. Tolerates a leading/trailing
|
|
402
|
-
* code fence ```json ... ``` because some proxies wrap.
|
|
403
|
-
*
|
|
404
|
-
* @param {string} raw
|
|
405
|
-
* @returns {RouterDecision}
|
|
406
|
-
*/
|
|
407
|
-
export function parseLLMDecision(raw) {
|
|
408
|
-
if (!raw || typeof raw !== 'string') {
|
|
409
|
-
throw new Error('empty classifier response');
|
|
410
|
-
}
|
|
411
|
-
let text = raw.trim();
|
|
412
|
-
// Strip ```json fences if present.
|
|
413
|
-
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
414
|
-
if (fenced) text = fenced[1].trim();
|
|
415
|
-
// Take the first { ... } block.
|
|
416
|
-
const start = text.indexOf('{');
|
|
417
|
-
const end = text.lastIndexOf('}');
|
|
418
|
-
if (start < 0 || end <= start) {
|
|
419
|
-
throw new Error('no JSON object in classifier response');
|
|
420
|
-
}
|
|
421
|
-
const slice = text.slice(start, end + 1);
|
|
422
|
-
let obj;
|
|
423
|
-
try {
|
|
424
|
-
obj = JSON.parse(slice);
|
|
425
|
-
} catch (e) {
|
|
426
|
-
throw new Error(`classifier response not valid JSON: ${e.message}`);
|
|
427
|
-
}
|
|
428
|
-
return {
|
|
429
|
-
action: obj.action,
|
|
430
|
-
targetThreadId: obj.targetThreadId,
|
|
431
|
-
reason: obj.reason || '',
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Build an IntentClassifier from session-level deps. This is the entry
|
|
437
|
-
* point used by session.js to populate `session.router`.
|
|
438
|
-
*
|
|
439
|
-
* @param {{ adapter: object, trace?: object, config: object }} deps
|
|
440
|
-
* @returns {IntentClassifier}
|
|
441
|
-
*/
|
|
442
|
-
export function createIntentClassifier(deps) {
|
|
443
|
-
return new IntentClassifier(deps);
|
|
444
|
-
}
|