@yeaft/webchat-agent 0.1.481 → 0.1.482

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.481",
3
+ "version": "0.1.482",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
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
+ }