ai-runtime-engine 1.3.0 → 2.7.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/CHANGELOG.md +578 -0
- package/dist/agents/admit.d.ts +69 -0
- package/dist/agents/admit.js +129 -0
- package/dist/agents/definition.d.ts +36 -0
- package/dist/agents/definition.js +9 -0
- package/dist/agents/envelope.d.ts +53 -0
- package/dist/agents/envelope.js +68 -0
- package/dist/agents/finding.d.ts +79 -0
- package/dist/agents/finding.js +80 -0
- package/dist/agents/task.d.ts +60 -0
- package/dist/agents/task.js +32 -0
- package/dist/agents/worker.d.ts +68 -0
- package/dist/agents/worker.js +256 -0
- package/dist/capabilities/capability.d.ts +117 -0
- package/dist/capabilities/capability.js +66 -0
- package/dist/capabilities/registry.d.ts +139 -0
- package/dist/capabilities/registry.js +413 -0
- package/dist/capabilities/vocabulary.d.ts +32 -0
- package/dist/capabilities/vocabulary.js +34 -0
- package/dist/cli/cli.js +55 -4
- package/dist/cli/commands/cleanup.js +29 -27
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +38 -8
- package/dist/cli/commands/executions.js +34 -25
- package/dist/cli/commands/info.d.ts +1 -0
- package/dist/cli/commands/info.js +11 -9
- package/dist/cli/commands/init.js +19 -0
- package/dist/cli/commands/inspect.d.ts +40 -1
- package/dist/cli/commands/inspect.js +157 -2
- package/dist/cli/commands/mcp.d.ts +45 -0
- package/dist/cli/commands/mcp.js +148 -0
- package/dist/cli/commands/route.js +21 -0
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +21 -2
- package/dist/cli/commands/skills.d.ts +2 -0
- package/dist/cli/commands/skills.js +29 -7
- package/dist/cli/interactive/ansi.d.ts +41 -0
- package/dist/cli/interactive/ansi.js +43 -0
- package/dist/cli/interactive/complete.d.ts +10 -0
- package/dist/cli/interactive/complete.js +19 -0
- package/dist/cli/interactive/repl.d.ts +3 -0
- package/dist/cli/interactive/repl.js +91 -13
- package/dist/cli/interactive/session.d.ts +8 -0
- package/dist/cli/interactive/session.js +73 -2
- package/dist/cli/render.d.ts +7 -0
- package/dist/cli/render.js +10 -0
- package/dist/cli/runtimeSession.d.ts +11 -0
- package/dist/cli/runtimeSession.js +17 -0
- package/dist/config/defaults.d.ts +3 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +2 -2
- package/dist/context/lossVerifier.d.ts +24 -0
- package/dist/context/lossVerifier.js +45 -0
- package/dist/context/summarize.d.ts +19 -0
- package/dist/context/summarize.js +53 -0
- package/dist/core/fallback/fallback.d.ts +5 -0
- package/dist/core/fallback/fallback.js +3 -1
- package/dist/core/router/router.d.ts +3 -0
- package/dist/core/router/router.js +1 -0
- package/dist/executions/execution.d.ts +13 -2
- package/dist/generation/generateAdapter.d.ts +14 -0
- package/dist/generation/generateAdapter.js +38 -0
- package/dist/generation/generateSkill.d.ts +26 -0
- package/dist/generation/generateSkill.js +51 -0
- package/dist/index.d.ts +43 -4
- package/dist/index.js +26 -2
- package/dist/mcp/client.d.ts +70 -0
- package/dist/mcp/client.js +221 -0
- package/dist/mcp/manager.d.ts +151 -0
- package/dist/mcp/manager.js +493 -0
- package/dist/mcp/protocol.d.ts +216 -0
- package/dist/mcp/protocol.js +149 -0
- package/dist/mcp/toolAdapter.d.ts +44 -0
- package/dist/mcp/toolAdapter.js +94 -0
- package/dist/mcp/transport.d.ts +109 -0
- package/dist/mcp/transport.js +383 -0
- package/dist/memory/embedders/hash.d.ts +12 -0
- package/dist/memory/embedders/hash.js +31 -0
- package/dist/memory/embedders/http.d.ts +25 -0
- package/dist/memory/embedders/http.js +48 -0
- package/dist/memory/memory.d.ts +19 -2
- package/dist/memory/memory.js +75 -11
- package/dist/memory/semantic.d.ts +17 -0
- package/dist/memory/semantic.js +29 -0
- package/dist/orchestration/budget.d.ts +30 -0
- package/dist/orchestration/budget.js +40 -0
- package/dist/orchestration/executor.d.ts +39 -1
- package/dist/orchestration/executor.js +64 -4
- package/dist/orchestration/orchestrator.d.ts +29 -1
- package/dist/orchestration/orchestrator.js +89 -8
- package/dist/orchestration/plan.d.ts +15 -1
- package/dist/orchestration/plan.js +23 -4
- package/dist/orchestration/planner.d.ts +19 -1
- package/dist/orchestration/planner.js +25 -5
- package/dist/plugin/ai.d.ts +4 -0
- package/dist/plugin/ai.js +9 -0
- package/dist/runtime/config.js +50 -6
- package/dist/runtime/intent/aiClassifier.d.ts +19 -0
- package/dist/runtime/intent/aiClassifier.js +74 -0
- package/dist/runtime/models/modelProfile.d.ts +61 -0
- package/dist/runtime/models/modelProfile.js +139 -0
- package/dist/runtime/planning/deriveCapabilities.d.ts +95 -0
- package/dist/runtime/planning/deriveCapabilities.js +146 -0
- package/dist/runtime/policy.d.ts +10 -0
- package/dist/runtime/policy.js +9 -2
- package/dist/runtime/runtime.d.ts +173 -0
- package/dist/runtime/runtime.js +705 -50
- package/dist/runtime/types.d.ts +88 -2
- package/dist/skills/manifest.d.ts +3 -0
- package/dist/skills/manifest.js +24 -0
- package/dist/skills/registry.d.ts +16 -1
- package/dist/skills/registry.js +21 -1
- package/dist/skills/skill.d.ts +6 -1
- package/dist/store/area.d.ts +15 -1
- package/dist/store/area.js +19 -8
- package/dist/store/crypto.d.ts +21 -0
- package/dist/store/crypto.js +49 -0
- package/dist/store/paths.d.ts +5 -1
- package/dist/store/paths.js +6 -0
- package/dist/store/store.d.ts +15 -3
- package/dist/store/store.js +28 -7
- package/dist/telemetry/sinks/otlp.d.ts +31 -0
- package/dist/telemetry/sinks/otlp.js +76 -0
- package/dist/tools/builtins/filesystem.js +1 -0
- package/dist/tools/builtins/git.js +1 -0
- package/dist/tools/builtins/shell.js +1 -0
- package/dist/tools/permissions.d.ts +28 -0
- package/dist/tools/permissions.js +72 -0
- package/dist/tools/registry.d.ts +18 -2
- package/dist/tools/registry.js +22 -2
- package/dist/tools/tool.d.ts +4 -0
- package/dist/types.d.ts +5 -1
- package/dist/util/flatten.d.ts +11 -0
- package/dist/util/flatten.js +18 -0
- package/dist/util/semaphore.d.ts +19 -0
- package/dist/util/semaphore.js +60 -0
- package/package.json +24 -9
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP transports (Phase 3.2) — zero-dependency, injectable, offline-testable.
|
|
3
|
+
*
|
|
4
|
+
* `StdioTransport` spawns a server process and frames JSON-RPC as newline-delimited JSON over stdin/
|
|
5
|
+
* stdout (stderr is captured, redacted, and ring-buffered for diagnostics). `HttpTransport` speaks
|
|
6
|
+
* streamable HTTP: it POSTs JSON-RPC and accepts either a single JSON response or an SSE stream, reusing
|
|
7
|
+
* the `data:`-line parsing approach already proven in `providers/httpClient.callHttpStream`, and carries
|
|
8
|
+
* the `Mcp-Session-Id` handed out at initialize. `InProcessTransport` is the test seam.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: a request URL or spawn command NEVER appears in a thrown error (undici embeds the URL in its
|
|
11
|
+
* message, and a URL can carry a token) — only the error NAME. Credentials arrive already resolved from
|
|
12
|
+
* an env-var NAME via `Credential`, are used solely to build a header (or a single env var for stdio),
|
|
13
|
+
* and are never stored on the transport, logged, or serialized.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { AIError } from '../core/fallback/errors.js';
|
|
17
|
+
import { redactString } from '../security/redact.js';
|
|
18
|
+
import { safeEnv } from '../tools/runner.js';
|
|
19
|
+
import { clampText } from './protocol.js';
|
|
20
|
+
const DIAG_LIMIT = 20;
|
|
21
|
+
/**
|
|
22
|
+
* ONE process-exit hook for every stdio child, not one per transport — `process` warns past ten
|
|
23
|
+
* listeners on an event, and a user with a dozen MCP servers would see that warning on stderr. The
|
|
24
|
+
* handler must be synchronous ('exit' cannot await); the graceful SIGTERM escalation lives in `close()`.
|
|
25
|
+
*/
|
|
26
|
+
const liveChildren = new Set();
|
|
27
|
+
let exitHookInstalled = false;
|
|
28
|
+
function trackChild(child) {
|
|
29
|
+
liveChildren.add(child);
|
|
30
|
+
if (exitHookInstalled)
|
|
31
|
+
return;
|
|
32
|
+
exitHookInstalled = true;
|
|
33
|
+
process.once('exit', () => {
|
|
34
|
+
for (const c of liveChildren) {
|
|
35
|
+
try {
|
|
36
|
+
if (c.exitCode === null)
|
|
37
|
+
c.kill('SIGKILL');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* already gone */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
liveChildren.clear();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Push a diagnostic into a bounded ring: redacted, then FLATTENED and clamped. A child's stderr is
|
|
48
|
+
* server-controlled multi-line text that ends up in `mcp <id>` output, so an unflattened line can forge
|
|
49
|
+
* additional rows in that report.
|
|
50
|
+
*/
|
|
51
|
+
function pushDiag(ring, line) {
|
|
52
|
+
const clean = clampText(redactString(line), 200);
|
|
53
|
+
if (!clean)
|
|
54
|
+
return;
|
|
55
|
+
ring.push(clean);
|
|
56
|
+
while (ring.length > DIAG_LIMIT)
|
|
57
|
+
ring.shift();
|
|
58
|
+
}
|
|
59
|
+
/** Inbound framing bounds: a server must not be able to grow our heap with one unterminated line. */
|
|
60
|
+
const MAX_LINE_BYTES = 1024 * 1024;
|
|
61
|
+
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
62
|
+
export class StdioTransport {
|
|
63
|
+
opts;
|
|
64
|
+
child;
|
|
65
|
+
handler;
|
|
66
|
+
buffer = '';
|
|
67
|
+
diag = [];
|
|
68
|
+
closed = false;
|
|
69
|
+
constructor(opts) {
|
|
70
|
+
this.opts = opts;
|
|
71
|
+
}
|
|
72
|
+
async start() {
|
|
73
|
+
// A minimal, secret-free environment (the shell tool's rule) plus ONLY the one credential the
|
|
74
|
+
// server was configured to receive, by env-var name.
|
|
75
|
+
const env = { ...safeEnv(this.opts.env ?? process.env) };
|
|
76
|
+
if (this.opts.tokenEnvName && this.opts.tokenValue)
|
|
77
|
+
env[this.opts.tokenEnvName] = this.opts.tokenValue;
|
|
78
|
+
let child;
|
|
79
|
+
try {
|
|
80
|
+
child = spawn(this.opts.command, this.opts.args ?? [], {
|
|
81
|
+
cwd: this.opts.cwd,
|
|
82
|
+
env,
|
|
83
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
84
|
+
detached: process.platform !== 'win32', // own process group so close() can kill the tree
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
// Never echo the command line — it can carry paths/args the operator considers sensitive.
|
|
89
|
+
throw new AIError(`MCP stdio server failed to start (${e instanceof Error ? e.name : 'Error'})`, { category: 'NETWORK', retryable: false });
|
|
90
|
+
}
|
|
91
|
+
this.child = child;
|
|
92
|
+
child.stdout.setEncoding('utf8');
|
|
93
|
+
child.stdout.on('data', (chunk) => this.onChunk(chunk));
|
|
94
|
+
child.stderr.setEncoding('utf8');
|
|
95
|
+
child.stderr.on('data', (chunk) => pushDiag(this.diag, String(chunk)));
|
|
96
|
+
child.on('error', (e) => pushDiag(this.diag, `process error (${e.name})`));
|
|
97
|
+
// A stream error on a dying child (EPIPE when we write into a process that just exited, ECONNRESET
|
|
98
|
+
// on a Windows named pipe) is EXPECTED during teardown. Unhandled, a stream 'error' is an uncaught
|
|
99
|
+
// exception that would take down the host process, so each pipe gets a handler that just records it.
|
|
100
|
+
for (const [name, stream] of [['stdin', child.stdin], ['stdout', child.stdout], ['stderr', child.stderr]]) {
|
|
101
|
+
stream.on('error', (e) => pushDiag(this.diag, `${name} error (${e.name})`));
|
|
102
|
+
}
|
|
103
|
+
// A `close()` we never got to (crash, uncaught throw, process.exit) must not leave an orphan.
|
|
104
|
+
trackChild(child);
|
|
105
|
+
child.on('exit', (code, signal) => {
|
|
106
|
+
liveChildren.delete(child);
|
|
107
|
+
if (!this.closed)
|
|
108
|
+
pushDiag(this.diag, `server exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`);
|
|
109
|
+
});
|
|
110
|
+
// `spawn()` reports a missing executable ASYNCHRONOUSLY, so without this the handshake would be
|
|
111
|
+
// written into a dead pipe and the caller would wait out the whole request timeout (30s by default)
|
|
112
|
+
// to learn the command does not exist. Racing 'spawn' against 'error' turns that into an immediate,
|
|
113
|
+
// accurate failure — and the command line still never reaches the message.
|
|
114
|
+
await new Promise((resolve, reject) => {
|
|
115
|
+
child.once('spawn', resolve);
|
|
116
|
+
child.once('error', (e) => reject(new AIError(`MCP stdio server failed to start (${e instanceof Error ? e.name : 'Error'})`, { category: 'NETWORK', retryable: false })));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
onChunk(chunk) {
|
|
120
|
+
this.buffer += chunk;
|
|
121
|
+
if (this.buffer.length > MAX_LINE_BYTES) {
|
|
122
|
+
// An unterminated megabyte is not a JSON-RPC frame; drop it rather than grow without bound.
|
|
123
|
+
this.buffer = '';
|
|
124
|
+
pushDiag(this.diag, 'server wrote an oversized line (dropped)');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let nl;
|
|
128
|
+
while ((nl = this.buffer.indexOf('\n')) >= 0) {
|
|
129
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
130
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
131
|
+
if (!line)
|
|
132
|
+
continue;
|
|
133
|
+
try {
|
|
134
|
+
this.handler?.(JSON.parse(line));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
pushDiag(this.diag, 'server wrote a non-JSON line');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async send(msg) {
|
|
142
|
+
const child = this.child;
|
|
143
|
+
// `killed` only reflects OUR kill(); a server that crashed on its own has exitCode set instead, and
|
|
144
|
+
// writing into it succeeds silently — the caller would then stall for the full timeout.
|
|
145
|
+
if (!child || child.killed || child.exitCode !== null || child.signalCode !== null || !child.stdin.writable) {
|
|
146
|
+
throw new AIError('MCP stdio server is not running', { category: 'NETWORK', retryable: true });
|
|
147
|
+
}
|
|
148
|
+
child.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
149
|
+
}
|
|
150
|
+
onMessage(cb) {
|
|
151
|
+
this.handler = cb;
|
|
152
|
+
}
|
|
153
|
+
/** SIGTERM, then a process-group SIGKILL — the same escalation the shell runner uses. */
|
|
154
|
+
async close() {
|
|
155
|
+
this.closed = true;
|
|
156
|
+
const child = this.child;
|
|
157
|
+
this.child = undefined;
|
|
158
|
+
if (child)
|
|
159
|
+
liveChildren.delete(child);
|
|
160
|
+
if (!child || child.exitCode !== null)
|
|
161
|
+
return;
|
|
162
|
+
try {
|
|
163
|
+
child.stdin.end();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
/* already gone */
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
child.kill('SIGTERM');
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* already gone */
|
|
173
|
+
}
|
|
174
|
+
await new Promise((res) => {
|
|
175
|
+
const t = setTimeout(() => {
|
|
176
|
+
try {
|
|
177
|
+
if (child.pid && process.platform !== 'win32')
|
|
178
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
179
|
+
else
|
|
180
|
+
child.kill('SIGKILL');
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* already gone */
|
|
184
|
+
}
|
|
185
|
+
res();
|
|
186
|
+
}, 300);
|
|
187
|
+
if (typeof t.unref === 'function')
|
|
188
|
+
t.unref();
|
|
189
|
+
child.once('exit', () => {
|
|
190
|
+
clearTimeout(t);
|
|
191
|
+
res();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
// The process is gone; drop its pipes so no handle can keep our own event loop alive.
|
|
195
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
|
196
|
+
try {
|
|
197
|
+
stream.destroy();
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* already destroyed */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
diagnostics() {
|
|
205
|
+
return [...this.diag];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export class HttpTransport {
|
|
209
|
+
opts;
|
|
210
|
+
handler;
|
|
211
|
+
sessionId;
|
|
212
|
+
/** Set once the handshake settles; the spec requires it on every subsequent request. */
|
|
213
|
+
negotiatedVersion;
|
|
214
|
+
diag = [];
|
|
215
|
+
fetchImpl;
|
|
216
|
+
constructor(opts) {
|
|
217
|
+
this.opts = opts;
|
|
218
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
219
|
+
}
|
|
220
|
+
async start() {
|
|
221
|
+
/* HTTP is connectionless here: `initialize` is the first POST. */
|
|
222
|
+
}
|
|
223
|
+
onMessage(cb) {
|
|
224
|
+
this.handler = cb;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Whether the session was invalidated (HTTP 404 on a session-bearing request). The manager reads this
|
|
228
|
+
* to force a full reconnect instead of retrying against a session the server has forgotten.
|
|
229
|
+
*/
|
|
230
|
+
sessionExpired = false;
|
|
231
|
+
/** Record the version the handshake settled on so later requests can carry the required header. */
|
|
232
|
+
setProtocolVersion(version) {
|
|
233
|
+
this.negotiatedVersion = version;
|
|
234
|
+
}
|
|
235
|
+
async send(msg) {
|
|
236
|
+
const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' };
|
|
237
|
+
if (this.opts.token)
|
|
238
|
+
headers.authorization = `Bearer ${this.opts.token}`;
|
|
239
|
+
if (this.sessionId)
|
|
240
|
+
headers['mcp-session-id'] = this.sessionId;
|
|
241
|
+
if (this.negotiatedVersion)
|
|
242
|
+
headers['mcp-protocol-version'] = this.negotiatedVersion;
|
|
243
|
+
let res;
|
|
244
|
+
try {
|
|
245
|
+
res = await this.fetchImpl(this.opts.url, { method: 'POST', headers, body: JSON.stringify(msg), signal: AbortSignal.timeout(this.opts.timeoutMs ?? 30_000) });
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
const name = e instanceof Error ? e.name : 'Error';
|
|
249
|
+
const isTimeout = name === 'TimeoutError' || name === 'AbortError';
|
|
250
|
+
// Only the error NAME — the URL must never reach the message.
|
|
251
|
+
throw new AIError(`MCP request failed (${name})`, { category: isTimeout ? 'TIMEOUT' : 'NETWORK', retryable: true });
|
|
252
|
+
}
|
|
253
|
+
const session = res.headers.get('mcp-session-id');
|
|
254
|
+
if (session)
|
|
255
|
+
this.sessionId = session;
|
|
256
|
+
// A 404 on a session-bearing request means the server dropped our session — re-initialize.
|
|
257
|
+
if (res.status === 404 && this.sessionId) {
|
|
258
|
+
this.sessionId = undefined;
|
|
259
|
+
this.sessionExpired = true;
|
|
260
|
+
throw new AIError('MCP session expired (HTTP 404) — reconnecting', { category: 'NETWORK', retryable: true });
|
|
261
|
+
}
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
const category = res.status === 401 || res.status === 403 ? 'AUTHENTICATION' : 'NETWORK';
|
|
264
|
+
throw new AIError(`MCP endpoint returned HTTP ${res.status}`, { category, status: res.status, retryable: category === 'NETWORK' });
|
|
265
|
+
}
|
|
266
|
+
if (res.status === 202)
|
|
267
|
+
return; // accepted notification, no body
|
|
268
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
269
|
+
if (contentType.includes('text/event-stream'))
|
|
270
|
+
await this.readSse(res);
|
|
271
|
+
else
|
|
272
|
+
await this.readJson(res);
|
|
273
|
+
}
|
|
274
|
+
async readJson(res) {
|
|
275
|
+
const text = await res.text();
|
|
276
|
+
if (text.length > MAX_BODY_BYTES)
|
|
277
|
+
throw new AIError('MCP endpoint returned an oversized body', { category: 'RESPONSE_VALIDATION', retryable: false });
|
|
278
|
+
if (!text.trim())
|
|
279
|
+
return;
|
|
280
|
+
try {
|
|
281
|
+
const parsed = JSON.parse(text);
|
|
282
|
+
for (const m of Array.isArray(parsed) ? parsed : [parsed])
|
|
283
|
+
this.handler?.(m);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
pushDiag(this.diag, 'endpoint returned a non-JSON body');
|
|
287
|
+
throw new AIError('MCP endpoint returned a non-JSON body', { category: 'RESPONSE_VALIDATION', retryable: false });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Consume an SSE body. Events are separated by a BLANK line and a single event may carry several
|
|
292
|
+
* `data:` lines that concatenate with newlines — parsing each line on its own loses any multi-line
|
|
293
|
+
* event, and treating a lone CR as no separator makes a CR-only stream unparseable. Both are handled
|
|
294
|
+
* here. We stop once the frame we are waiting for arrives: a RESPONSE (an id plus result/error and no
|
|
295
|
+
* method), not merely anything carrying an id — a server-initiated request would otherwise abandon the
|
|
296
|
+
* stream before our own reply showed up.
|
|
297
|
+
*/
|
|
298
|
+
async readSse(res) {
|
|
299
|
+
if (!res.body)
|
|
300
|
+
return;
|
|
301
|
+
const reader = res.body.getReader();
|
|
302
|
+
const decoder = new TextDecoder();
|
|
303
|
+
let buffer = '';
|
|
304
|
+
let sawResponse = false;
|
|
305
|
+
try {
|
|
306
|
+
for (;;) {
|
|
307
|
+
const { value, done } = await reader.read();
|
|
308
|
+
if (done)
|
|
309
|
+
break;
|
|
310
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, '\n');
|
|
311
|
+
if (buffer.length > MAX_BODY_BYTES)
|
|
312
|
+
throw new AIError('MCP endpoint streamed an oversized body', { category: 'RESPONSE_VALIDATION', retryable: false });
|
|
313
|
+
let sep;
|
|
314
|
+
while ((sep = buffer.indexOf('\n\n')) >= 0) {
|
|
315
|
+
const event = buffer.slice(0, sep);
|
|
316
|
+
buffer = buffer.slice(sep + 2);
|
|
317
|
+
if (this.dispatchSseEvent(event))
|
|
318
|
+
sawResponse = true;
|
|
319
|
+
}
|
|
320
|
+
if (sawResponse)
|
|
321
|
+
break; // our reply arrived; do not hold the stream open
|
|
322
|
+
}
|
|
323
|
+
// A body that ends without a trailing blank line still carries a complete final event.
|
|
324
|
+
if (!sawResponse && buffer.trim())
|
|
325
|
+
this.dispatchSseEvent(buffer);
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
await reader.cancel().catch(() => { });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** Parse one SSE event block; returns true when it carried a JSON-RPC RESPONSE to one of our requests. */
|
|
332
|
+
dispatchSseEvent(block) {
|
|
333
|
+
const data = block
|
|
334
|
+
.split('\n')
|
|
335
|
+
.filter((l) => l.startsWith('data:'))
|
|
336
|
+
.map((l) => l.slice(5).replace(/^ /, ''))
|
|
337
|
+
.join('\n')
|
|
338
|
+
.trim();
|
|
339
|
+
if (!data || data === '[DONE]')
|
|
340
|
+
return false;
|
|
341
|
+
try {
|
|
342
|
+
const msg = JSON.parse(data);
|
|
343
|
+
this.handler?.(msg);
|
|
344
|
+
const m = msg;
|
|
345
|
+
return m.id !== undefined && m.id !== null && typeof m.method !== 'string' && (m.result !== undefined || m.error !== undefined);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
pushDiag(this.diag, 'stream carried a non-JSON event');
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async close() {
|
|
353
|
+
this.sessionId = undefined;
|
|
354
|
+
this.negotiatedVersion = undefined;
|
|
355
|
+
}
|
|
356
|
+
diagnostics() {
|
|
357
|
+
return [...this.diag];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// ── in-process (tests) ───────────────────────────────────────────────────────
|
|
361
|
+
/** A scripted server for offline tests: a plain function from request to response. */
|
|
362
|
+
export class InProcessTransport {
|
|
363
|
+
server;
|
|
364
|
+
handler;
|
|
365
|
+
constructor(server) {
|
|
366
|
+
this.server = server;
|
|
367
|
+
}
|
|
368
|
+
async start() { }
|
|
369
|
+
onMessage(cb) {
|
|
370
|
+
this.handler = cb;
|
|
371
|
+
}
|
|
372
|
+
async send(msg) {
|
|
373
|
+
if (!('id' in msg))
|
|
374
|
+
return; // notification
|
|
375
|
+
const reply = await this.server(msg);
|
|
376
|
+
if (reply)
|
|
377
|
+
this.handler?.(reply);
|
|
378
|
+
}
|
|
379
|
+
async close() { }
|
|
380
|
+
diagnostics() {
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HashEmbedder — a deterministic, zero-dependency, offline embedding via the hashing trick (Phase 14).
|
|
3
|
+
* It maps each token to a bucket in a fixed-dimension vector and L2-normalizes, so cosine similarity
|
|
4
|
+
* tracks token overlap. It is not a learned semantic model, but it is deterministic and needs no network
|
|
5
|
+
* — the local default (config `embedding: { provider: 'local' }`) and the offline test backbone for the
|
|
6
|
+
* whole semantic path. Swap in a real `HttpEmbedder` for learned embeddings.
|
|
7
|
+
*/
|
|
8
|
+
import type { EmbeddingProvider } from '../memory.js';
|
|
9
|
+
export declare class HashEmbedder implements EmbeddingProvider {
|
|
10
|
+
readonly id = "local-hash";
|
|
11
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HashEmbedder — a deterministic, zero-dependency, offline embedding via the hashing trick (Phase 14).
|
|
3
|
+
* It maps each token to a bucket in a fixed-dimension vector and L2-normalizes, so cosine similarity
|
|
4
|
+
* tracks token overlap. It is not a learned semantic model, but it is deterministic and needs no network
|
|
5
|
+
* — the local default (config `embedding: { provider: 'local' }`) and the offline test backbone for the
|
|
6
|
+
* whole semantic path. Swap in a real `HttpEmbedder` for learned embeddings.
|
|
7
|
+
*/
|
|
8
|
+
import { tokenize } from '../bm25.js';
|
|
9
|
+
const DIM = 256;
|
|
10
|
+
/** FNV-1a hash of a token → a bucket in [0, DIM). */
|
|
11
|
+
function bucket(token) {
|
|
12
|
+
let h = 2166136261;
|
|
13
|
+
for (let i = 0; i < token.length; i += 1) {
|
|
14
|
+
h ^= token.charCodeAt(i);
|
|
15
|
+
h = Math.imul(h, 16777619);
|
|
16
|
+
}
|
|
17
|
+
return (h >>> 0) % DIM;
|
|
18
|
+
}
|
|
19
|
+
function embedOne(text) {
|
|
20
|
+
const v = new Array(DIM).fill(0);
|
|
21
|
+
for (const tok of tokenize(text))
|
|
22
|
+
v[bucket(tok)] += 1;
|
|
23
|
+
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
|
|
24
|
+
return v.map((x) => x / norm);
|
|
25
|
+
}
|
|
26
|
+
export class HashEmbedder {
|
|
27
|
+
id = 'local-hash';
|
|
28
|
+
async embed(texts) {
|
|
29
|
+
return texts.map(embedOne);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HttpEmbedder — an OpenAI-compatible embeddings adapter (Phase 14): `POST {baseUrl}/embeddings` with
|
|
3
|
+
* `{ model, input: texts }`, Bearer auth from a `Credential` (env-var name only, never a raw key). Parses
|
|
4
|
+
* `{ data: [{ embedding: number[] }] }`. `fetch` is injectable for offline tests.
|
|
5
|
+
*
|
|
6
|
+
* SECURITY: the request URL is never placed in a thrown error (undici embeds it, and it can carry a
|
|
7
|
+
* key-in-query); the key lives only in the header. A failure here surfaces as a thrown error, which the
|
|
8
|
+
* caller (MemoryStore.searchSemantic) turns into a BM25 fallback — retrieval never breaks the run.
|
|
9
|
+
*/
|
|
10
|
+
import type { EmbeddingProvider } from '../memory.js';
|
|
11
|
+
import type { FetchLike } from '../../providers/httpClient.js';
|
|
12
|
+
import type { Credential } from '../../security/credentials.js';
|
|
13
|
+
export interface HttpEmbedderConfig {
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
model: string;
|
|
16
|
+
credential: Credential;
|
|
17
|
+
fetchImpl?: FetchLike;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export declare class HttpEmbedder implements EmbeddingProvider {
|
|
21
|
+
private readonly cfg;
|
|
22
|
+
readonly id: string;
|
|
23
|
+
constructor(cfg: HttpEmbedderConfig);
|
|
24
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HttpEmbedder — an OpenAI-compatible embeddings adapter (Phase 14): `POST {baseUrl}/embeddings` with
|
|
3
|
+
* `{ model, input: texts }`, Bearer auth from a `Credential` (env-var name only, never a raw key). Parses
|
|
4
|
+
* `{ data: [{ embedding: number[] }] }`. `fetch` is injectable for offline tests.
|
|
5
|
+
*
|
|
6
|
+
* SECURITY: the request URL is never placed in a thrown error (undici embeds it, and it can carry a
|
|
7
|
+
* key-in-query); the key lives only in the header. A failure here surfaces as a thrown error, which the
|
|
8
|
+
* caller (MemoryStore.searchSemantic) turns into a BM25 fallback — retrieval never breaks the run.
|
|
9
|
+
*/
|
|
10
|
+
export class HttpEmbedder {
|
|
11
|
+
cfg;
|
|
12
|
+
id;
|
|
13
|
+
constructor(cfg) {
|
|
14
|
+
this.cfg = cfg;
|
|
15
|
+
this.id = `http:${cfg.model}`;
|
|
16
|
+
}
|
|
17
|
+
async embed(texts) {
|
|
18
|
+
if (texts.length === 0)
|
|
19
|
+
return [];
|
|
20
|
+
const fetchImpl = this.cfg.fetchImpl ?? fetch;
|
|
21
|
+
const url = `${String(this.cfg.baseUrl).replace(/\/+$/, '')}/embeddings`;
|
|
22
|
+
const headers = { 'content-type': 'application/json' };
|
|
23
|
+
const key = this.cfg.credential.use();
|
|
24
|
+
if (key)
|
|
25
|
+
headers.authorization = `Bearer ${key}`;
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify({ model: this.cfg.model, input: texts }), signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 30_000) });
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
// undici embeds the full URL in the message — use only the error NAME.
|
|
32
|
+
throw new Error(`embedding request failed (${e instanceof Error ? e.name : 'Error'})`);
|
|
33
|
+
}
|
|
34
|
+
if (!res.ok)
|
|
35
|
+
throw new Error(`embedding endpoint returned HTTP ${res.status}`);
|
|
36
|
+
const json = (await res.json());
|
|
37
|
+
const data = json.data ?? [];
|
|
38
|
+
if (data.length !== texts.length)
|
|
39
|
+
throw new Error(`embedding count mismatch (got ${data.length}, want ${texts.length})`);
|
|
40
|
+
return data.map((d, i) => {
|
|
41
|
+
const vec = d.embedding;
|
|
42
|
+
// A valid embedding is a non-empty numeric vector; reject a degenerate one so the caller falls back to BM25.
|
|
43
|
+
if (!Array.isArray(vec) || vec.length === 0)
|
|
44
|
+
throw new Error(`embedding ${i} is not a valid vector`);
|
|
45
|
+
return vec;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/memory/memory.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { Clock } from '../util/clock.js';
|
|
9
9
|
import type { RuntimeStore } from '../store/store.js';
|
|
10
|
-
export type MemoryScope = 'user' | 'project' | 'repository' | 'domain' | 'skill' | 'execution';
|
|
10
|
+
export type MemoryScope = 'user' | 'project' | 'repository' | 'organization' | 'domain' | 'skill' | 'execution';
|
|
11
11
|
export type MemorySource = 'explicit' | 'observed' | 'inferred' | 'imported';
|
|
12
12
|
export interface MemoryRecord {
|
|
13
13
|
id: string;
|
|
@@ -56,11 +56,17 @@ export interface EmbeddingProvider {
|
|
|
56
56
|
}
|
|
57
57
|
export declare class MemoryStore {
|
|
58
58
|
private readonly store;
|
|
59
|
+
private readonly embedder?;
|
|
59
60
|
private readonly clock;
|
|
60
61
|
private counter;
|
|
61
|
-
|
|
62
|
+
/** Per-instance cache of fact-text → vector, so a stable fact isn't re-embedded on every query. */
|
|
63
|
+
private readonly vecCache;
|
|
64
|
+
constructor(store: RuntimeStore, clock?: Clock, embedder?: EmbeddingProvider | undefined);
|
|
62
65
|
get enabled(): boolean;
|
|
63
66
|
private area;
|
|
67
|
+
/** Physical scopes to traverse for reads/cascades — includes `organization` only when one is configured,
|
|
68
|
+
* so search/delete/supersession/purge cover it for free without leaking it into org-less stores. */
|
|
69
|
+
private physicalScopes;
|
|
64
70
|
private nextId;
|
|
65
71
|
/** Store a fact. Redacts text, resolves conflicts (supersedes prior same-group facts). */
|
|
66
72
|
remember(input: RememberInput): MemoryRecord;
|
|
@@ -70,7 +76,18 @@ export declare class MemoryStore {
|
|
|
70
76
|
/** Every stored record across scopes (including superseded/expired). A corrupt file is skipped. */
|
|
71
77
|
all(): MemoryRecord[];
|
|
72
78
|
/** Retrieval: filter (scope/domain/confidence/temporal/superseded) → BM25 rank → top-N. */
|
|
79
|
+
/** The filter stage shared by BM25 and semantic search: expiry, active-supersession, confidence, scope, domain. */
|
|
80
|
+
private filteredCandidates;
|
|
81
|
+
private rankBm25;
|
|
82
|
+
/** Retrieve facts by BM25 keyword relevance (the offline default; sync, unchanged since 1.0). */
|
|
73
83
|
search(query: string, opts?: SearchOptions): MemoryRecord[];
|
|
84
|
+
/**
|
|
85
|
+
* Retrieve facts by SEMANTIC (vector) relevance when an embedder is configured, else exactly BM25
|
|
86
|
+
* (Phase 14). Same filter stage, same `{scope/confidence/temporal}` semantics, same return shape. Any
|
|
87
|
+
* embedder failure falls back to BM25 — retrieval must never break the run. Candidate vectors are cached
|
|
88
|
+
* per fact text so a stable fact is embedded at most once per session.
|
|
89
|
+
*/
|
|
90
|
+
searchSemantic(query: string, opts?: SearchOptions): Promise<MemoryRecord[]>;
|
|
74
91
|
/** Delete a record and cascade (strip references, heal supersession chains), under the lock. */
|
|
75
92
|
delete(id: string): DeletionAudit;
|
|
76
93
|
/** The cascade itself (assumes the caller holds the lock). Heals A→B→C when B is removed. */
|