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.
- package/README.md +8 -5
- package/dist/adapters/common.d.ts +34 -0
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +528 -446
- package/dist/adapters/common.js.map +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +19 -0
- package/dist/api/routes.js.map +1 -1
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +31 -29
- package/dist/cli/migrate.js.map +1 -1
- package/dist/core/asked-value.d.ts +37 -0
- package/dist/core/asked-value.d.ts.map +1 -0
- package/dist/core/asked-value.js +77 -0
- package/dist/core/asked-value.js.map +1 -0
- package/dist/core/conflicts.d.ts +89 -0
- package/dist/core/conflicts.d.ts.map +1 -0
- package/dist/core/conflicts.js +267 -0
- package/dist/core/conflicts.js.map +1 -0
- package/dist/core/embeddings.d.ts +14 -2
- package/dist/core/embeddings.d.ts.map +1 -1
- package/dist/core/embeddings.js +32 -10
- package/dist/core/embeddings.js.map +1 -1
- package/dist/core/format-recall.d.ts +21 -1
- package/dist/core/format-recall.d.ts.map +1 -1
- package/dist/core/format-recall.js +60 -5
- package/dist/core/format-recall.js.map +1 -1
- package/dist/core/ml-worker.d.ts +24 -1
- package/dist/core/ml-worker.d.ts.map +1 -1
- package/dist/core/ml-worker.js +165 -4
- package/dist/core/ml-worker.js.map +1 -1
- package/dist/core/model-daemon-client.d.ts +77 -0
- package/dist/core/model-daemon-client.d.ts.map +1 -0
- package/dist/core/model-daemon-client.js +383 -0
- package/dist/core/model-daemon-client.js.map +1 -0
- package/dist/core/model-daemon-server.d.ts +71 -0
- package/dist/core/model-daemon-server.d.ts.map +1 -0
- package/dist/core/model-daemon-server.js +363 -0
- package/dist/core/model-daemon-server.js.map +1 -0
- package/dist/core/model-daemon.d.ts +137 -0
- package/dist/core/model-daemon.d.ts.map +1 -0
- package/dist/core/model-daemon.js +231 -0
- package/dist/core/model-daemon.js.map +1 -0
- package/dist/core/query-expander.d.ts +5 -0
- package/dist/core/query-expander.d.ts.map +1 -1
- package/dist/core/query-expander.js +3 -2
- package/dist/core/query-expander.js.map +1 -1
- package/dist/core/recall-config.d.ts +1 -1
- package/dist/core/recall-config.d.ts.map +1 -1
- package/dist/core/recall-config.js +5 -0
- package/dist/core/recall-config.js.map +1 -1
- package/dist/core/rerank-window.d.ts +7 -2
- package/dist/core/rerank-window.d.ts.map +1 -1
- package/dist/core/rerank-window.js +9 -4
- package/dist/core/rerank-window.js.map +1 -1
- package/dist/core/rerank2.d.ts +52 -2
- package/dist/core/rerank2.d.ts.map +1 -1
- package/dist/core/rerank2.js +60 -3
- package/dist/core/rerank2.js.map +1 -1
- package/dist/core/reranker.d.ts +19 -0
- package/dist/core/reranker.d.ts.map +1 -1
- package/dist/core/reranker.js +90 -13
- package/dist/core/reranker.js.map +1 -1
- package/dist/core/trust.d.ts +32 -0
- package/dist/core/trust.d.ts.map +1 -0
- package/dist/core/trust.js +66 -0
- package/dist/core/trust.js.map +1 -0
- package/dist/core/validity.d.ts +21 -0
- package/dist/core/validity.d.ts.map +1 -0
- package/dist/core/validity.js +41 -0
- package/dist/core/validity.js.map +1 -0
- package/dist/core/whoami.d.ts +3 -0
- package/dist/core/whoami.d.ts.map +1 -1
- package/dist/core/whoami.js +14 -0
- package/dist/core/whoami.js.map +1 -1
- package/dist/core/write-pipeline.d.ts +48 -0
- package/dist/core/write-pipeline.d.ts.map +1 -1
- package/dist/core/write-pipeline.js +80 -3
- package/dist/core/write-pipeline.js.map +1 -1
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +27 -4
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +53 -4
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/hooks/prime-query.d.ts +57 -0
- package/dist/hooks/prime-query.d.ts.map +1 -0
- package/dist/hooks/prime-query.js +388 -0
- package/dist/hooks/prime-query.js.map +1 -0
- package/dist/hooks/prime.d.ts +8 -0
- package/dist/hooks/prime.d.ts.map +1 -1
- package/dist/hooks/prime.js +21 -2
- package/dist/hooks/prime.js.map +1 -1
- package/dist/hooks/sidecar.d.ts +18 -0
- package/dist/hooks/sidecar.d.ts.map +1 -1
- package/dist/hooks/sidecar.js +58 -2
- package/dist/hooks/sidecar.js.map +1 -1
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +157 -104
- package/dist/mcp.js.map +1 -1
- package/dist/model-daemon.d.ts +8 -0
- package/dist/model-daemon.d.ts.map +1 -0
- package/dist/model-daemon.js +45 -0
- package/dist/model-daemon.js.map +1 -0
- package/dist/storage/factory.d.ts.map +1 -1
- package/dist/storage/factory.js +5 -0
- package/dist/storage/factory.js.map +1 -1
- package/dist/storage/pglite-schema.d.ts.map +1 -1
- package/dist/storage/pglite-schema.js +176 -170
- package/dist/storage/pglite-schema.js.map +1 -1
- package/dist/storage/pglite.d.ts +5 -0
- package/dist/storage/pglite.d.ts.map +1 -1
- package/dist/storage/pglite.js +165 -153
- package/dist/storage/pglite.js.map +1 -1
- package/dist/storage/postgres.d.ts +5 -0
- package/dist/storage/postgres.d.ts.map +1 -1
- package/dist/storage/postgres.js +165 -153
- package/dist/storage/postgres.js.map +1 -1
- package/dist/storage/sqlite.d.ts +22 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +415 -363
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/engram.d.ts +33 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/engram.js.map +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/README.md +1 -1
- package/plugin/bin/awm-mcp-launcher.cjs +2 -2
- package/plugin/skills/awm-memory/SKILL.md +11 -2
- package/src/adapters/common.ts +881 -802
- package/src/api/routes.ts +20 -0
- package/src/cli/migrate.ts +309 -307
- package/src/core/asked-value.ts +74 -0
- package/src/core/conflicts.ts +280 -0
- package/src/core/embeddings.ts +35 -10
- package/src/core/format-recall.ts +83 -25
- package/src/core/ml-worker.ts +458 -281
- package/src/core/model-daemon-client.ts +374 -0
- package/src/core/model-daemon-server.ts +344 -0
- package/src/core/model-daemon.ts +243 -0
- package/src/core/query-expander.ts +127 -126
- package/src/core/recall-config.ts +5 -0
- package/src/core/rerank-window.ts +164 -159
- package/src/core/rerank2.ts +143 -82
- package/src/core/reranker.ts +92 -12
- package/src/core/trust.ts +65 -0
- package/src/core/validity.ts +47 -0
- package/src/core/whoami.ts +136 -120
- package/src/core/write-pipeline.ts +119 -3
- package/src/engine/activation.ts +27 -4
- package/src/engine/consolidation.ts +950 -899
- package/src/hooks/prime-query.ts +375 -0
- package/src/hooks/prime.ts +25 -2
- package/src/hooks/sidecar.ts +57 -3
- package/src/index.ts +258 -248
- package/src/mcp.ts +1529 -1475
- package/src/model-daemon.ts +48 -0
- package/src/storage/factory.ts +25 -19
- package/src/storage/pglite-schema.ts +199 -193
- package/src/storage/pglite.ts +1513 -1498
- package/src/storage/postgres.ts +1613 -1598
- package/src/storage/sqlite.ts +2069 -2019
- package/src/types/engram.ts +30 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* The shared model daemon process (see model-daemon.ts for the design).
|
|
5
|
+
*
|
|
6
|
+
* It runs ONE inference at a time. onnxruntime-node's CPU arena grows to the largest
|
|
7
|
+
* tensor a session has run, so concurrent runs on the same session would multiply the
|
|
8
|
+
* high-water mark this daemon exists to bound. One at a time costs no speed: four
|
|
9
|
+
* processes recalling at once on one machine finished in 12.3 min with their own models
|
|
10
|
+
* and 11.6 min through one daemon, because they compete for the same cores anyway.
|
|
11
|
+
*
|
|
12
|
+
* Scheduling: interactive requests run before background ones. An embed is run one
|
|
13
|
+
* chunk at a time; an unfinished interactive embed goes to the back of its lane, so
|
|
14
|
+
* sessions take turns and one session's 100-memory write-batch cannot hold up another
|
|
15
|
+
* session's recall; an unfinished background embed goes to the front of its lane, so
|
|
16
|
+
* its chunks stay in order. A request whose connection has closed is dropped unrun.
|
|
17
|
+
* Each step reports `started` when it begins, and a job sent back to the queue between
|
|
18
|
+
* chunks reports `requeued`, so a client's run timer covers only time the daemon spends
|
|
19
|
+
* on its job: its timeout measures a stuck daemon, never a busy one. A step that runs
|
|
20
|
+
* longer than AWM_MODEL_DAEMON_STUCK_MS (default: the client's step timeout, 2 min; for
|
|
21
|
+
* a warm, which may download the models, the client's 10 min) means the daemon is wedged
|
|
22
|
+
* on a promise that will never settle: it exits, and the next client starts a fresh one.
|
|
23
|
+
* The two limits are the same so that a client that gave up on a step is never left
|
|
24
|
+
* reconnecting to the daemon still stuck on it.
|
|
25
|
+
*
|
|
26
|
+
* The daemon exits when no authenticated client has been connected for
|
|
27
|
+
* AWM_MODEL_DAEMON_IDLE_MS (default 5 min). Clients hold one connection each for the
|
|
28
|
+
* life of their process.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createServer, connect, type Server, type Socket } from 'node:net';
|
|
32
|
+
import { randomBytes } from 'node:crypto';
|
|
33
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
35
|
+
import {
|
|
36
|
+
PROTOCOL_VERSION, HELLO_MAX_BYTES, MAX_FRAME_BYTES, addressProblem, encodeFrame, envInt, FrameReader,
|
|
37
|
+
modelDaemonPaths, proof, proofMatches, runDirProblem, REQUEST_TIMEOUT_MS, WARM_TIMEOUT_MS,
|
|
38
|
+
type AuthFrame, type HelloFrame, type Lane, type RequestFrame, type ResponseFrame,
|
|
39
|
+
} from './model-daemon.js';
|
|
40
|
+
import { VERSION } from '../version.js';
|
|
41
|
+
|
|
42
|
+
export interface DaemonHandlers {
|
|
43
|
+
embed(args: { texts: string[]; pooling: 'cls' | 'mean'; dimensions: number }): Promise<number[][]>;
|
|
44
|
+
rerank(args: { query: string; passages: string[] }): Promise<Array<{ index: number; score: number }>>;
|
|
45
|
+
expand(args: { prompt: string; maxNewTokens: number; noRepeatNgramSize: number }): Promise<string>;
|
|
46
|
+
warm(): Promise<void>;
|
|
47
|
+
/** Texts per scheduling step for an embed; 0 = the whole request at once. */
|
|
48
|
+
embedChunk: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DaemonOptions {
|
|
52
|
+
fingerprint: string;
|
|
53
|
+
runDir?: string;
|
|
54
|
+
idleMs?: number;
|
|
55
|
+
handlers: DaemonHandlers;
|
|
56
|
+
log?: (msg: string) => void;
|
|
57
|
+
/** Called when the idle lease runs out, after the server has closed. The entry exits. */
|
|
58
|
+
onIdle?: () => void;
|
|
59
|
+
/** A step running longer than this is wedged (a warm gets WARM_TIMEOUT_MS at least). */
|
|
60
|
+
stuckMs?: number;
|
|
61
|
+
/** Called when a step is wedged. The entry exits with an error. */
|
|
62
|
+
onStuck?: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Job {
|
|
66
|
+
socket: Socket;
|
|
67
|
+
id: number;
|
|
68
|
+
op: RequestFrame['op'];
|
|
69
|
+
lane: Lane;
|
|
70
|
+
args: any;
|
|
71
|
+
/** Embed: the next slice to run and the vectors so far. */
|
|
72
|
+
offset?: number;
|
|
73
|
+
vectors?: number[][];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RunningDaemon {
|
|
77
|
+
server: Server;
|
|
78
|
+
address: string;
|
|
79
|
+
fingerprint: string;
|
|
80
|
+
close(): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const HELLO_TIMEOUT_MS = 5_000;
|
|
84
|
+
|
|
85
|
+
/** Windows refuses a rename over a file another process has open; clients open it briefly. */
|
|
86
|
+
async function replaceFile(tmp: string, target: string): Promise<void> {
|
|
87
|
+
for (let attempt = 0; ; attempt++) {
|
|
88
|
+
try { renameSync(tmp, target); return; } catch (err) {
|
|
89
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
90
|
+
if ((code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES') || attempt >= 40) {
|
|
91
|
+
try { unlinkSync(tmp); } catch { /* nothing to clean */ }
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
await delay(25);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A unix socket file left by a crashed daemon refuses connections; a live one accepts. */
|
|
100
|
+
function removeStaleSocket(address: string): Promise<void> {
|
|
101
|
+
if (process.platform === 'win32' || !existsSync(address)) return Promise.resolve();
|
|
102
|
+
return new Promise(resolve => {
|
|
103
|
+
const probe = connect(address);
|
|
104
|
+
probe.once('connect', () => { probe.destroy(); resolve(); });
|
|
105
|
+
probe.once('error', () => { try { unlinkSync(address); } catch { /* raced */ } resolve(); });
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function startModelDaemon(opts: DaemonOptions): Promise<RunningDaemon> {
|
|
110
|
+
const { fingerprint } = opts;
|
|
111
|
+
const paths = modelDaemonPaths(fingerprint, opts.runDir);
|
|
112
|
+
const idleMs = opts.idleMs ?? envInt('AWM_MODEL_DAEMON_IDLE_MS', 300_000);
|
|
113
|
+
const log = opts.log ?? ((m: string) => console.error(`[model-daemon ${new Date().toISOString()}] ${m}`));
|
|
114
|
+
const h = opts.handlers;
|
|
115
|
+
|
|
116
|
+
const problem = addressProblem(paths);
|
|
117
|
+
if (problem) throw new Error(problem);
|
|
118
|
+
mkdirSync(paths.runDir, { recursive: true, mode: 0o700 });
|
|
119
|
+
try { chmodSync(paths.runDir, 0o700); } catch { /* not ours to change, or Windows */ }
|
|
120
|
+
const unsafe = runDirProblem(paths.runDir);
|
|
121
|
+
if (unsafe) throw new Error(unsafe);
|
|
122
|
+
const stuckMs = opts.stuckMs ?? envInt('AWM_MODEL_DAEMON_STUCK_MS', envInt('AWM_MODEL_DAEMON_TIMEOUT_MS', REQUEST_TIMEOUT_MS));
|
|
123
|
+
await removeStaleSocket(paths.address);
|
|
124
|
+
|
|
125
|
+
const token = randomBytes(32).toString('hex');
|
|
126
|
+
const sockets = new Set<Socket>(); // every connection, authenticated or not
|
|
127
|
+
/** Requests a client gave up on, per connection: never started, or never continued. */
|
|
128
|
+
const cancelled = new WeakMap<Socket, Set<number>>();
|
|
129
|
+
const isCancelled = (job: Job) => cancelled.get(job.socket)?.has(job.id) ?? false;
|
|
130
|
+
/** Until the token file is ours, a hello is told to retry, not proved with a token the
|
|
131
|
+
* file does not hold yet (the rename may be retried on Windows). */
|
|
132
|
+
let tokenReady = false;
|
|
133
|
+
const clients = new Set<Socket>(); // authenticated ones: these hold the lease
|
|
134
|
+
const interactive: Job[] = [];
|
|
135
|
+
const background: Job[] = [];
|
|
136
|
+
const served: Record<string, number> = {};
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
let busy = false;
|
|
139
|
+
let current: { op: string; since: number } | null = null;
|
|
140
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
141
|
+
let closing: Promise<void> | null = null;
|
|
142
|
+
|
|
143
|
+
const send = (socket: Socket, frame: ResponseFrame) => {
|
|
144
|
+
if (!socket.destroyed) socket.write(encodeFrame(frame));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const armIdle = () => {
|
|
148
|
+
if (idleTimer || clients.size > 0 || idleMs <= 0 || closing) return;
|
|
149
|
+
idleTimer = setTimeout(() => {
|
|
150
|
+
idleTimer = null;
|
|
151
|
+
if (clients.size > 0) return;
|
|
152
|
+
// A step still running (a first-run warm downloading the models) finishes first.
|
|
153
|
+
if (current || interactive.length || background.length) { armIdle(); return; }
|
|
154
|
+
log(`no clients for ${idleMs} ms, exiting`);
|
|
155
|
+
void running.close().then(() => (opts.onIdle ?? (() => process.exit(0)))());
|
|
156
|
+
}, idleMs);
|
|
157
|
+
idleTimer.unref?.();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Runs one step of a job. Returns true when the job is finished. */
|
|
161
|
+
async function step(job: Job): Promise<boolean> {
|
|
162
|
+
send(job.socket, { id: job.id, started: true });
|
|
163
|
+
if (job.vectors) {
|
|
164
|
+
const texts: string[] = job.args.texts;
|
|
165
|
+
const size = h.embedChunk > 0 ? h.embedChunk : texts.length;
|
|
166
|
+
const slice = texts.slice(job.offset!, job.offset! + size);
|
|
167
|
+
job.vectors.push(...await h.embed({ ...job.args, texts: slice }));
|
|
168
|
+
job.offset! += slice.length;
|
|
169
|
+
if (job.offset! < texts.length) return false;
|
|
170
|
+
send(job.socket, { id: job.id, ok: true, result: job.vectors });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
let result: unknown;
|
|
174
|
+
switch (job.op) {
|
|
175
|
+
case 'embed': result = await h.embed(job.args); break;
|
|
176
|
+
case 'rerank': result = await h.rerank(job.args); break;
|
|
177
|
+
case 'expand': result = await h.expand(job.args); break;
|
|
178
|
+
case 'warm': await h.warm(); result = true; break;
|
|
179
|
+
default: throw new Error(`unknown op ${String(job.op)}`);
|
|
180
|
+
}
|
|
181
|
+
send(job.socket, { id: job.id, ok: true, result });
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function pump(): Promise<void> {
|
|
186
|
+
if (busy) return;
|
|
187
|
+
busy = true;
|
|
188
|
+
try {
|
|
189
|
+
for (;;) {
|
|
190
|
+
const job = interactive.shift() ?? background.shift();
|
|
191
|
+
if (!job) break;
|
|
192
|
+
if (job.socket.destroyed || isCancelled(job)) continue;
|
|
193
|
+
let done = true;
|
|
194
|
+
current = { op: job.op, since: Date.now() };
|
|
195
|
+
try {
|
|
196
|
+
done = await step(job);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
send(job.socket, { id: job.id, ok: false, error: (err as Error)?.message ?? String(err) });
|
|
199
|
+
} finally {
|
|
200
|
+
current = null;
|
|
201
|
+
}
|
|
202
|
+
if (done) { served[job.op] = (served[job.op] ?? 0) + 1; continue; }
|
|
203
|
+
// Back in the queue between chunks: the client's run timer stops until the next
|
|
204
|
+
// step starts, because waiting here is the daemon being busy, not stuck.
|
|
205
|
+
if (isCancelled(job)) continue;
|
|
206
|
+
send(job.socket, { id: job.id, requeued: true });
|
|
207
|
+
if (job.lane === 'interactive') interactive.push(job);
|
|
208
|
+
else background.unshift(job);
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
busy = false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const onRequest = (socket: Socket, frame: RequestFrame) => {
|
|
216
|
+
if (typeof frame?.id !== 'number') return;
|
|
217
|
+
if (frame.op === 'cancel') {
|
|
218
|
+
// The client gave up waiting: drop the job, or stop it after its running step.
|
|
219
|
+
const id = (frame.args as any)?.id;
|
|
220
|
+
if (typeof id !== 'number') return;
|
|
221
|
+
let set = cancelled.get(socket);
|
|
222
|
+
if (!set) cancelled.set(socket, (set = new Set()));
|
|
223
|
+
if (set.size >= 10_000) set.clear();
|
|
224
|
+
set.add(id);
|
|
225
|
+
for (const q of [interactive, background]) {
|
|
226
|
+
const i = q.findIndex(j => j.socket === socket && j.id === id);
|
|
227
|
+
if (i >= 0) q.splice(i, 1);
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (frame.op === 'stats') {
|
|
232
|
+
send(socket, {
|
|
233
|
+
id: frame.id, ok: true, result: {
|
|
234
|
+
pid: process.pid, fingerprint, version: VERSION, uptimeMs: Date.now() - startedAt,
|
|
235
|
+
clients: clients.size, queued: { interactive: interactive.length, background: background.length },
|
|
236
|
+
served, rssBytes: process.memoryUsage().rss,
|
|
237
|
+
current: current ? { op: current.op, runningMs: Date.now() - current.since } : null,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const lane: Lane = frame.lane === 'background' ? 'background' : 'interactive';
|
|
243
|
+
const job: Job = { socket, id: frame.id, op: frame.op, lane, args: frame.args };
|
|
244
|
+
if (frame.op === 'embed' && Array.isArray((frame.args as any)?.texts)) {
|
|
245
|
+
job.offset = 0;
|
|
246
|
+
job.vectors = [];
|
|
247
|
+
}
|
|
248
|
+
(lane === 'background' ? background : interactive).push(job);
|
|
249
|
+
void pump();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const server = createServer(socket => {
|
|
253
|
+
sockets.add(socket);
|
|
254
|
+
socket.setEncoding('utf8');
|
|
255
|
+
let stage: 'hello' | 'auth' | 'ready' = 'hello';
|
|
256
|
+
let clientNonce = '';
|
|
257
|
+
let daemonNonce = '';
|
|
258
|
+
const helloTimer = setTimeout(() => socket.destroy(), HELLO_TIMEOUT_MS);
|
|
259
|
+
const refuse = (error: string) => { clearTimeout(helloTimer); socket.end(encodeFrame({ t: 'hello', ok: false, error })); };
|
|
260
|
+
const reader = new FrameReader(frame => {
|
|
261
|
+
if (stage === 'ready') return onRequest(socket, frame as RequestFrame);
|
|
262
|
+
if (closing) return refuse('closing');
|
|
263
|
+
if (!tokenReady) return refuse('starting');
|
|
264
|
+
if (stage === 'hello') {
|
|
265
|
+
const hello = frame as HelloFrame;
|
|
266
|
+
if (hello?.t !== 'hello') return refuse('expected hello');
|
|
267
|
+
if (hello.protocol !== PROTOCOL_VERSION) return refuse(`protocol ${hello.protocol} != ${PROTOCOL_VERSION}`);
|
|
268
|
+
if (hello.fingerprint !== fingerprint) return refuse('fingerprint mismatch');
|
|
269
|
+
if (typeof hello.nonce !== 'string' || hello.nonce.length < 32) return refuse('bad nonce');
|
|
270
|
+
clientNonce = hello.nonce;
|
|
271
|
+
daemonNonce = randomBytes(16).toString('hex');
|
|
272
|
+
stage = 'auth';
|
|
273
|
+
socket.write(encodeFrame({
|
|
274
|
+
t: 'challenge', nonce: daemonNonce, pid: process.pid, version: VERSION,
|
|
275
|
+
proof: proof(token, 'daemon', clientNonce, daemonNonce, fingerprint),
|
|
276
|
+
}));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const auth = frame as AuthFrame;
|
|
280
|
+
if (auth?.t !== 'auth' || !proofMatches(proof(token, 'client', clientNonce, daemonNonce, fingerprint), auth.proof)) {
|
|
281
|
+
return refuse('bad token');
|
|
282
|
+
}
|
|
283
|
+
clearTimeout(helloTimer);
|
|
284
|
+
stage = 'ready';
|
|
285
|
+
reader.limit = MAX_FRAME_BYTES;
|
|
286
|
+
clients.add(socket);
|
|
287
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
288
|
+
socket.write(encodeFrame({ t: 'hello', ok: true }));
|
|
289
|
+
}, HELLO_MAX_BYTES);
|
|
290
|
+
socket.on('data', (chunk: string) => { if (!reader.push(chunk)) socket.destroy(); });
|
|
291
|
+
socket.on('error', () => { /* the close handler cleans up */ });
|
|
292
|
+
socket.on('close', () => {
|
|
293
|
+
clearTimeout(helloTimer);
|
|
294
|
+
sockets.delete(socket);
|
|
295
|
+
if (clients.delete(socket)) armIdle();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await new Promise<void>((resolve, reject) => {
|
|
300
|
+
server.once('error', reject);
|
|
301
|
+
server.listen(paths.address, () => { server.off('error', reject); resolve(); });
|
|
302
|
+
});
|
|
303
|
+
if (process.platform !== 'win32') { try { chmodSync(paths.address, 0o600); } catch { /* best effort */ } }
|
|
304
|
+
// The token is written only once this daemon owns the address. A second daemon that
|
|
305
|
+
// lost the race (EADDRINUSE above) must not overwrite the live one's token. A client
|
|
306
|
+
// that connected in the gap read no token or an old one; it is refused and retries.
|
|
307
|
+
const tmp = `${paths.token}.${process.pid}`;
|
|
308
|
+
writeFileSync(tmp, token, { mode: 0o600 });
|
|
309
|
+
try {
|
|
310
|
+
await replaceFile(tmp, paths.token);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
server.close();
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
tokenReady = true;
|
|
316
|
+
log(`listening on ${paths.address} (pid ${process.pid}, AWM ${VERSION}, fingerprint ${fingerprint})`);
|
|
317
|
+
armIdle();
|
|
318
|
+
|
|
319
|
+
const watchdog = stuckMs > 0 ? setInterval(() => {
|
|
320
|
+
if (!current) return;
|
|
321
|
+
const limit = current.op === 'warm' ? Math.max(stuckMs, WARM_TIMEOUT_MS) : stuckMs;
|
|
322
|
+
if (Date.now() - current.since <= limit) return;
|
|
323
|
+
log(`${current.op} has run for ${Date.now() - current.since} ms: wedged, exiting so a fresh daemon can start`);
|
|
324
|
+
if (watchdog) clearInterval(watchdog);
|
|
325
|
+
void running.close().then(() => (opts.onStuck ?? (() => process.exit(1)))());
|
|
326
|
+
}, Math.min(5_000, Math.max(50, Math.floor(stuckMs / 4)))) : null;
|
|
327
|
+
watchdog?.unref?.();
|
|
328
|
+
|
|
329
|
+
const running: RunningDaemon = {
|
|
330
|
+
server, address: paths.address, fingerprint,
|
|
331
|
+
close: () => (closing ??= new Promise<void>(resolve => {
|
|
332
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
333
|
+
if (watchdog) clearInterval(watchdog);
|
|
334
|
+
// Every connection, including one still in its handshake: server.close() waits for
|
|
335
|
+
// all of them, and a late hello must not authenticate against a closing daemon.
|
|
336
|
+
for (const s of sockets) s.destroy();
|
|
337
|
+
server.close(() => {
|
|
338
|
+
try { if (readFileSync(paths.token, 'utf8') === token) unlinkSync(paths.token); } catch { /* already gone */ }
|
|
339
|
+
resolve();
|
|
340
|
+
});
|
|
341
|
+
})),
|
|
342
|
+
};
|
|
343
|
+
return running;
|
|
344
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Shared model daemon: what the client and the daemon agree on.
|
|
5
|
+
*
|
|
6
|
+
* Every AWM MCP process used to load its own three ONNX models (embedder, cross-encoder,
|
|
7
|
+
* query expander): about 1.1 GB per process even with the input-shape caps, so six
|
|
8
|
+
* Claude Code sessions held 6+ GB of identical weights and arenas. With
|
|
9
|
+
* AWM_MODEL_DAEMON=1, one per-user process holds the models and every AWM process on the
|
|
10
|
+
* machine sends inference to it over a local socket. Store, engine and recall stay in
|
|
11
|
+
* each process; only the three dispatch calls in ml-worker.ts cross the socket.
|
|
12
|
+
*
|
|
13
|
+
* This module has no model imports, so a client that only talks to the daemon never
|
|
14
|
+
* loads @huggingface/transformers' models itself.
|
|
15
|
+
*
|
|
16
|
+
* - The daemon is keyed by a FINGERPRINT of the inference config the model modules
|
|
17
|
+
* actually captured (model ids, dtype, dimensions, pooling, shape caps), the model
|
|
18
|
+
* cache dir, the AWM version and package root, the user and the run dir. Two installs,
|
|
19
|
+
* two versions or two configs get separate daemons and can never mix vectors. The
|
|
20
|
+
* spawned daemon is given the client's captured config as env, so a value applied
|
|
21
|
+
* after the modules loaded (a .env file) cannot make the two disagree; the handshake
|
|
22
|
+
* checks the fingerprint again.
|
|
23
|
+
* - Transport: a named pipe on Windows, a unix socket in ~/.awm/run (0700) elsewhere.
|
|
24
|
+
* - Authentication is mutual, by HMAC over two nonces with the token the daemon wrote to
|
|
25
|
+
* the run dir (0600). The token itself never crosses the socket, so a process that
|
|
26
|
+
* took the pipe name first learns nothing and is refused by the client.
|
|
27
|
+
* - Frames are newline-delimited JSON. JSON.stringify escapes every newline inside a
|
|
28
|
+
* value, so a newline only ever ends a frame. Before authentication a connection may
|
|
29
|
+
* send HELLO_MAX_BYTES; after it, MAX_FRAME_BYTES.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
33
|
+
import { statSync } from 'node:fs';
|
|
34
|
+
import { createRequire } from 'node:module';
|
|
35
|
+
import { homedir, userInfo } from 'node:os';
|
|
36
|
+
import { dirname, join, relative, resolve, isAbsolute } from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { VERSION } from '../version.js';
|
|
39
|
+
|
|
40
|
+
export const PROTOCOL_VERSION = 2;
|
|
41
|
+
export const MAX_FRAME_BYTES = 64 * 1024 * 1024;
|
|
42
|
+
export const HELLO_MAX_BYTES = 4 * 1024;
|
|
43
|
+
/** A step must finish within this (client run timer and daemon watchdog both). */
|
|
44
|
+
export const REQUEST_TIMEOUT_MS = 120_000;
|
|
45
|
+
/** The first warm can include a first-run model download (client and daemon both). */
|
|
46
|
+
export const WARM_TIMEOUT_MS = 10 * 60_000;
|
|
47
|
+
/** Exit code of a daemon whose own fingerprint is not the one its spawner expected. */
|
|
48
|
+
export const EXIT_FINGERPRINT_DRIFT = 3;
|
|
49
|
+
/** Longest unix socket path every platform accepts (macOS: 104 bytes with the NUL). */
|
|
50
|
+
const UNIX_SOCKET_MAX = 103;
|
|
51
|
+
|
|
52
|
+
export type ModelOp = 'embed' | 'rerank' | 'expand' | 'warm' | 'stats' | 'cancel';
|
|
53
|
+
/** Interactive work (prime hook, recall, writes) runs ahead of background backfill. */
|
|
54
|
+
export type Lane = 'interactive' | 'background';
|
|
55
|
+
|
|
56
|
+
/** What the model modules captured at import; registered with ml-worker. */
|
|
57
|
+
export type InferenceConfig = Record<string, string | number>;
|
|
58
|
+
|
|
59
|
+
/** Client → daemon, first frame. */
|
|
60
|
+
export interface HelloFrame { t: 'hello'; protocol: number; fingerprint: string; nonce: string }
|
|
61
|
+
/** Daemon → client: its proof, and a refusal carries `error`. */
|
|
62
|
+
export interface ChallengeFrame { t: 'challenge'; nonce: string; proof: string; pid: number; version: string }
|
|
63
|
+
/** Client → daemon: the client's proof. */
|
|
64
|
+
export interface AuthFrame { t: 'auth'; proof: string }
|
|
65
|
+
export interface HelloResult { t: 'hello'; ok: boolean; error?: string }
|
|
66
|
+
export interface RequestFrame { id: number; op: ModelOp; lane?: Lane; args?: unknown }
|
|
67
|
+
/** `started` starts the client's run timer and `requeued` stops it; the final frame has `ok`. */
|
|
68
|
+
export interface ResponseFrame { id: number; ok?: boolean; started?: true; requeued?: true; result?: unknown; error?: string }
|
|
69
|
+
|
|
70
|
+
/** Env names the config is passed to a spawned daemon as. */
|
|
71
|
+
const CONFIG_ENV: Record<string, string> = {
|
|
72
|
+
embedModel: 'AWM_EMBED_MODEL', embedDims: 'AWM_EMBED_DIMS', embedPooling: 'AWM_EMBED_POOLING',
|
|
73
|
+
embedBatch: 'AWM_EMBED_BATCH', rerankModel: 'AWM_RERANKER_MODEL', rerankDtype: 'AWM_RERANKER_DTYPE',
|
|
74
|
+
rerankBatch: 'AWM_RERANK_BATCH', rerankQueryTokens: 'AWM_RERANK_QUERY_TOKENS',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function configEnv(config: InferenceConfig): Record<string, string> {
|
|
78
|
+
const env: Record<string, string> = {};
|
|
79
|
+
for (const [k, name] of Object.entries(CONFIG_ENV)) if (config[k] !== undefined) env[name] = String(config[k]);
|
|
80
|
+
return env;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeUser(): string {
|
|
84
|
+
try { return userInfo().username; } catch { return process.env.USERNAME ?? process.env.USER ?? ''; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function packageRoot(): string {
|
|
88
|
+
// src/core/model-daemon.ts (dev) and dist/core/model-daemon.js (build): two levels up.
|
|
89
|
+
return resolve(join(dirname(fileURLToPath(import.meta.url)), '..', '..'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function defaultRunDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
93
|
+
return env.AWM_RUN_DIR || join(homedir(), '.awm', 'run');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Size and mtime of the modules whose code decides what inference returns. A rebuild or
|
|
98
|
+
* a dev edit without a version bump is a different daemon, not the old one serving old
|
|
99
|
+
* code to new clients. The client and the daemon run from the same files, so they agree.
|
|
100
|
+
*/
|
|
101
|
+
export function codeStamp(): string {
|
|
102
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
103
|
+
const ext = import.meta.url.endsWith('.ts') ? '.ts' : '.js';
|
|
104
|
+
const files = ['embeddings', 'reranker', 'query-expander', 'model-daemon', 'model-daemon-server', 'ml-worker', 'rerank-window']
|
|
105
|
+
.map(m => { try { const s = statSync(join(here, m + ext)); return `${m}:${s.size}:${Math.floor(s.mtimeMs)}`; } catch { return `${m}:-`; } });
|
|
106
|
+
// An `npm update` of the inference libraries changes results without an AWM version bump.
|
|
107
|
+
const req = createRequire(import.meta.url);
|
|
108
|
+
const libs = ['@huggingface/transformers', 'onnxruntime-node'].map(name => {
|
|
109
|
+
// Not `${name}/package.json`: a package's "exports" may not expose it.
|
|
110
|
+
try {
|
|
111
|
+
for (let dir = dirname(req.resolve(name)); dir !== dirname(dir); dir = dirname(dir)) {
|
|
112
|
+
try {
|
|
113
|
+
const pkg = req(join(dir, 'package.json'));
|
|
114
|
+
if (pkg.name === name) return `${name}@${pkg.version}`;
|
|
115
|
+
} catch { /* keep walking up */ }
|
|
116
|
+
}
|
|
117
|
+
} catch { /* not installed */ }
|
|
118
|
+
return `${name}@-`;
|
|
119
|
+
});
|
|
120
|
+
return [...files, ...libs].join(',');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 16 hex chars. Everything is a parameter so tests can compare configs in one process. */
|
|
124
|
+
export function modelDaemonFingerprint(
|
|
125
|
+
config: InferenceConfig,
|
|
126
|
+
o: { env?: NodeJS.ProcessEnv; version?: string; root?: string; code?: string } = {},
|
|
127
|
+
): string {
|
|
128
|
+
const env = o.env ?? process.env;
|
|
129
|
+
const parts = [
|
|
130
|
+
`protocol=${PROTOCOL_VERSION}`, `version=${o.version ?? VERSION}`, `root=${o.root ?? packageRoot()}`,
|
|
131
|
+
`code=${o.code ?? codeStamp()}`,
|
|
132
|
+
`user=${safeUser()}`, `home=${homedir()}`, `run=${defaultRunDir(env)}`,
|
|
133
|
+
// Read lazily by model-cache.ts, so the env at this point is the env it would use.
|
|
134
|
+
`cache=${env.AWM_CACHE_DIR || env.HF_HOME || ''}`,
|
|
135
|
+
...Object.keys(config).sort().map(k => `${k}=${config[k]}`),
|
|
136
|
+
];
|
|
137
|
+
return createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 16);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface DaemonPaths {
|
|
141
|
+
runDir: string;
|
|
142
|
+
/** Pipe name on Windows, socket path elsewhere. */
|
|
143
|
+
address: string;
|
|
144
|
+
token: string;
|
|
145
|
+
lock: string;
|
|
146
|
+
log: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function modelDaemonPaths(fingerprint: string, runDir: string = defaultRunDir()): DaemonPaths {
|
|
150
|
+
const base = join(runDir, `models-${fingerprint}`);
|
|
151
|
+
return {
|
|
152
|
+
runDir,
|
|
153
|
+
// Windows pipe names are one machine-wide namespace; the fingerprint includes the
|
|
154
|
+
// user, home and run dir, so two users never share a name by accident.
|
|
155
|
+
address: process.platform === 'win32' ? `\\\\.\\pipe\\awm-models-${fingerprint}` : `${base}.sock`,
|
|
156
|
+
token: `${base}.token`,
|
|
157
|
+
lock: `${base}.lock`,
|
|
158
|
+
log: `${base}.log`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A unix socket path the OS will refuse, or null. */
|
|
163
|
+
export function addressProblem(paths: DaemonPaths): string | null {
|
|
164
|
+
if (process.platform === 'win32') return null;
|
|
165
|
+
return Buffer.byteLength(paths.address) > UNIX_SOCKET_MAX
|
|
166
|
+
? `socket path is ${Buffer.byteLength(paths.address)} bytes (max ${UNIX_SOCKET_MAX}); set AWM_RUN_DIR to a shorter directory you own (not a shared one like /tmp)`
|
|
167
|
+
: null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A run dir another user can write to, or that this user does not own, is refused off
|
|
172
|
+
* Windows: the token and the log are written there, and another user could plant a
|
|
173
|
+
* symlink in their place. (On Windows the dir sits under the user's profile, whose ACL
|
|
174
|
+
* already excludes other users.)
|
|
175
|
+
*/
|
|
176
|
+
export function runDirProblem(runDir: string): string | null {
|
|
177
|
+
if (process.platform === 'win32') {
|
|
178
|
+
// No uid to compare. The profile's ACL excludes other users; a dir outside it
|
|
179
|
+
// (C:\Users\Public, a shared drive) may not.
|
|
180
|
+
const rel = relative(homedir(), resolve(runDir));
|
|
181
|
+
return rel && (rel.startsWith('..') || isAbsolute(rel)) ? `run dir ${runDir} is outside your user profile` : null;
|
|
182
|
+
}
|
|
183
|
+
if (typeof process.getuid !== 'function') return null;
|
|
184
|
+
try {
|
|
185
|
+
const s = statSync(runDir);
|
|
186
|
+
if (s.uid !== process.getuid()) return `run dir ${runDir} is not owned by this user`;
|
|
187
|
+
if (s.mode & 0o022) return `run dir ${runDir} is writable by other users`;
|
|
188
|
+
return null;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
return `run dir ${runDir}: ${(err as Error).message}`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function proof(token: string, role: 'daemon' | 'client', clientNonce: string, daemonNonce: string, fingerprint: string): string {
|
|
195
|
+
return createHmac('sha256', token).update(`${role}|${clientNonce}|${daemonNonce}|${fingerprint}`).digest('hex');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function proofMatches(expected: string, got: unknown): boolean {
|
|
199
|
+
if (typeof got !== 'string' || got.length !== expected.length) return false;
|
|
200
|
+
return timingSafeEqual(Buffer.from(expected), Buffer.from(got));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Splits a byte stream into frames. push() returns false when the unterminated tail is
|
|
205
|
+
* over `limit`. Chunks without a newline are kept as a list and only the new chunk is
|
|
206
|
+
* searched, so a long frame arriving in many chunks costs linear time: appending to one
|
|
207
|
+
* string and searching it again would copy the whole buffer on every chunk.
|
|
208
|
+
*/
|
|
209
|
+
export class FrameReader {
|
|
210
|
+
private parts: string[] = [];
|
|
211
|
+
private size = 0;
|
|
212
|
+
constructor(private readonly onFrame: (frame: unknown) => void, public limit = MAX_FRAME_BYTES) {}
|
|
213
|
+
|
|
214
|
+
push(chunk: string): boolean {
|
|
215
|
+
let nl: number;
|
|
216
|
+
while ((nl = chunk.indexOf('\n')) >= 0) {
|
|
217
|
+
const line = this.parts.length ? this.parts.join('') + chunk.slice(0, nl) : chunk.slice(0, nl);
|
|
218
|
+
this.parts = [];
|
|
219
|
+
this.size = 0;
|
|
220
|
+
chunk = chunk.slice(nl + 1);
|
|
221
|
+
if (!line) continue;
|
|
222
|
+
let frame: unknown;
|
|
223
|
+
try { frame = JSON.parse(line); } catch { return false; }
|
|
224
|
+
this.onFrame(frame);
|
|
225
|
+
}
|
|
226
|
+
if (chunk) { this.parts.push(chunk); this.size += chunk.length; }
|
|
227
|
+
return this.size <= this.limit;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function encodeFrame(frame: unknown): string {
|
|
232
|
+
return `${JSON.stringify(frame)}\n`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function isModelDaemonEnabled(): boolean {
|
|
236
|
+
return process.env.AWM_MODEL_DAEMON === '1';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Non-negative integer env var; anything else falls back (an explicit 0 is kept). */
|
|
240
|
+
export function envInt(name: string, fallback: number): number {
|
|
241
|
+
const v = Number(process.env[name]?.trim() || NaN);
|
|
242
|
+
return Number.isInteger(v) && v >= 0 ? v : fallback;
|
|
243
|
+
}
|