@yeaft/webchat-agent 0.1.481 → 0.1.483
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/conversation/migrate-messages-threadid.js +116 -0
- package/unify/conversation/persist.js +8 -0
- package/unify/input-queue/store.js +322 -0
- package/unify/session.js +27 -0
- package/unify/threads/engine-instance.js +218 -0
- package/unify/threads/engine-registry.js +192 -0
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate-messages-threadid.js — task-307 one-shot migration.
|
|
3
|
+
*
|
|
4
|
+
* For every message under <yeaftDir>/conversation/messages/ and cold/ that
|
|
5
|
+
* does NOT yet carry a `threadId:` frontmatter field, stamp it with
|
|
6
|
+
* `threadId: main`. This matches design §5 semantics: pre-Phase-2 messages
|
|
7
|
+
* predate threading and belong to the root conversation.
|
|
8
|
+
*
|
|
9
|
+
* Idempotency:
|
|
10
|
+
* A marker file `<yeaftDir>/conversation/.migrations/messagesThreadId`
|
|
11
|
+
* is written on successful completion. Subsequent runs observe the
|
|
12
|
+
* marker and short-circuit with `{ ran: false }`.
|
|
13
|
+
*
|
|
14
|
+
* Safety:
|
|
15
|
+
* - Messages that already carry a threadId are left untouched.
|
|
16
|
+
* - If a file is unreadable / unparseable it is skipped (never rewritten).
|
|
17
|
+
* - Write errors are swallowed so a half-successful migration can resume
|
|
18
|
+
* on the next boot by re-running.
|
|
19
|
+
*
|
|
20
|
+
* Usage (programmatic):
|
|
21
|
+
* import { migrateMessagesThreadId } from './migrate-messages-threadid.js';
|
|
22
|
+
* const res = migrateMessagesThreadId('/home/me/.yeaft');
|
|
23
|
+
* // { ran: true, migrated: 42, skipped: 3 }
|
|
24
|
+
*
|
|
25
|
+
* Usage (CLI):
|
|
26
|
+
* node agent/unify/conversation/migrate-messages-threadid.js [yeaftDir]
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
existsSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from 'fs';
|
|
36
|
+
import { join } from 'path';
|
|
37
|
+
import { homedir } from 'os';
|
|
38
|
+
|
|
39
|
+
export function migrateMessagesThreadId(yeaftDir) {
|
|
40
|
+
const root = yeaftDir || join(homedir(), '.yeaft');
|
|
41
|
+
const convDir = join(root, 'conversation');
|
|
42
|
+
const markerDir = join(convDir, '.migrations');
|
|
43
|
+
const markerPath = join(markerDir, 'messagesThreadId');
|
|
44
|
+
|
|
45
|
+
if (!existsSync(convDir)) {
|
|
46
|
+
return { ran: false, reason: 'no conversation dir', migrated: 0, skipped: 0 };
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
if (existsSync(markerPath)) {
|
|
50
|
+
return { ran: false, reason: 'already migrated', migrated: 0, skipped: 0 };
|
|
51
|
+
}
|
|
52
|
+
} catch { /* best-effort */ }
|
|
53
|
+
|
|
54
|
+
const dirs = [join(convDir, 'messages'), join(convDir, 'cold')];
|
|
55
|
+
let migrated = 0;
|
|
56
|
+
let skipped = 0;
|
|
57
|
+
|
|
58
|
+
for (const dir of dirs) {
|
|
59
|
+
if (!existsSync(dir)) continue;
|
|
60
|
+
let files;
|
|
61
|
+
try {
|
|
62
|
+
files = readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
63
|
+
} catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const f of files) {
|
|
67
|
+
const path = join(dir, f);
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = readFileSync(path, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
skipped += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!raw || !raw.startsWith('---')) { skipped += 1; continue; }
|
|
76
|
+
const endIdx = raw.indexOf('\n---', 3);
|
|
77
|
+
if (endIdx === -1) { skipped += 1; continue; }
|
|
78
|
+
const frontmatter = raw.slice(4, endIdx);
|
|
79
|
+
const body = raw.slice(endIdx); // leading '\n---' ...
|
|
80
|
+
if (/^threadId:/m.test(frontmatter)) {
|
|
81
|
+
skipped += 1;
|
|
82
|
+
continue; // already has threadId
|
|
83
|
+
}
|
|
84
|
+
const newFm = frontmatter.replace(/\s*$/, '') + '\nthreadId: main';
|
|
85
|
+
const rebuilt = '---\n' + newFm.trimStart() + body;
|
|
86
|
+
try {
|
|
87
|
+
writeFileSync(path, rebuilt, { encoding: 'utf8', mode: 0o644 });
|
|
88
|
+
migrated += 1;
|
|
89
|
+
} catch {
|
|
90
|
+
skipped += 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(markerDir, { recursive: true, mode: 0o755 });
|
|
97
|
+
writeFileSync(
|
|
98
|
+
markerPath,
|
|
99
|
+
`migrated: ${new Date().toISOString()}\ncount: ${migrated}\nskipped: ${skipped}\n`,
|
|
100
|
+
{ encoding: 'utf8', mode: 0o644 },
|
|
101
|
+
);
|
|
102
|
+
} catch {
|
|
103
|
+
// Without the marker the migration will re-run; it's idempotent per-file
|
|
104
|
+
// (files already carrying threadId are skipped) so that's acceptable.
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { ran: true, migrated, skipped };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// CLI entry point.
|
|
111
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
112
|
+
const dir = process.argv[2];
|
|
113
|
+
const res = migrateMessagesThreadId(dir);
|
|
114
|
+
// eslint-disable-next-line no-console
|
|
115
|
+
console.log(JSON.stringify(res, null, 2));
|
|
116
|
+
}
|
|
@@ -56,6 +56,10 @@ function serializeMessage(msg) {
|
|
|
56
56
|
if (msg.turnNumber != null) fm.push(`turnNumber: ${msg.turnNumber}`);
|
|
57
57
|
if (msg.toolCallId) fm.push(`toolCallId: ${msg.toolCallId}`);
|
|
58
58
|
if (msg.isError) fm.push(`isError: true`);
|
|
59
|
+
// task-307: every message is stamped with a threadId so multi-thread
|
|
60
|
+
// routing can filter/replay by thread without rescanning JSON blobs.
|
|
61
|
+
// Defaults to 'main' for legacy messages (see migrate-messages-threadid.js).
|
|
62
|
+
fm.push(`threadId: ${msg.threadId || 'main'}`);
|
|
59
63
|
|
|
60
64
|
// Token estimate
|
|
61
65
|
const content = msg.content || '';
|
|
@@ -111,10 +115,14 @@ export function parseMessage(raw) {
|
|
|
111
115
|
case 'toolCallId': msg.toolCallId = value; break;
|
|
112
116
|
case 'isError': msg.isError = value === 'true'; break;
|
|
113
117
|
case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
|
|
118
|
+
case 'threadId': msg.threadId = value; break;
|
|
114
119
|
// toolCalls are multi-line YAML — handled separately below
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
// task-307: legacy messages written before threadId existed default to 'main'.
|
|
124
|
+
if (!msg.threadId) msg.threadId = 'main';
|
|
125
|
+
|
|
118
126
|
// Parse toolCalls if present (simplified multi-line YAML)
|
|
119
127
|
if (frontmatter.includes('toolCalls:')) {
|
|
120
128
|
const toolCalls = [];
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* input-queue/store.js — Persistent FIFO input queue for Yeaft Unify.
|
|
3
|
+
*
|
|
4
|
+
* task-307b (Phase 2): a disk-backed queue of user inputs waiting to be
|
|
5
|
+
* routed to a thread/engine. Each entry is one JSON file at
|
|
6
|
+
* <yeaftDir>/input-queue/<id>.json
|
|
7
|
+
* so that crashing during a routing decision never loses a queued input.
|
|
8
|
+
*
|
|
9
|
+
* Schema follows design doc §5:
|
|
10
|
+
* {
|
|
11
|
+
* id: 'iq-xxxxxxxx',
|
|
12
|
+
* text: '<the user-typed input>',
|
|
13
|
+
* createdAt: 1234567890123, // epoch ms
|
|
14
|
+
* status: 'pending' | 'routing' | 'dispatched',
|
|
15
|
+
* routedTo: '<threadId>' | null, // set when status === 'dispatched'
|
|
16
|
+
* routedAt: 1234567890456 | null,
|
|
17
|
+
* error: '<message>' | null, // optional — populated when a
|
|
18
|
+
* // routing attempt fails and
|
|
19
|
+
* // the entry is put back to
|
|
20
|
+
* // 'pending' for retry.
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* State machine:
|
|
24
|
+
* enqueue(text)
|
|
25
|
+
* │
|
|
26
|
+
* ▼
|
|
27
|
+
* pending ─────claim()─────► routing ─────markRouted()────► dispatched
|
|
28
|
+
* ▲ │
|
|
29
|
+
* └──────markFailed()──────┘ (status back to pending; error field recorded)
|
|
30
|
+
*
|
|
31
|
+
* - pending : waiting for a dispatcher
|
|
32
|
+
* - routing : a dispatcher has taken responsibility; crash here means
|
|
33
|
+
* boot-time recovery will still see the entry and can
|
|
34
|
+
* re-claim it (the row is still on disk).
|
|
35
|
+
* - dispatched : routed to a thread — the file is removed because the
|
|
36
|
+
* durable audit trail now lives in the messages.
|
|
37
|
+
*
|
|
38
|
+
* API (task-307b per PM):
|
|
39
|
+
* - enqueue(text) → entry Create + persist pending.
|
|
40
|
+
* - dequeue() → entry | null Peek at oldest pending
|
|
41
|
+
* (non-mutating — see
|
|
42
|
+
* claim() for the mutating
|
|
43
|
+
* transition).
|
|
44
|
+
* - claim() → entry | null Oldest pending → 'routing'
|
|
45
|
+
* (atomic w.r.t. disk).
|
|
46
|
+
* - list(status?) → entry[] Snapshot filtered by status.
|
|
47
|
+
* - markRouted(id, routedTo) → entry | null routing → dispatched;
|
|
48
|
+
* file deleted.
|
|
49
|
+
* - markFailed(id, err) → entry | null routing → pending; error
|
|
50
|
+
* recorded; kept on disk.
|
|
51
|
+
* - peek() / get(id) / remove(id) / size() / pendingCount()
|
|
52
|
+
*
|
|
53
|
+
* Persistence:
|
|
54
|
+
* - Writes are synchronous. The queue is a durability boundary, not a
|
|
55
|
+
* hot path (one write per state transition).
|
|
56
|
+
* - Permission/FS failures don't throw: they are swallowed and logged once
|
|
57
|
+
* per process (matches ConversationStore's philosophy).
|
|
58
|
+
* - If `yeaftDir` is omitted, the store operates purely in memory.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { randomUUID } from 'crypto';
|
|
62
|
+
import {
|
|
63
|
+
existsSync,
|
|
64
|
+
mkdirSync,
|
|
65
|
+
readdirSync,
|
|
66
|
+
readFileSync,
|
|
67
|
+
writeFileSync,
|
|
68
|
+
unlinkSync,
|
|
69
|
+
} from 'fs';
|
|
70
|
+
import { join } from 'path';
|
|
71
|
+
|
|
72
|
+
/** All valid status values, per design §5. */
|
|
73
|
+
export const INPUT_QUEUE_STATUSES = ['pending', 'routing', 'dispatched'];
|
|
74
|
+
|
|
75
|
+
export class InputQueueStore {
|
|
76
|
+
/** @type {Map<string, object>} */
|
|
77
|
+
#entries;
|
|
78
|
+
/** @type {string|null} */
|
|
79
|
+
#dir;
|
|
80
|
+
/** @type {boolean} */
|
|
81
|
+
#persistent;
|
|
82
|
+
/** @type {boolean} */
|
|
83
|
+
#warned;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string|null} [yeaftDir]
|
|
87
|
+
*/
|
|
88
|
+
constructor(yeaftDir = null) {
|
|
89
|
+
this.#entries = new Map();
|
|
90
|
+
this.#warned = false;
|
|
91
|
+
|
|
92
|
+
if (yeaftDir) {
|
|
93
|
+
this.#dir = join(yeaftDir, 'input-queue');
|
|
94
|
+
this.#persistent = true;
|
|
95
|
+
this.#ensureDir();
|
|
96
|
+
this.#loadAll();
|
|
97
|
+
} else {
|
|
98
|
+
this.#dir = null;
|
|
99
|
+
this.#persistent = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get persistent() { return this.#persistent; }
|
|
104
|
+
size() { return this.#entries.size; }
|
|
105
|
+
|
|
106
|
+
pendingCount() {
|
|
107
|
+
let n = 0;
|
|
108
|
+
for (const e of this.#entries.values()) if (e.status === 'pending') n += 1;
|
|
109
|
+
return n;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Append a new pending entry and persist it.
|
|
114
|
+
* @param {string} text
|
|
115
|
+
* @returns {object} the entry
|
|
116
|
+
*/
|
|
117
|
+
enqueue(text) {
|
|
118
|
+
if (typeof text !== 'string') throw new Error('text must be a string');
|
|
119
|
+
const id = `iq-${randomUUID().slice(0, 8)}`;
|
|
120
|
+
const entry = {
|
|
121
|
+
id,
|
|
122
|
+
text,
|
|
123
|
+
createdAt: Date.now(),
|
|
124
|
+
status: 'pending',
|
|
125
|
+
routedTo: null,
|
|
126
|
+
routedAt: null,
|
|
127
|
+
error: null,
|
|
128
|
+
};
|
|
129
|
+
this.#entries.set(id, entry);
|
|
130
|
+
this.#writeEntry(entry);
|
|
131
|
+
return entry;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Oldest pending entry (no state change). null if empty. */
|
|
135
|
+
peek() {
|
|
136
|
+
let oldest = null;
|
|
137
|
+
for (const e of this.#entries.values()) {
|
|
138
|
+
if (e.status !== 'pending') continue;
|
|
139
|
+
if (!oldest || e.createdAt < oldest.createdAt) oldest = e;
|
|
140
|
+
}
|
|
141
|
+
return oldest;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Return the oldest pending entry without mutating state. Kept as a
|
|
146
|
+
* separate method from claim() because some consumers only want to
|
|
147
|
+
* observe the head of the queue (e.g. UI preview).
|
|
148
|
+
*/
|
|
149
|
+
dequeue() {
|
|
150
|
+
return this.peek();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Transition the oldest pending entry → 'routing' and persist. This is
|
|
155
|
+
* the real consumer-facing take: after claim() the caller must eventually
|
|
156
|
+
* call markRouted() (success) or markFailed() (→ put back as pending).
|
|
157
|
+
*
|
|
158
|
+
* @returns {object|null} the claimed entry, or null if nothing pending
|
|
159
|
+
*/
|
|
160
|
+
claim() {
|
|
161
|
+
const e = this.peek();
|
|
162
|
+
if (!e) return null;
|
|
163
|
+
e.status = 'routing';
|
|
164
|
+
this.#writeEntry(e);
|
|
165
|
+
return e;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Mark an entry as successfully dispatched to a thread. Persists the
|
|
170
|
+
* updated state then removes the file — the authoritative record now
|
|
171
|
+
* lives in the conversation/messages log.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} id
|
|
174
|
+
* @param {string} routedTo — thread id (e.g. 'main' or 'thr-xxxxxxxx')
|
|
175
|
+
* @returns {object|null}
|
|
176
|
+
*/
|
|
177
|
+
markRouted(id, routedTo) {
|
|
178
|
+
const e = this.#entries.get(id);
|
|
179
|
+
if (!e) return null;
|
|
180
|
+
if (!routedTo || typeof routedTo !== 'string') throw new Error('routedTo required');
|
|
181
|
+
e.status = 'dispatched';
|
|
182
|
+
e.routedTo = routedTo;
|
|
183
|
+
e.routedAt = Date.now();
|
|
184
|
+
// Persist the transition before removing (crash-safe ordering).
|
|
185
|
+
this.#writeEntry(e);
|
|
186
|
+
this.#removeFile(id);
|
|
187
|
+
this.#entries.delete(id);
|
|
188
|
+
return e;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Mark a routing attempt failed. The entry returns to 'pending' so the
|
|
193
|
+
* next claim() can retry it; the error string is retained for diagnostics.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} id
|
|
196
|
+
* @param {string|Error} err
|
|
197
|
+
*/
|
|
198
|
+
markFailed(id, err) {
|
|
199
|
+
const e = this.#entries.get(id);
|
|
200
|
+
if (!e) return null;
|
|
201
|
+
e.status = 'pending';
|
|
202
|
+
e.error = typeof err === 'string' ? err : (err?.message || String(err));
|
|
203
|
+
this.#writeEntry(e);
|
|
204
|
+
return e;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Remove an entry entirely (memory + disk). */
|
|
208
|
+
remove(id) {
|
|
209
|
+
if (!this.#entries.has(id)) return false;
|
|
210
|
+
this.#entries.delete(id);
|
|
211
|
+
this.#removeFile(id);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Snapshot of all entries, optionally filtered by status. Chronological.
|
|
217
|
+
* @param {'pending'|'routing'|'dispatched'} [status]
|
|
218
|
+
*/
|
|
219
|
+
list(status) {
|
|
220
|
+
let arr = [...this.#entries.values()];
|
|
221
|
+
if (status) arr = arr.filter(e => e.status === status);
|
|
222
|
+
arr.sort((a, b) => a.createdAt - b.createdAt);
|
|
223
|
+
return arr;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
get(id) { return this.#entries.get(id) || null; }
|
|
227
|
+
|
|
228
|
+
// ─── Persistence internals ────────────────────────────
|
|
229
|
+
|
|
230
|
+
#ensureDir() {
|
|
231
|
+
try {
|
|
232
|
+
if (!existsSync(this.#dir)) mkdirSync(this.#dir, { recursive: true, mode: 0o755 });
|
|
233
|
+
} catch (err) {
|
|
234
|
+
this.#warn(`Cannot create input-queue dir: ${err?.code || err?.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#loadAll() {
|
|
239
|
+
if (!existsSync(this.#dir)) return;
|
|
240
|
+
let files;
|
|
241
|
+
try {
|
|
242
|
+
files = readdirSync(this.#dir);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
this.#warn(`Cannot read input-queue dir: ${err?.code || err?.message}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
for (const f of files) {
|
|
248
|
+
if (!f.endsWith('.json')) continue;
|
|
249
|
+
const path = join(this.#dir, f);
|
|
250
|
+
try {
|
|
251
|
+
const raw = readFileSync(path, 'utf8');
|
|
252
|
+
const parsed = JSON.parse(raw);
|
|
253
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.id) continue;
|
|
254
|
+
if (!INPUT_QUEUE_STATUSES.includes(parsed.status)) continue;
|
|
255
|
+
// Crash recovery: an entry left in 'routing' at startup had a
|
|
256
|
+
// dispatcher claim it right before the crash. Put it back to
|
|
257
|
+
// 'pending' so the next claim() can retry it.
|
|
258
|
+
const status = parsed.status === 'routing' ? 'pending' : parsed.status;
|
|
259
|
+
this.#entries.set(parsed.id, {
|
|
260
|
+
id: parsed.id,
|
|
261
|
+
text: typeof parsed.text === 'string' ? parsed.text : '',
|
|
262
|
+
createdAt: Number(parsed.createdAt) || Date.now(),
|
|
263
|
+
status,
|
|
264
|
+
routedTo: parsed.routedTo || null,
|
|
265
|
+
routedAt: parsed.routedAt || null,
|
|
266
|
+
error: parsed.error || null,
|
|
267
|
+
});
|
|
268
|
+
} catch {
|
|
269
|
+
// Skip corrupt file.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#writeEntry(entry) {
|
|
275
|
+
if (!this.#persistent) return;
|
|
276
|
+
const path = join(this.#dir, `${entry.id}.json`);
|
|
277
|
+
try {
|
|
278
|
+
writeFileSync(path, JSON.stringify(entry, null, 2), { encoding: 'utf8', mode: 0o644 });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
this.#warn(`Cannot write ${entry.id}: ${err?.code || err?.message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#removeFile(id) {
|
|
285
|
+
if (!this.#persistent) return;
|
|
286
|
+
const path = join(this.#dir, `${id}.json`);
|
|
287
|
+
if (!existsSync(path)) return;
|
|
288
|
+
try {
|
|
289
|
+
unlinkSync(path);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
this.#warn(`Cannot remove ${id}: ${err?.code || err?.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#warn(msg) {
|
|
296
|
+
if (this.#warned) return;
|
|
297
|
+
this.#warned = true;
|
|
298
|
+
// eslint-disable-next-line no-console
|
|
299
|
+
console.warn(`[Yeaft InputQueue] ${msg}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── Singleton helpers ────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
/** @type {InputQueueStore|null} */
|
|
306
|
+
let inputQueueStore = null;
|
|
307
|
+
|
|
308
|
+
export function initInputQueueStore(opts = {}) {
|
|
309
|
+
if (!inputQueueStore || opts.force) {
|
|
310
|
+
inputQueueStore = new InputQueueStore(opts.yeaftDir || null);
|
|
311
|
+
}
|
|
312
|
+
return inputQueueStore;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function getInputQueueStore() {
|
|
316
|
+
if (!inputQueueStore) inputQueueStore = new InputQueueStore(null);
|
|
317
|
+
return inputQueueStore;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function _resetInputQueueStoreForTests() {
|
|
321
|
+
inputQueueStore = null;
|
|
322
|
+
}
|
package/unify/session.js
CHANGED
|
@@ -25,6 +25,8 @@ import { createFullRegistry } from './tools/index.js';
|
|
|
25
25
|
import { initTaskStore } from './tools/task-tools.js';
|
|
26
26
|
import { initThreadStore } from './threads/store.js';
|
|
27
27
|
import { Engine } from './engine.js';
|
|
28
|
+
import { createThreadEngineRegistry } from './threads/engine-registry.js';
|
|
29
|
+
import { MAIN_THREAD_ID } from './threads/store.js';
|
|
28
30
|
import { join } from 'path';
|
|
29
31
|
|
|
30
32
|
/**
|
|
@@ -166,6 +168,25 @@ export async function loadSession(options = {}) {
|
|
|
166
168
|
yeaftDir,
|
|
167
169
|
});
|
|
168
170
|
|
|
171
|
+
// task-308 Phase 2: thread-aware engine registry.
|
|
172
|
+
// Each thread gets its own EngineInstance (lazy-created) that owns its
|
|
173
|
+
// messages array and tags all events with the bound threadId. Legacy
|
|
174
|
+
// single-engine callers keep working via `session.engine`; multi-thread
|
|
175
|
+
// callers use `session.engineRegistry.ensure(threadId)`.
|
|
176
|
+
const engineRegistry = createThreadEngineRegistry({
|
|
177
|
+
adapter,
|
|
178
|
+
trace,
|
|
179
|
+
config,
|
|
180
|
+
conversationStore,
|
|
181
|
+
memoryStore,
|
|
182
|
+
toolRegistry,
|
|
183
|
+
skillManager,
|
|
184
|
+
mcpManager,
|
|
185
|
+
yeaftDir,
|
|
186
|
+
});
|
|
187
|
+
// Seed the main-thread instance so listActive() is non-empty from T=0.
|
|
188
|
+
engineRegistry.ensure(MAIN_THREAD_ID);
|
|
189
|
+
|
|
169
190
|
// ─── 10. Build session ─────────────────────────────────
|
|
170
191
|
const status = {
|
|
171
192
|
skills: skillManager.size,
|
|
@@ -176,6 +197,11 @@ export async function loadSession(options = {}) {
|
|
|
176
197
|
|
|
177
198
|
/** Graceful shutdown: disconnect MCP, close trace DB. */
|
|
178
199
|
async function shutdown() {
|
|
200
|
+
try {
|
|
201
|
+
engineRegistry.terminateAll();
|
|
202
|
+
} catch {
|
|
203
|
+
// Best-effort cleanup
|
|
204
|
+
}
|
|
179
205
|
try {
|
|
180
206
|
await mcpManager.disconnectAll();
|
|
181
207
|
} catch {
|
|
@@ -190,6 +216,7 @@ export async function loadSession(options = {}) {
|
|
|
190
216
|
|
|
191
217
|
return {
|
|
192
218
|
engine,
|
|
219
|
+
engineRegistry,
|
|
193
220
|
adapter,
|
|
194
221
|
config,
|
|
195
222
|
conversationStore,
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine-instance.js — task-308 Phase 2.
|
|
3
|
+
*
|
|
4
|
+
* An EngineInstance binds a single `threadId` to an Engine + an independent
|
|
5
|
+
* per-thread `messages` array + an independent memory scope ref. Multiple
|
|
6
|
+
* EngineInstance objects can run .query() concurrently on the same unify
|
|
7
|
+
* session without cross-contaminating state:
|
|
8
|
+
*
|
|
9
|
+
* - The underlying Engine's query loop is a pure async generator that
|
|
10
|
+
* takes `messages` as a parameter — it holds no mutable turn state on
|
|
11
|
+
* `this` during a run, so concurrent generators cannot alias each
|
|
12
|
+
* other's conversation or tool-call arrays.
|
|
13
|
+
* - All yielded events are re-tagged with the instance's bound
|
|
14
|
+
* `threadId` (not with the global current-thread marker from the
|
|
15
|
+
* singleton ThreadStore), so the web-bridge can route them to the
|
|
16
|
+
* right pane even while several threads stream simultaneously.
|
|
17
|
+
* - `messages` is owned by the instance: user/assistant messages
|
|
18
|
+
* appended during a query are persisted to the instance's own array,
|
|
19
|
+
* not to a global.
|
|
20
|
+
*
|
|
21
|
+
* Memory scope: Phase 2 design doc §6 — the memory store is shared across
|
|
22
|
+
* threads (one user, one brain), but the EngineInstance carries a
|
|
23
|
+
* `memoryScope` ref that can later be used to namespace recall/query
|
|
24
|
+
* results by thread. Today the ref is the threadId itself; downstream
|
|
25
|
+
* memory adapters can opt in.
|
|
26
|
+
*
|
|
27
|
+
* Q2 decision (PM brief): all threads use session primaryModel. No
|
|
28
|
+
* per-thread model override is accepted.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { Engine } from '../engine.js';
|
|
32
|
+
import { MAIN_THREAD_ID } from './store.js';
|
|
33
|
+
|
|
34
|
+
export class EngineInstance {
|
|
35
|
+
/** @type {string} */
|
|
36
|
+
#threadId;
|
|
37
|
+
|
|
38
|
+
/** @type {Engine} */
|
|
39
|
+
#engine;
|
|
40
|
+
|
|
41
|
+
/** @type {Array<object>} owned per-thread conversation messages */
|
|
42
|
+
#messages;
|
|
43
|
+
|
|
44
|
+
/** @type {string} memory scope ref — today simply the threadId */
|
|
45
|
+
#memoryScope;
|
|
46
|
+
|
|
47
|
+
/** @type {boolean} */
|
|
48
|
+
#terminated = false;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{
|
|
52
|
+
* threadId: string,
|
|
53
|
+
* engine: Engine,
|
|
54
|
+
* memoryScope?: string,
|
|
55
|
+
* initialMessages?: Array<object>,
|
|
56
|
+
* }} params
|
|
57
|
+
*/
|
|
58
|
+
constructor({ threadId, engine, memoryScope, initialMessages }) {
|
|
59
|
+
if (!threadId || typeof threadId !== 'string') {
|
|
60
|
+
throw new Error('EngineInstance: threadId is required');
|
|
61
|
+
}
|
|
62
|
+
if (!engine) {
|
|
63
|
+
throw new Error('EngineInstance: engine is required');
|
|
64
|
+
}
|
|
65
|
+
this.#threadId = threadId;
|
|
66
|
+
this.#engine = engine;
|
|
67
|
+
this.#memoryScope = memoryScope || threadId;
|
|
68
|
+
this.#messages = Array.isArray(initialMessages) ? [...initialMessages] : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @returns {string} */
|
|
72
|
+
get threadId() { return this.#threadId; }
|
|
73
|
+
|
|
74
|
+
/** @returns {string} */
|
|
75
|
+
get memoryScope() { return this.#memoryScope; }
|
|
76
|
+
|
|
77
|
+
/** @returns {boolean} */
|
|
78
|
+
get terminated() { return this.#terminated; }
|
|
79
|
+
|
|
80
|
+
/** Number of messages recorded on this instance. */
|
|
81
|
+
get messageCount() { return this.#messages.length; }
|
|
82
|
+
|
|
83
|
+
/** Snapshot of the current messages array (copy, safe for callers). */
|
|
84
|
+
get messages() { return [...this.#messages]; }
|
|
85
|
+
|
|
86
|
+
/** Underlying Engine (for tool registration, trace access, etc.). */
|
|
87
|
+
get engine() { return this.#engine; }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run a query on this thread's engine. Yields events tagged with this
|
|
91
|
+
* instance's bound threadId. After the run, user + assistant messages
|
|
92
|
+
* are appended to the owned messages array.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} params
|
|
95
|
+
* @param {string} params.prompt
|
|
96
|
+
* @param {'dream'} [params.mode]
|
|
97
|
+
* @param {AbortSignal} [params.signal]
|
|
98
|
+
* @yields {object} EngineEvent with { ...event, threadId }
|
|
99
|
+
*/
|
|
100
|
+
async *query({ prompt, mode, signal }) {
|
|
101
|
+
if (this.#terminated) {
|
|
102
|
+
yield {
|
|
103
|
+
type: 'error',
|
|
104
|
+
threadId: this.#threadId,
|
|
105
|
+
error: new Error(`EngineInstance(${this.#threadId}) has been terminated`),
|
|
106
|
+
retryable: false,
|
|
107
|
+
};
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Snapshot of messages passed to the engine; the engine treats this
|
|
112
|
+
// as read-only (it builds its own conversation array internally).
|
|
113
|
+
const snapshot = [...this.#messages];
|
|
114
|
+
let assistantText = '';
|
|
115
|
+
const assistantToolCalls = [];
|
|
116
|
+
|
|
117
|
+
for await (const event of this.#engine.query({ prompt, mode, messages: snapshot, signal })) {
|
|
118
|
+
// Re-tag every event with the bound threadId. Non-object events
|
|
119
|
+
// (shouldn't happen — all engine events are objects) are passed
|
|
120
|
+
// through untouched.
|
|
121
|
+
const tagged = event && typeof event === 'object'
|
|
122
|
+
? { ...event, threadId: this.#threadId }
|
|
123
|
+
: event;
|
|
124
|
+
yield tagged;
|
|
125
|
+
|
|
126
|
+
// Track assistant reply to persist after stream ends. Only tag the
|
|
127
|
+
// natural stream types — not our injected turn_start/turn_end.
|
|
128
|
+
if (event && typeof event === 'object') {
|
|
129
|
+
if (event.type === 'text_delta' && typeof event.text === 'string') {
|
|
130
|
+
assistantText += event.text;
|
|
131
|
+
} else if (event.type === 'tool_call') {
|
|
132
|
+
assistantToolCalls.push({ id: event.id, name: event.name, input: event.input });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Append user + assistant to the owned messages array so subsequent
|
|
138
|
+
// queries on this thread carry conversational context.
|
|
139
|
+
this.#messages.push({ role: 'user', content: prompt });
|
|
140
|
+
const assistantMsg = { role: 'assistant', content: assistantText };
|
|
141
|
+
if (assistantToolCalls.length > 0) {
|
|
142
|
+
assistantMsg.toolCalls = assistantToolCalls;
|
|
143
|
+
}
|
|
144
|
+
this.#messages.push(assistantMsg);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Terminate this engine instance. Further .query() calls will emit an
|
|
149
|
+
* error event and return early. Does NOT tear down the underlying
|
|
150
|
+
* Engine (engines are shared across instances via composition from
|
|
151
|
+
* the registry — only the instance's per-thread state is dropped).
|
|
152
|
+
*/
|
|
153
|
+
terminate() {
|
|
154
|
+
this.#terminated = true;
|
|
155
|
+
this.#messages = [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reset the owned messages array. Used by the registry for crash
|
|
160
|
+
* recovery / test cleanup. Does NOT terminate the instance.
|
|
161
|
+
* @param {Array<object>} [messages=[]]
|
|
162
|
+
*/
|
|
163
|
+
resetMessages(messages = []) {
|
|
164
|
+
this.#messages = Array.isArray(messages) ? [...messages] : [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Factory helper — builds an EngineInstance that owns a fresh Engine,
|
|
170
|
+
* sharing the given dependency bag across all threads of a session.
|
|
171
|
+
*
|
|
172
|
+
* @param {{
|
|
173
|
+
* threadId: string,
|
|
174
|
+
* adapter: object,
|
|
175
|
+
* trace: object,
|
|
176
|
+
* config: object,
|
|
177
|
+
* conversationStore?: object,
|
|
178
|
+
* memoryStore?: object,
|
|
179
|
+
* toolRegistry?: object,
|
|
180
|
+
* skillManager?: object,
|
|
181
|
+
* mcpManager?: object,
|
|
182
|
+
* yeaftDir?: string,
|
|
183
|
+
* initialMessages?: Array<object>,
|
|
184
|
+
* }} deps
|
|
185
|
+
* @returns {EngineInstance}
|
|
186
|
+
*/
|
|
187
|
+
export function createEngineInstance(deps) {
|
|
188
|
+
const {
|
|
189
|
+
threadId,
|
|
190
|
+
adapter,
|
|
191
|
+
trace,
|
|
192
|
+
config,
|
|
193
|
+
conversationStore,
|
|
194
|
+
memoryStore,
|
|
195
|
+
toolRegistry,
|
|
196
|
+
skillManager,
|
|
197
|
+
mcpManager,
|
|
198
|
+
yeaftDir,
|
|
199
|
+
initialMessages,
|
|
200
|
+
} = deps;
|
|
201
|
+
const engine = new Engine({
|
|
202
|
+
adapter,
|
|
203
|
+
trace,
|
|
204
|
+
config,
|
|
205
|
+
conversationStore,
|
|
206
|
+
memoryStore,
|
|
207
|
+
toolRegistry,
|
|
208
|
+
skillManager,
|
|
209
|
+
mcpManager,
|
|
210
|
+
yeaftDir,
|
|
211
|
+
});
|
|
212
|
+
return new EngineInstance({
|
|
213
|
+
threadId: threadId || MAIN_THREAD_ID,
|
|
214
|
+
engine,
|
|
215
|
+
memoryScope: threadId || MAIN_THREAD_ID,
|
|
216
|
+
initialMessages,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine-registry.js — task-308 Phase 2.
|
|
3
|
+
*
|
|
4
|
+
* A ThreadEngineRegistry is a per-session Map<threadId, EngineInstance>.
|
|
5
|
+
* It owns instance lifecycles:
|
|
6
|
+
*
|
|
7
|
+
* - `get(threadId)` returns the existing instance for a thread.
|
|
8
|
+
* - `ensure(threadId, opts)` lazily creates one via the configured
|
|
9
|
+
* factory if it does not yet exist. This is the primary entry point
|
|
10
|
+
* for routing a user message to the correct thread engine.
|
|
11
|
+
* - `listActive()` enumerates non-terminated instances, useful for
|
|
12
|
+
* the web-bridge to show "active threads" indicators.
|
|
13
|
+
* - `terminate(threadId)` tears down a single thread engine without
|
|
14
|
+
* disturbing the rest.
|
|
15
|
+
* - `terminateAll()` is called on session shutdown.
|
|
16
|
+
*
|
|
17
|
+
* The registry holds no LLM/tool state itself — it delegates to the
|
|
18
|
+
* factory, which in production will be the closure over the shared
|
|
19
|
+
* session deps (adapter, trace, config, stores, tool registry, …).
|
|
20
|
+
*
|
|
21
|
+
* Concurrency note: Node's single-threaded event loop means the
|
|
22
|
+
* registry's Map mutations are race-free. Concurrent .query() calls
|
|
23
|
+
* interleave only at await points, and each EngineInstance keeps its
|
|
24
|
+
* per-turn state inside the async generator's local scope, not on
|
|
25
|
+
* `this` — so two threads can stream simultaneously without stepping
|
|
26
|
+
* on each other.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { MAIN_THREAD_ID } from './store.js';
|
|
30
|
+
import { createEngineInstance } from './engine-instance.js';
|
|
31
|
+
|
|
32
|
+
export class ThreadEngineRegistry {
|
|
33
|
+
/** @type {Map<string, import('./engine-instance.js').EngineInstance>} */
|
|
34
|
+
#instances;
|
|
35
|
+
|
|
36
|
+
/** @type {(threadId: string, opts?: object) => import('./engine-instance.js').EngineInstance} */
|
|
37
|
+
#factory;
|
|
38
|
+
|
|
39
|
+
/** @type {string} */
|
|
40
|
+
#currentThreadId;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{
|
|
44
|
+
* factory: (threadId: string, opts?: object) => import('./engine-instance.js').EngineInstance,
|
|
45
|
+
* }} params
|
|
46
|
+
*/
|
|
47
|
+
constructor({ factory } = {}) {
|
|
48
|
+
if (typeof factory !== 'function') {
|
|
49
|
+
throw new Error('ThreadEngineRegistry: factory function is required');
|
|
50
|
+
}
|
|
51
|
+
this.#instances = new Map();
|
|
52
|
+
this.#factory = factory;
|
|
53
|
+
this.#currentThreadId = MAIN_THREAD_ID;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @returns {string} */
|
|
57
|
+
get currentThreadId() { return this.#currentThreadId; }
|
|
58
|
+
|
|
59
|
+
/** Total number of registered (including terminated) instances. */
|
|
60
|
+
get size() { return this.#instances.size; }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get an existing instance for a thread. Returns null if not yet
|
|
64
|
+
* created. Does NOT lazy-create — use ensure() for that.
|
|
65
|
+
* @param {string} threadId
|
|
66
|
+
*/
|
|
67
|
+
get(threadId) {
|
|
68
|
+
return this.#instances.get(threadId) || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Lazy-get-or-create an instance for a thread. If one already exists
|
|
73
|
+
* and is not terminated, it is returned; if it was terminated, a new
|
|
74
|
+
* one replaces it. Any `opts` are forwarded to the factory.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} threadId
|
|
77
|
+
* @param {object} [opts]
|
|
78
|
+
* @returns {import('./engine-instance.js').EngineInstance}
|
|
79
|
+
*/
|
|
80
|
+
ensure(threadId, opts) {
|
|
81
|
+
if (!threadId || typeof threadId !== 'string') {
|
|
82
|
+
throw new Error('ThreadEngineRegistry.ensure: threadId required');
|
|
83
|
+
}
|
|
84
|
+
const existing = this.#instances.get(threadId);
|
|
85
|
+
if (existing && !existing.terminated) return existing;
|
|
86
|
+
const instance = this.#factory(threadId, opts);
|
|
87
|
+
if (!instance || typeof instance.query !== 'function') {
|
|
88
|
+
throw new Error(`ThreadEngineRegistry.ensure: factory did not return an EngineInstance for ${threadId}`);
|
|
89
|
+
}
|
|
90
|
+
this.#instances.set(threadId, instance);
|
|
91
|
+
return instance;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Set the current thread marker. Does not lazy-create — caller must
|
|
96
|
+
* ensure() if they want an instance for an unseen thread.
|
|
97
|
+
* @param {string} threadId
|
|
98
|
+
*/
|
|
99
|
+
setCurrent(threadId) {
|
|
100
|
+
if (!threadId || typeof threadId !== 'string') {
|
|
101
|
+
throw new Error('ThreadEngineRegistry.setCurrent: threadId required');
|
|
102
|
+
}
|
|
103
|
+
this.#currentThreadId = threadId;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* List all non-terminated instances. The order is insertion order.
|
|
108
|
+
* @returns {Array<import('./engine-instance.js').EngineInstance>}
|
|
109
|
+
*/
|
|
110
|
+
listActive() {
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const inst of this.#instances.values()) {
|
|
113
|
+
if (!inst.terminated) out.push(inst);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* All instances including terminated ones (for inspection / tests).
|
|
120
|
+
* @returns {Array<import('./engine-instance.js').EngineInstance>}
|
|
121
|
+
*/
|
|
122
|
+
listAll() {
|
|
123
|
+
return [...this.#instances.values()];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Terminate a single thread's instance. Safe on unknown threadId.
|
|
128
|
+
* @param {string} threadId
|
|
129
|
+
* @returns {boolean} true if a live instance was terminated
|
|
130
|
+
*/
|
|
131
|
+
terminate(threadId) {
|
|
132
|
+
const inst = this.#instances.get(threadId);
|
|
133
|
+
if (!inst) return false;
|
|
134
|
+
if (inst.terminated) return false;
|
|
135
|
+
inst.terminate();
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Terminate all instances. Used on session shutdown.
|
|
141
|
+
* @returns {number} count terminated
|
|
142
|
+
*/
|
|
143
|
+
terminateAll() {
|
|
144
|
+
let n = 0;
|
|
145
|
+
for (const inst of this.#instances.values()) {
|
|
146
|
+
if (!inst.terminated) {
|
|
147
|
+
inst.terminate();
|
|
148
|
+
n += 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return n;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Remove a thread's instance from the map entirely. The registry
|
|
156
|
+
* will no longer return it from listAll / listActive. Primarily used
|
|
157
|
+
* after terminate() when the caller wants a full forget.
|
|
158
|
+
* @param {string} threadId
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
delete(threadId) {
|
|
162
|
+
const inst = this.#instances.get(threadId);
|
|
163
|
+
if (!inst) return false;
|
|
164
|
+
if (!inst.terminated) inst.terminate();
|
|
165
|
+
return this.#instances.delete(threadId);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build a registry whose factory constructs full Engine instances using
|
|
171
|
+
* a shared dependency bag. This is the production entry point used by
|
|
172
|
+
* session.js:
|
|
173
|
+
*
|
|
174
|
+
* const registry = createThreadEngineRegistry({
|
|
175
|
+
* adapter, trace, config, conversationStore, memoryStore,
|
|
176
|
+
* toolRegistry, skillManager, mcpManager, yeaftDir,
|
|
177
|
+
* });
|
|
178
|
+
* const inst = registry.ensure(threadId);
|
|
179
|
+
* for await (const event of inst.query({ prompt })) { ... }
|
|
180
|
+
*
|
|
181
|
+
* @param {object} deps — shared session deps (see session.js §9)
|
|
182
|
+
* @returns {ThreadEngineRegistry}
|
|
183
|
+
*/
|
|
184
|
+
export function createThreadEngineRegistry(deps) {
|
|
185
|
+
return new ThreadEngineRegistry({
|
|
186
|
+
factory: (threadId, opts = {}) => createEngineInstance({
|
|
187
|
+
...deps,
|
|
188
|
+
...opts,
|
|
189
|
+
threadId,
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
}
|