agent-working-memory 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +8 -5
  2. package/dist/adapters/common.d.ts +34 -0
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +528 -446
  5. package/dist/adapters/common.js.map +1 -1
  6. package/dist/api/routes.d.ts.map +1 -1
  7. package/dist/api/routes.js +19 -0
  8. package/dist/api/routes.js.map +1 -1
  9. package/dist/cli/migrate.d.ts.map +1 -1
  10. package/dist/cli/migrate.js +31 -29
  11. package/dist/cli/migrate.js.map +1 -1
  12. package/dist/core/asked-value.d.ts +37 -0
  13. package/dist/core/asked-value.d.ts.map +1 -0
  14. package/dist/core/asked-value.js +77 -0
  15. package/dist/core/asked-value.js.map +1 -0
  16. package/dist/core/conflicts.d.ts +89 -0
  17. package/dist/core/conflicts.d.ts.map +1 -0
  18. package/dist/core/conflicts.js +267 -0
  19. package/dist/core/conflicts.js.map +1 -0
  20. package/dist/core/embeddings.d.ts +14 -2
  21. package/dist/core/embeddings.d.ts.map +1 -1
  22. package/dist/core/embeddings.js +32 -10
  23. package/dist/core/embeddings.js.map +1 -1
  24. package/dist/core/format-recall.d.ts +21 -1
  25. package/dist/core/format-recall.d.ts.map +1 -1
  26. package/dist/core/format-recall.js +60 -5
  27. package/dist/core/format-recall.js.map +1 -1
  28. package/dist/core/ml-worker.d.ts +24 -1
  29. package/dist/core/ml-worker.d.ts.map +1 -1
  30. package/dist/core/ml-worker.js +165 -4
  31. package/dist/core/ml-worker.js.map +1 -1
  32. package/dist/core/model-daemon-client.d.ts +77 -0
  33. package/dist/core/model-daemon-client.d.ts.map +1 -0
  34. package/dist/core/model-daemon-client.js +383 -0
  35. package/dist/core/model-daemon-client.js.map +1 -0
  36. package/dist/core/model-daemon-server.d.ts +71 -0
  37. package/dist/core/model-daemon-server.d.ts.map +1 -0
  38. package/dist/core/model-daemon-server.js +363 -0
  39. package/dist/core/model-daemon-server.js.map +1 -0
  40. package/dist/core/model-daemon.d.ts +137 -0
  41. package/dist/core/model-daemon.d.ts.map +1 -0
  42. package/dist/core/model-daemon.js +231 -0
  43. package/dist/core/model-daemon.js.map +1 -0
  44. package/dist/core/query-expander.d.ts +5 -0
  45. package/dist/core/query-expander.d.ts.map +1 -1
  46. package/dist/core/query-expander.js +3 -2
  47. package/dist/core/query-expander.js.map +1 -1
  48. package/dist/core/recall-config.d.ts +1 -1
  49. package/dist/core/recall-config.d.ts.map +1 -1
  50. package/dist/core/recall-config.js +5 -0
  51. package/dist/core/recall-config.js.map +1 -1
  52. package/dist/core/rerank-window.d.ts +7 -2
  53. package/dist/core/rerank-window.d.ts.map +1 -1
  54. package/dist/core/rerank-window.js +9 -4
  55. package/dist/core/rerank-window.js.map +1 -1
  56. package/dist/core/rerank2.d.ts +52 -2
  57. package/dist/core/rerank2.d.ts.map +1 -1
  58. package/dist/core/rerank2.js +60 -3
  59. package/dist/core/rerank2.js.map +1 -1
  60. package/dist/core/reranker.d.ts +19 -0
  61. package/dist/core/reranker.d.ts.map +1 -1
  62. package/dist/core/reranker.js +90 -13
  63. package/dist/core/reranker.js.map +1 -1
  64. package/dist/core/trust.d.ts +32 -0
  65. package/dist/core/trust.d.ts.map +1 -0
  66. package/dist/core/trust.js +66 -0
  67. package/dist/core/trust.js.map +1 -0
  68. package/dist/core/validity.d.ts +21 -0
  69. package/dist/core/validity.d.ts.map +1 -0
  70. package/dist/core/validity.js +41 -0
  71. package/dist/core/validity.js.map +1 -0
  72. package/dist/core/whoami.d.ts +3 -0
  73. package/dist/core/whoami.d.ts.map +1 -1
  74. package/dist/core/whoami.js +14 -0
  75. package/dist/core/whoami.js.map +1 -1
  76. package/dist/core/write-pipeline.d.ts +48 -0
  77. package/dist/core/write-pipeline.d.ts.map +1 -1
  78. package/dist/core/write-pipeline.js +80 -3
  79. package/dist/core/write-pipeline.js.map +1 -1
  80. package/dist/engine/activation.d.ts.map +1 -1
  81. package/dist/engine/activation.js +27 -4
  82. package/dist/engine/activation.js.map +1 -1
  83. package/dist/engine/consolidation.d.ts.map +1 -1
  84. package/dist/engine/consolidation.js +53 -4
  85. package/dist/engine/consolidation.js.map +1 -1
  86. package/dist/hooks/prime-query.d.ts +57 -0
  87. package/dist/hooks/prime-query.d.ts.map +1 -0
  88. package/dist/hooks/prime-query.js +388 -0
  89. package/dist/hooks/prime-query.js.map +1 -0
  90. package/dist/hooks/prime.d.ts +8 -0
  91. package/dist/hooks/prime.d.ts.map +1 -1
  92. package/dist/hooks/prime.js +21 -2
  93. package/dist/hooks/prime.js.map +1 -1
  94. package/dist/hooks/sidecar.d.ts +18 -0
  95. package/dist/hooks/sidecar.d.ts.map +1 -1
  96. package/dist/hooks/sidecar.js +58 -2
  97. package/dist/hooks/sidecar.js.map +1 -1
  98. package/dist/index.js +15 -3
  99. package/dist/index.js.map +1 -1
  100. package/dist/mcp.js +157 -104
  101. package/dist/mcp.js.map +1 -1
  102. package/dist/model-daemon.d.ts +8 -0
  103. package/dist/model-daemon.d.ts.map +1 -0
  104. package/dist/model-daemon.js +45 -0
  105. package/dist/model-daemon.js.map +1 -0
  106. package/dist/storage/factory.d.ts.map +1 -1
  107. package/dist/storage/factory.js +5 -0
  108. package/dist/storage/factory.js.map +1 -1
  109. package/dist/storage/pglite-schema.d.ts.map +1 -1
  110. package/dist/storage/pglite-schema.js +176 -170
  111. package/dist/storage/pglite-schema.js.map +1 -1
  112. package/dist/storage/pglite.d.ts +5 -0
  113. package/dist/storage/pglite.d.ts.map +1 -1
  114. package/dist/storage/pglite.js +165 -153
  115. package/dist/storage/pglite.js.map +1 -1
  116. package/dist/storage/postgres.d.ts +5 -0
  117. package/dist/storage/postgres.d.ts.map +1 -1
  118. package/dist/storage/postgres.js +165 -153
  119. package/dist/storage/postgres.js.map +1 -1
  120. package/dist/storage/sqlite.d.ts +22 -0
  121. package/dist/storage/sqlite.d.ts.map +1 -1
  122. package/dist/storage/sqlite.js +415 -363
  123. package/dist/storage/sqlite.js.map +1 -1
  124. package/dist/types/engram.d.ts +33 -0
  125. package/dist/types/engram.d.ts.map +1 -1
  126. package/dist/types/engram.js.map +1 -1
  127. package/package.json +1 -1
  128. package/plugin/.claude-plugin/plugin.json +1 -1
  129. package/plugin/README.md +1 -1
  130. package/plugin/bin/awm-mcp-launcher.cjs +2 -2
  131. package/plugin/skills/awm-memory/SKILL.md +11 -2
  132. package/src/adapters/common.ts +881 -802
  133. package/src/api/routes.ts +20 -0
  134. package/src/cli/migrate.ts +309 -307
  135. package/src/core/asked-value.ts +74 -0
  136. package/src/core/conflicts.ts +280 -0
  137. package/src/core/embeddings.ts +35 -10
  138. package/src/core/format-recall.ts +83 -25
  139. package/src/core/ml-worker.ts +458 -281
  140. package/src/core/model-daemon-client.ts +374 -0
  141. package/src/core/model-daemon-server.ts +344 -0
  142. package/src/core/model-daemon.ts +243 -0
  143. package/src/core/query-expander.ts +127 -126
  144. package/src/core/recall-config.ts +5 -0
  145. package/src/core/rerank-window.ts +164 -159
  146. package/src/core/rerank2.ts +143 -82
  147. package/src/core/reranker.ts +92 -12
  148. package/src/core/trust.ts +65 -0
  149. package/src/core/validity.ts +47 -0
  150. package/src/core/whoami.ts +136 -120
  151. package/src/core/write-pipeline.ts +119 -3
  152. package/src/engine/activation.ts +27 -4
  153. package/src/engine/consolidation.ts +950 -899
  154. package/src/hooks/prime-query.ts +375 -0
  155. package/src/hooks/prime.ts +25 -2
  156. package/src/hooks/sidecar.ts +57 -3
  157. package/src/index.ts +258 -248
  158. package/src/mcp.ts +1529 -1475
  159. package/src/model-daemon.ts +48 -0
  160. package/src/storage/factory.ts +25 -19
  161. package/src/storage/pglite-schema.ts +199 -193
  162. package/src/storage/pglite.ts +1513 -1498
  163. package/src/storage/postgres.ts +1613 -1598
  164. package/src/storage/sqlite.ts +2069 -2019
  165. package/src/types/engram.ts +30 -0
@@ -1,281 +1,458 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * ML inference dispatch pool.
5
- *
6
- * STATUS (AWM 0.8.x P1 REVISE, 2026-05-25): worker_threads were the original
7
- * plan, but @huggingface/transformers in Node only supports the `cpu`
8
- * (native ONNX) and `dml` (Windows GPU) backends. Neither is safe inside
9
- * a worker_thread — onnxruntime-node's native bindings store V8 handles
10
- * that get invalidated when crossing isolate boundaries, causing
11
- * `v8::HandleScope::CreateHandle()` crashes on first inference call.
12
- * The browser-only `wasm` backend is not loaded in Node builds of
13
- * transformers.js.
14
- *
15
- * The dispatch abstraction is preserved so a future child_process pool
16
- * or HTTP sidecar (see AWM_ML_SIDECAR_URL design in docs/awm-architecture-history.md)
17
- * can plug in. For now ALL inference runs in-process. The freeze fix from
18
- * P0 (sleep-only consolidation) already eliminates the multi-second
19
- * in-band blocks. Individual inference calls (~50ms each) on the main
20
- * thread are accepted as-is.
21
- *
22
- * Test / dev mode: AWM_ML_INPROCESS=1 is honored but is now the default.
23
- * The env var remains as a no-op for backwards compatibility.
24
- */
25
-
26
- import { Worker } from 'node:worker_threads';
27
- import { fileURLToPath } from 'node:url';
28
- import { dirname, join } from 'node:path';
29
- import { existsSync } from 'node:fs';
30
-
31
- type WorkerRole = 'embed' | 'rerank' | 'expand';
32
-
33
- interface PendingRequest {
34
- resolve: (value: any) => void;
35
- reject: (err: Error) => void;
36
- timeoutHandle?: ReturnType<typeof setTimeout>;
37
- }
38
-
39
- interface ManagedWorker {
40
- role: WorkerRole;
41
- worker: Worker | null;
42
- ready: Promise<void>;
43
- setReady: () => void;
44
- setFailed: (err: Error) => void;
45
- pending: Map<number, PendingRequest>;
46
- buffered: Array<{ id: number; op: WorkerRole; args: any }>;
47
- isReady: boolean;
48
- restartTimestamps: number[]; // unix-ms of recent restarts (for backoff cap)
49
- }
50
-
51
- let inProcessMode = false;
52
- let workers: Record<WorkerRole, ManagedWorker> | null = null;
53
- let nextId = 1;
54
-
55
- // In-process fallback handles (used in tests and as crash escape hatch).
56
- let inProcessEmbed: ((args: any) => Promise<number[][]>) | null = null;
57
- let inProcessRerank: ((args: any) => Promise<Array<{ index: number; score: number }>>) | null = null;
58
- let inProcessExpand: ((args: any) => Promise<string>) | null = null;
59
-
60
- const REQUEST_TIMEOUT_MS = 60_000; // any single inference call > 60s is treated as failed
61
- const MAX_RESTARTS_PER_MINUTE = 3;
62
-
63
- function shouldUseInProcess(): boolean {
64
- // AWM 0.8.x P1 REVISE: worker_threads were removed because @huggingface/transformers
65
- // in Node is not worker_threads-safe (see file header). Always in-process for now.
66
- // The dispatch abstraction is preserved for a future child_process pool or
67
- // HTTP sidecar pivot.
68
- return true;
69
- }
70
-
71
- function createWorker(role: WorkerRole): ManagedWorker {
72
- const m: ManagedWorker = {
73
- role,
74
- worker: null,
75
- ready: Promise.resolve(),
76
- setReady: () => {},
77
- setFailed: () => {},
78
- pending: new Map(),
79
- buffered: [],
80
- isReady: false,
81
- restartTimestamps: [],
82
- };
83
- m.ready = new Promise<void>((resolve, reject) => {
84
- m.setReady = () => { m.isReady = true; resolve(); };
85
- m.setFailed = (err) => reject(err);
86
- });
87
- spawnWorker(m);
88
- return m;
89
- }
90
-
91
- function workerEntryPath(): string {
92
- // Resolve to the compiled .js. Two possible locations:
93
- // 1. Same directory as this file (when running from dist/core/)
94
- // 2. Sibling dist/core/ (when running from src/core/ via tsx)
95
- // Always prefer the compiled file. If neither exists, shouldUseInProcess()
96
- // will detect the missing entry and fall back to in-process mode.
97
- const here = dirname(fileURLToPath(import.meta.url));
98
- const samedir = join(here, 'ml-worker-entry.js');
99
- if (existsSync(samedir)) return samedir;
100
- // From src/core/ml-worker.ts, dist/core/ml-worker-entry.js is at ../../dist/core/
101
- const distSibling = join(here, '..', '..', 'dist', 'core', 'ml-worker-entry.js');
102
- if (existsSync(distSibling)) return distSibling;
103
- // Fallback to the same-dir path (will fail existsSync in shouldUseInProcess
104
- // and trigger in-process mode)
105
- return samedir;
106
- }
107
-
108
- function spawnWorker(m: ManagedWorker): void {
109
- const w = new Worker(workerEntryPath(), { workerData: { role: m.role } });
110
- m.worker = w;
111
-
112
- w.on('message', (msg: any) => {
113
- if (msg?.ready === true) {
114
- m.setReady();
115
- // Drain buffered messages
116
- for (const buf of m.buffered) w.postMessage(buf);
117
- m.buffered = [];
118
- return;
119
- }
120
- if (msg?.ready === false) {
121
- m.setFailed(new Error(`worker ${m.role} failed to load model: ${msg.error}`));
122
- return;
123
- }
124
- if (msg?.shutdown === 'done') return;
125
- if (typeof msg?.id !== 'number') return;
126
-
127
- const p = m.pending.get(msg.id);
128
- if (!p) return;
129
- m.pending.delete(msg.id);
130
- if (p.timeoutHandle) clearTimeout(p.timeoutHandle);
131
- if (msg.ok) p.resolve(msg.result);
132
- else p.reject(new Error(msg.error ?? 'worker error'));
133
- });
134
-
135
- w.on('error', (err) => {
136
- console.error(`[ml-worker:${m.role}] error:`, err);
137
- });
138
-
139
- w.on('exit', (code) => {
140
- if (code === 0) return; // graceful exit
141
- console.warn(`[ml-worker:${m.role}] exited with code ${code} — recovering`);
142
- // Reject all pending requests
143
- for (const [, p] of m.pending) {
144
- if (p.timeoutHandle) clearTimeout(p.timeoutHandle);
145
- p.reject(new Error(`worker ${m.role} crashed (exit ${code})`));
146
- }
147
- m.pending.clear();
148
-
149
- // Restart-rate backoff
150
- const now = Date.now();
151
- m.restartTimestamps = m.restartTimestamps.filter(t => now - t < 60_000);
152
- m.restartTimestamps.push(now);
153
-
154
- if (m.restartTimestamps.length > MAX_RESTARTS_PER_MINUTE) {
155
- console.error(`[ml-worker:${m.role}] crashed ${m.restartTimestamps.length} times in 60s — falling back to in-process`);
156
- m.worker = null;
157
- m.isReady = false;
158
- inProcessMode = true;
159
- return;
160
- }
161
-
162
- // Reset ready promise + respawn
163
- m.isReady = false;
164
- m.ready = new Promise<void>((resolve, reject) => {
165
- m.setReady = () => { m.isReady = true; resolve(); };
166
- m.setFailed = (err) => reject(err);
167
- });
168
- spawnWorker(m);
169
- });
170
- }
171
-
172
- /** Initialize the pool. Idempotent — safe to call multiple times. */
173
- export function initMLPool(): void {
174
- if (shouldUseInProcess()) {
175
- inProcessMode = true;
176
- return;
177
- }
178
- if (workers) return;
179
- workers = {
180
- embed: createWorker('embed'),
181
- rerank: createWorker('rerank'),
182
- expand: createWorker('expand'),
183
- };
184
- }
185
-
186
- /** Register in-process fallback handlers. Called once by the consumer modules. */
187
- export function registerInProcessHandlers(handlers: {
188
- embed?: typeof inProcessEmbed;
189
- rerank?: typeof inProcessRerank;
190
- expand?: typeof inProcessExpand;
191
- }): void {
192
- if (handlers.embed) inProcessEmbed = handlers.embed;
193
- if (handlers.rerank) inProcessRerank = handlers.rerank;
194
- if (handlers.expand) inProcessExpand = handlers.expand;
195
- }
196
-
197
- /** True if the pool is operating in in-process mode (no workers). */
198
- export function isInProcessMode(): boolean {
199
- return inProcessMode;
200
- }
201
-
202
- async function dispatchToWorker<T>(role: WorkerRole, args: any): Promise<T> {
203
- if (!workers) initMLPool();
204
- if (inProcessMode) {
205
- return dispatchInProcess<T>(role, args);
206
- }
207
-
208
- const m = workers![role];
209
- await m.ready;
210
- if (inProcessMode) {
211
- // Fallback flipped while we awaited ready
212
- return dispatchInProcess<T>(role, args);
213
- }
214
-
215
- const id = nextId++;
216
- return new Promise<T>((resolve, reject) => {
217
- const req: PendingRequest = {
218
- resolve,
219
- reject,
220
- timeoutHandle: setTimeout(() => {
221
- m.pending.delete(id);
222
- reject(new Error(`ml-worker:${role} request ${id} timed out after ${REQUEST_TIMEOUT_MS}ms`));
223
- }, REQUEST_TIMEOUT_MS),
224
- };
225
- m.pending.set(id, req);
226
- const msg = { id, op: role, args };
227
- if (m.isReady && m.worker) {
228
- m.worker.postMessage(msg);
229
- } else {
230
- m.buffered.push(msg);
231
- }
232
- });
233
- }
234
-
235
- async function dispatchInProcess<T>(role: WorkerRole, args: any): Promise<T> {
236
- switch (role) {
237
- case 'embed':
238
- if (!inProcessEmbed) throw new Error('in-process embed handler not registered');
239
- return inProcessEmbed(args) as Promise<T>;
240
- case 'rerank':
241
- if (!inProcessRerank) throw new Error('in-process rerank handler not registered');
242
- return inProcessRerank(args) as Promise<T>;
243
- case 'expand':
244
- if (!inProcessExpand) throw new Error('in-process expand handler not registered');
245
- return inProcessExpand(args) as Promise<T>;
246
- }
247
- }
248
-
249
- // --- Public API used by the consumer modules ---
250
-
251
- export async function dispatchEmbed(args: { texts: string[]; pooling: 'cls' | 'mean'; dimensions: number }): Promise<number[][]> {
252
- return dispatchToWorker<number[][]>('embed', args);
253
- }
254
-
255
- export async function dispatchRerank(args: { query: string; passages: string[] }): Promise<Array<{ index: number; score: number }>> {
256
- return dispatchToWorker<Array<{ index: number; score: number }>>('rerank', args);
257
- }
258
-
259
- export async function dispatchExpand(args: { prompt: string; maxNewTokens: number; noRepeatNgramSize: number }): Promise<string> {
260
- return dispatchToWorker<string>('expand', args);
261
- }
262
-
263
- /** Graceful shutdown. Waits up to 2s for queue drain, then terminates. */
264
- export async function shutdownMLPool(): Promise<void> {
265
- if (!workers) return;
266
- const promises: Promise<void>[] = [];
267
- for (const role of ['embed', 'rerank', 'expand'] as WorkerRole[]) {
268
- const m = workers[role];
269
- if (!m.worker) continue;
270
- const w = m.worker;
271
- promises.push(new Promise<void>((resolve) => {
272
- const timeout = setTimeout(() => {
273
- w.terminate().finally(() => resolve());
274
- }, 2000);
275
- w.once('exit', () => { clearTimeout(timeout); resolve(); });
276
- w.postMessage({ shutdown: true });
277
- }));
278
- }
279
- await Promise.all(promises);
280
- workers = null;
281
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * ML inference dispatch pool.
5
+ *
6
+ * STATUS (AWM 0.8.x P1 REVISE, 2026-05-25): worker_threads were the original
7
+ * plan, but @huggingface/transformers in Node only supports the `cpu`
8
+ * (native ONNX) and `dml` (Windows GPU) backends. Neither is safe inside
9
+ * a worker_thread — onnxruntime-node's native bindings store V8 handles
10
+ * that get invalidated when crossing isolate boundaries, causing
11
+ * `v8::HandleScope::CreateHandle()` crashes on first inference call.
12
+ * The browser-only `wasm` backend is not loaded in Node builds of
13
+ * transformers.js.
14
+ *
15
+ * The dispatch abstraction is preserved so a future child_process pool
16
+ * or HTTP sidecar (see AWM_ML_SIDECAR_URL design in docs/awm-architecture-history.md)
17
+ * can plug in. For now ALL inference runs in-process. The freeze fix from
18
+ * P0 (sleep-only consolidation) already eliminates the multi-second
19
+ * in-band blocks. Individual inference calls (~50ms each) on the main
20
+ * thread are accepted as-is.
21
+ *
22
+ * Test / dev mode: AWM_ML_INPROCESS=1 is honored but is now the default.
23
+ * The env var remains as a no-op for backwards compatibility.
24
+ */
25
+
26
+ import { Worker } from 'node:worker_threads';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { dirname, join } from 'node:path';
29
+ import { existsSync } from 'node:fs';
30
+ import {
31
+ configEnv, envInt, isModelDaemonEnabled, modelDaemonFingerprint, REQUEST_TIMEOUT_MS as DAEMON_STEP_TIMEOUT_MS, WARM_TIMEOUT_MS,
32
+ type InferenceConfig, type Lane,
33
+ } from './model-daemon.js';
34
+ import { DaemonRemoteError, DaemonUnavailableError, ModelDaemonClient } from './model-daemon-client.js';
35
+
36
+ type WorkerRole = 'embed' | 'rerank' | 'expand';
37
+
38
+ interface PendingRequest {
39
+ resolve: (value: any) => void;
40
+ reject: (err: Error) => void;
41
+ timeoutHandle?: ReturnType<typeof setTimeout>;
42
+ }
43
+
44
+ interface ManagedWorker {
45
+ role: WorkerRole;
46
+ worker: Worker | null;
47
+ ready: Promise<void>;
48
+ setReady: () => void;
49
+ setFailed: (err: Error) => void;
50
+ pending: Map<number, PendingRequest>;
51
+ buffered: Array<{ id: number; op: WorkerRole; args: any }>;
52
+ isReady: boolean;
53
+ restartTimestamps: number[]; // unix-ms of recent restarts (for backoff cap)
54
+ }
55
+
56
+ let inProcessMode = false;
57
+ let workers: Record<WorkerRole, ManagedWorker> | null = null;
58
+ let nextId = 1;
59
+
60
+ // In-process fallback handles (used in tests and as crash escape hatch).
61
+ let inProcessEmbed: ((args: any) => Promise<number[][]>) | null = null;
62
+ let inProcessRerank: ((args: any) => Promise<Array<{ index: number; score: number }>>) | null = null;
63
+ let inProcessExpand: ((args: any) => Promise<string>) | null = null;
64
+
65
+ const REQUEST_TIMEOUT_MS = 60_000; // any single inference call > 60s is treated as failed
66
+ const MAX_RESTARTS_PER_MINUTE = 3;
67
+
68
+ function shouldUseInProcess(): boolean {
69
+ // AWM 0.8.x P1 REVISE: worker_threads were removed because @huggingface/transformers
70
+ // in Node is not worker_threads-safe (see file header). Always in-process for now.
71
+ // The dispatch abstraction is preserved for a future child_process pool or
72
+ // HTTP sidecar pivot.
73
+ return true;
74
+ }
75
+
76
+ function createWorker(role: WorkerRole): ManagedWorker {
77
+ const m: ManagedWorker = {
78
+ role,
79
+ worker: null,
80
+ ready: Promise.resolve(),
81
+ setReady: () => {},
82
+ setFailed: () => {},
83
+ pending: new Map(),
84
+ buffered: [],
85
+ isReady: false,
86
+ restartTimestamps: [],
87
+ };
88
+ m.ready = new Promise<void>((resolve, reject) => {
89
+ m.setReady = () => { m.isReady = true; resolve(); };
90
+ m.setFailed = (err) => reject(err);
91
+ });
92
+ spawnWorker(m);
93
+ return m;
94
+ }
95
+
96
+ function workerEntryPath(): string {
97
+ // Resolve to the compiled .js. Two possible locations:
98
+ // 1. Same directory as this file (when running from dist/core/)
99
+ // 2. Sibling dist/core/ (when running from src/core/ via tsx)
100
+ // Always prefer the compiled file. If neither exists, shouldUseInProcess()
101
+ // will detect the missing entry and fall back to in-process mode.
102
+ const here = dirname(fileURLToPath(import.meta.url));
103
+ const samedir = join(here, 'ml-worker-entry.js');
104
+ if (existsSync(samedir)) return samedir;
105
+ // From src/core/ml-worker.ts, dist/core/ml-worker-entry.js is at ../../dist/core/
106
+ const distSibling = join(here, '..', '..', 'dist', 'core', 'ml-worker-entry.js');
107
+ if (existsSync(distSibling)) return distSibling;
108
+ // Fallback to the same-dir path (will fail existsSync in shouldUseInProcess
109
+ // and trigger in-process mode)
110
+ return samedir;
111
+ }
112
+
113
+ function spawnWorker(m: ManagedWorker): void {
114
+ const w = new Worker(workerEntryPath(), { workerData: { role: m.role } });
115
+ m.worker = w;
116
+
117
+ w.on('message', (msg: any) => {
118
+ if (msg?.ready === true) {
119
+ m.setReady();
120
+ // Drain buffered messages
121
+ for (const buf of m.buffered) w.postMessage(buf);
122
+ m.buffered = [];
123
+ return;
124
+ }
125
+ if (msg?.ready === false) {
126
+ m.setFailed(new Error(`worker ${m.role} failed to load model: ${msg.error}`));
127
+ return;
128
+ }
129
+ if (msg?.shutdown === 'done') return;
130
+ if (typeof msg?.id !== 'number') return;
131
+
132
+ const p = m.pending.get(msg.id);
133
+ if (!p) return;
134
+ m.pending.delete(msg.id);
135
+ if (p.timeoutHandle) clearTimeout(p.timeoutHandle);
136
+ if (msg.ok) p.resolve(msg.result);
137
+ else p.reject(new Error(msg.error ?? 'worker error'));
138
+ });
139
+
140
+ w.on('error', (err) => {
141
+ console.error(`[ml-worker:${m.role}] error:`, err);
142
+ });
143
+
144
+ w.on('exit', (code) => {
145
+ if (code === 0) return; // graceful exit
146
+ console.warn(`[ml-worker:${m.role}] exited with code ${code} — recovering`);
147
+ // Reject all pending requests
148
+ for (const [, p] of m.pending) {
149
+ if (p.timeoutHandle) clearTimeout(p.timeoutHandle);
150
+ p.reject(new Error(`worker ${m.role} crashed (exit ${code})`));
151
+ }
152
+ m.pending.clear();
153
+
154
+ // Restart-rate backoff
155
+ const now = Date.now();
156
+ m.restartTimestamps = m.restartTimestamps.filter(t => now - t < 60_000);
157
+ m.restartTimestamps.push(now);
158
+
159
+ if (m.restartTimestamps.length > MAX_RESTARTS_PER_MINUTE) {
160
+ console.error(`[ml-worker:${m.role}] crashed ${m.restartTimestamps.length} times in 60s — falling back to in-process`);
161
+ m.worker = null;
162
+ m.isReady = false;
163
+ inProcessMode = true;
164
+ return;
165
+ }
166
+
167
+ // Reset ready promise + respawn
168
+ m.isReady = false;
169
+ m.ready = new Promise<void>((resolve, reject) => {
170
+ m.setReady = () => { m.isReady = true; resolve(); };
171
+ m.setFailed = (err) => reject(err);
172
+ });
173
+ spawnWorker(m);
174
+ });
175
+ }
176
+
177
+ /** Initialize the pool. Idempotent — safe to call multiple times. */
178
+ export function initMLPool(): void {
179
+ if (shouldUseInProcess()) {
180
+ inProcessMode = true;
181
+ return;
182
+ }
183
+ if (workers) return;
184
+ workers = {
185
+ embed: createWorker('embed'),
186
+ rerank: createWorker('rerank'),
187
+ expand: createWorker('expand'),
188
+ };
189
+ }
190
+
191
+ /** Register in-process fallback handlers. Called once by the consumer modules. */
192
+ export function registerInProcessHandlers(handlers: {
193
+ embed?: typeof inProcessEmbed;
194
+ rerank?: typeof inProcessRerank;
195
+ expand?: typeof inProcessExpand;
196
+ }): void {
197
+ if (handlers.embed) inProcessEmbed = handlers.embed;
198
+ if (handlers.rerank) inProcessRerank = handlers.rerank;
199
+ if (handlers.expand) inProcessExpand = handlers.expand;
200
+ }
201
+
202
+ /** True if the pool is operating in in-process mode (no workers). */
203
+ export function isInProcessMode(): boolean {
204
+ return inProcessMode;
205
+ }
206
+
207
+ async function dispatchToWorker<T>(role: WorkerRole, args: any): Promise<T> {
208
+ if (!workers) initMLPool();
209
+ if (inProcessMode) {
210
+ return dispatchInProcess<T>(role, args);
211
+ }
212
+
213
+ const m = workers![role];
214
+ await m.ready;
215
+ if (inProcessMode) {
216
+ // Fallback flipped while we awaited ready
217
+ return dispatchInProcess<T>(role, args);
218
+ }
219
+
220
+ const id = nextId++;
221
+ return new Promise<T>((resolve, reject) => {
222
+ const req: PendingRequest = {
223
+ resolve,
224
+ reject,
225
+ timeoutHandle: setTimeout(() => {
226
+ m.pending.delete(id);
227
+ reject(new Error(`ml-worker:${role} request ${id} timed out after ${REQUEST_TIMEOUT_MS}ms`));
228
+ }, REQUEST_TIMEOUT_MS),
229
+ };
230
+ m.pending.set(id, req);
231
+ const msg = { id, op: role, args };
232
+ if (m.isReady && m.worker) {
233
+ m.worker.postMessage(msg);
234
+ } else {
235
+ m.buffered.push(msg);
236
+ }
237
+ });
238
+ }
239
+
240
+ async function dispatchInProcess<T>(role: WorkerRole, args: any): Promise<T> {
241
+ switch (role) {
242
+ case 'embed':
243
+ if (!inProcessEmbed) throw new Error('in-process embed handler not registered');
244
+ return inProcessEmbed(args) as Promise<T>;
245
+ case 'rerank':
246
+ if (!inProcessRerank) throw new Error('in-process rerank handler not registered');
247
+ return inProcessRerank(args) as Promise<T>;
248
+ case 'expand':
249
+ if (!inProcessExpand) throw new Error('in-process expand handler not registered');
250
+ return inProcessExpand(args) as Promise<T>;
251
+ }
252
+ }
253
+
254
+ // --- Shared model daemon (AWM_MODEL_DAEMON=1, see model-daemon.ts) ---
255
+
256
+ /**
257
+ * The config each model module captured at import, registered by the module itself.
258
+ * The daemon's fingerprint is built from these values, not from the env strings, and a
259
+ * spawned daemon is given them as env: a .env file applied after the modules loaded
260
+ * would otherwise make the daemon run a different model than the fallback here.
261
+ */
262
+ const inferenceConfig: InferenceConfig = {};
263
+ export function registerInferenceConfig(part: InferenceConfig): void {
264
+ Object.assign(inferenceConfig, part);
265
+ }
266
+ export function registeredInferenceConfig(): InferenceConfig {
267
+ return { ...inferenceConfig };
268
+ }
269
+
270
+ /**
271
+ * When the daemon fails, inference runs in-process and the daemon is probed again in
272
+ * the background, after 60 s, then 2, 4, 8 and at most 15 min. Requests never wait on a
273
+ * probe. In-process is the pre-daemon behaviour, bounded by the same shape caps, so a
274
+ * daemon outage costs memory, never recall. A BUSY daemon is not a failure: only an
275
+ * unreachable one, one that will not start, or one that stops making progress.
276
+ */
277
+ const RETRY_FIRST_MS = 60_000;
278
+ const RETRY_MAX_MS = 15 * 60_000;
279
+
280
+ let daemon: Promise<ModelDaemonClient> | null = null;
281
+ let fallbackActive = false;
282
+ let retryMs = RETRY_FIRST_MS;
283
+ let retryAt = 0;
284
+ let probing = false;
285
+ let connectedPid: number | null = null;
286
+ let clientFingerprint: string | null = null;
287
+ const daemonState = {
288
+ requests: 0,
289
+ queueTimeouts: 0,
290
+ fallbacks: 0,
291
+ lastFallbackReason: null as string | null,
292
+ lastFallbackAt: null as string | null,
293
+ };
294
+
295
+ function daemonClient(): Promise<ModelDaemonClient> {
296
+ return (daemon ??= (async () => {
297
+ // All three modules register their config on import. Load any not loaded yet, so
298
+ // the fingerprint is the daemon's (which loads all three).
299
+ await Promise.all([import('./embeddings.js'), import('./reranker.js'), import('./query-expander.js')]);
300
+ const config = registeredInferenceConfig();
301
+ clientFingerprint = modelDaemonFingerprint(config);
302
+ return new ModelDaemonClient({ fingerprint: clientFingerprint, spawnEnv: configEnv(config) });
303
+ })());
304
+ }
305
+
306
+ function reasonOf(err: unknown): string {
307
+ return err instanceof DaemonUnavailableError ? err.reason : 'error';
308
+ }
309
+
310
+ function noteFallback(reason: string, err: unknown): void {
311
+ daemonState.fallbacks++;
312
+ daemonState.lastFallbackReason = reason;
313
+ daemonState.lastFallbackAt = new Date().toISOString();
314
+ retryAt = Date.now() + retryMs;
315
+ console.error(`[awm] model daemon unavailable (${reason}): ${(err as Error)?.message ?? err} — running inference in this process; next try in ${Math.round(retryMs / 1000)}s`);
316
+ retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
317
+ fallbackActive = true;
318
+ }
319
+
320
+ function noteConnected(client: ModelDaemonClient): void {
321
+ connectedPid = client.daemonPid;
322
+ if (fallbackActive) console.error(`[awm] model daemon back (pid ${client.daemonPid}); inference leaves this process`);
323
+ fallbackActive = false;
324
+ retryMs = RETRY_FIRST_MS;
325
+ }
326
+
327
+ function probeInBackground(): void {
328
+ if (probing || Date.now() < retryAt) return;
329
+ probing = true;
330
+ void daemonClient()
331
+ .then(async c => {
332
+ const st = await c.request<{ current: { op: string; runningMs: number } | null }>('stats');
333
+ // Answering stats is not health: a daemon stuck on a step answers it too.
334
+ const limit = st.current?.op === 'warm' ? WARM_TIMEOUT_MS : envInt('AWM_MODEL_DAEMON_TIMEOUT_MS', DAEMON_STEP_TIMEOUT_MS);
335
+ if (st.current && st.current.runningMs > limit) {
336
+ throw new DaemonUnavailableError(`daemon has been running ${st.current.op} for ${st.current.runningMs} ms`, 'wedged');
337
+ }
338
+ noteConnected(c);
339
+ })
340
+ .catch(err => noteFallback(reasonOf(err), err))
341
+ .finally(() => { probing = false; });
342
+ }
343
+
344
+ async function dispatch<T>(role: WorkerRole, args: any, lane: Lane = 'interactive'): Promise<T> {
345
+ if (!isModelDaemonEnabled()) return dispatchToWorker<T>(role, args);
346
+ if (fallbackActive) {
347
+ probeInBackground();
348
+ return dispatchToWorker<T>(role, args);
349
+ }
350
+ // One retry on a dropped connection: the daemon may have exited on its idle lease
351
+ // just as this request went out, and the retry starts a new one.
352
+ for (let attempt = 0; ; attempt++) {
353
+ try {
354
+ const client = await daemonClient();
355
+ const result = await client.request<T>(role, args, lane);
356
+ daemonState.requests++;
357
+ connectedPid = client.daemonPid;
358
+ return result;
359
+ } catch (err) {
360
+ if (err instanceof DaemonRemoteError) throw err;
361
+ if (attempt === 0 && reasonOf(err) === 'disconnected') continue;
362
+ // Queued too long is busy, not down: this one request runs here, and the next one
363
+ // goes to the daemon again.
364
+ if (reasonOf(err) === 'queue-timeout') { daemonState.queueTimeouts++; return dispatchToWorker<T>(role, args); }
365
+ noteFallback(reasonOf(err), err);
366
+ return dispatchToWorker<T>(role, args);
367
+ }
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Eager warm through the daemon: it loads its models; this process loads none. On
373
+ * failure this process enters the fallback window (so the first recall does not pay
374
+ * the start timeout again) but loads nothing now: the first real request does that, if
375
+ * the daemon is still down by then.
376
+ */
377
+ export async function warmModelDaemon(): Promise<void> {
378
+ try {
379
+ const client = await daemonClient();
380
+ await client.request('warm');
381
+ noteConnected(client);
382
+ } catch (err) {
383
+ if (err instanceof DaemonRemoteError) {
384
+ console.error(`[awm] model daemon could not load its models: ${err.message}`);
385
+ return;
386
+ }
387
+ noteFallback(reasonOf(err), err);
388
+ }
389
+ }
390
+
391
+ export function modelDaemonStatus(): {
392
+ enabled: boolean;
393
+ mode: 'off' | 'daemon' | 'in-process-fallback' | 'not-connected';
394
+ fingerprint: string | null;
395
+ daemonPid: number | null;
396
+ requests: number;
397
+ queueTimeouts: number;
398
+ fallbacks: number;
399
+ lastFallbackReason: string | null;
400
+ lastFallbackAt: string | null;
401
+ } {
402
+ const enabled = isModelDaemonEnabled();
403
+ const mode = !enabled ? 'off' : fallbackActive ? 'in-process-fallback' : connectedPid ? 'daemon' : 'not-connected';
404
+ return {
405
+ enabled, mode,
406
+ fingerprint: enabled ? clientFingerprint : null,
407
+ daemonPid: enabled ? connectedPid : null,
408
+ ...daemonState,
409
+ };
410
+ }
411
+
412
+ /** Test-only: forget the client, the fallback window and the counters. */
413
+ export async function __resetModelDaemonClient(): Promise<void> {
414
+ const c = daemon;
415
+ daemon = null;
416
+ (await c?.catch(() => null))?.close();
417
+ fallbackActive = false;
418
+ retryMs = RETRY_FIRST_MS;
419
+ retryAt = 0;
420
+ probing = false;
421
+ connectedPid = null;
422
+ clientFingerprint = null;
423
+ Object.assign(daemonState, { requests: 0, queueTimeouts: 0, fallbacks: 0, lastFallbackReason: null, lastFallbackAt: null });
424
+ }
425
+
426
+ // --- Public API used by the consumer modules ---
427
+
428
+ export async function dispatchEmbed(args: { texts: string[]; pooling: 'cls' | 'mean'; dimensions: number }, lane: Lane = 'interactive'): Promise<number[][]> {
429
+ return dispatch<number[][]>('embed', args, lane);
430
+ }
431
+
432
+ export async function dispatchRerank(args: { query: string; passages: string[] }): Promise<Array<{ index: number; score: number }>> {
433
+ return dispatch<Array<{ index: number; score: number }>>('rerank', args);
434
+ }
435
+
436
+ export async function dispatchExpand(args: { prompt: string; maxNewTokens: number; noRepeatNgramSize: number }): Promise<string> {
437
+ return dispatch<string>('expand', args);
438
+ }
439
+
440
+ /** Graceful shutdown. Waits up to 2s for queue drain, then terminates. */
441
+ export async function shutdownMLPool(): Promise<void> {
442
+ if (!workers) return;
443
+ const promises: Promise<void>[] = [];
444
+ for (const role of ['embed', 'rerank', 'expand'] as WorkerRole[]) {
445
+ const m = workers[role];
446
+ if (!m.worker) continue;
447
+ const w = m.worker;
448
+ promises.push(new Promise<void>((resolve) => {
449
+ const timeout = setTimeout(() => {
450
+ w.terminate().finally(() => resolve());
451
+ }, 2000);
452
+ w.once('exit', () => { clearTimeout(timeout); resolve(); });
453
+ w.postMessage({ shutdown: true });
454
+ }));
455
+ }
456
+ await Promise.all(promises);
457
+ workers = null;
458
+ }