akemon 0.3.3 → 0.3.5
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 +24 -1
- package/dist/cli.js +218 -14
- package/dist/engine-peripheral.js +5 -4
- package/dist/engine-routing.js +99 -0
- package/dist/event-bus.js +63 -17
- package/dist/privacy-filter.js +269 -0
- package/dist/redaction.js +159 -0
- package/dist/relay-client.js +39 -2
- package/dist/server.js +222 -52
- package/dist/software-agent-memory.js +139 -0
- package/dist/software-agent-peripheral.js +599 -33
- package/dist/software-agent-stream-cli.js +101 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -164,11 +164,34 @@ akemon serve --name my-agent --engine claude
|
|
|
164
164
|
|
|
165
165
|
# In another terminal, ask the local software peripheral to work in the repo
|
|
166
166
|
akemon software-agent "Add one focused test and run the relevant test command."
|
|
167
|
+
|
|
168
|
+
# Review recent software-agent runs
|
|
169
|
+
akemon software-agent-tasks --limit 5
|
|
167
170
|
```
|
|
168
171
|
|
|
169
172
|
This is different from `--engine`: engines are replaceable compute, while software agents are external software bodies with their own repo context, skills, tools, and execution loop.
|
|
170
173
|
|
|
171
|
-
Current Batch 5 status: the Codex integration uses `codex exec` as a one-shot baseline, not a true persistent interactive session yet. It is owner-only, local-only, one task at a time, and every call is wrapped in an explicit task envelope with workdir, memory scope, risk level, allowed actions, and forbidden actions.
|
|
174
|
+
Current Batch 5 status: the Codex integration uses `codex exec` as a one-shot baseline, not a true persistent interactive session yet. It is owner-only, local-only, one task at a time, streams local stdout/stderr by default, and every call is wrapped in an explicit task envelope with workdir, memory scope, risk level, allowed actions, and forbidden actions.
|
|
175
|
+
|
|
176
|
+
Software-agent tasks default to the `akemon serve` workdir boundary. Use `--allow-outside-workdir` only when you explicitly want the software agent to run outside that root. Each run is recorded under `.akemon/agents/<name>/software-agent/tasks/` with the envelope, result, output summaries, and git worktree status.
|
|
177
|
+
|
|
178
|
+
The Codex child process currently inherits the `akemon serve` environment so model credentials and CLI configuration work as expected. Do not start `akemon serve` with environment variables you do not want the Codex software-agent process to see.
|
|
179
|
+
|
|
180
|
+
Common secret-like values are redacted from software-agent streams, task ledger records, relay task stream events, and the persistent event log before they are displayed or stored.
|
|
181
|
+
|
|
182
|
+
For PII-oriented filtering, Akemon also has an optional adapter for [OpenAI Privacy Filter](https://github.com/openai/privacy-filter). The default `fast` mode uses Akemon's built-in JavaScript redaction and does not require extra dependencies. To use OPF, install the external `opf` Python CLI yourself, then opt in explicitly:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
akemon privacy-filter --mode fast "OPENAI_API_KEY=sk-..."
|
|
186
|
+
akemon privacy-filter --mode pii --backend opf --device cpu "Alice was born on 1990-01-02."
|
|
187
|
+
akemon privacy-filter --mode strict --backend opf --checkpoint ~/.opf/privacy_filter "Alice ..."
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
You can also configure OPF with `AKEMON_PRIVACY_FILTER=opf`, `AKEMON_OPF_COMMAND`, `AKEMON_OPF_DEVICE`, `AKEMON_OPF_CHECKPOINT`, `AKEMON_OPF_TIMEOUT_MS`, and `AKEMON_OPF_MAX_INPUT_CHARS`. In `pii` mode, OPF failures fall back to built-in redaction with a warning; in `strict` mode they fail the command.
|
|
191
|
+
|
|
192
|
+
The software-agent task ledger keeps the most recent 200 task records by default.
|
|
193
|
+
|
|
194
|
+
The persistent event log rotates automatically at about 10 MB per file and keeps the current `events.jsonl` plus five rotated files.
|
|
172
195
|
|
|
173
196
|
## Serve Options
|
|
174
197
|
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,8 @@ import { getOrCreateRelayCredentials } from "./config.js";
|
|
|
6
6
|
import { connectRelay } from "./relay-client.js";
|
|
7
7
|
import { listAgents } from "./list.js";
|
|
8
8
|
import { connect } from "./connect.js";
|
|
9
|
+
import { PrivacyFilterUnavailableError, sanitizeText, } from "./privacy-filter.js";
|
|
10
|
+
import { SoftwareAgentStreamCliRenderer } from "./software-agent-stream-cli.js";
|
|
9
11
|
import { readFileSync } from "fs";
|
|
10
12
|
import { fileURLToPath } from "url";
|
|
11
13
|
import { dirname, join } from "path";
|
|
@@ -18,21 +20,74 @@ function parsePortOption(port) {
|
|
|
18
20
|
const value = typeof port === "number" ? port : parseInt(String(port || "3000"));
|
|
19
21
|
return Number.isInteger(value) && value > 0 ? value : 3000;
|
|
20
22
|
}
|
|
23
|
+
function clampPositiveInt(value, fallback, max) {
|
|
24
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
25
|
+
if (!Number.isInteger(parsed) || parsed <= 0)
|
|
26
|
+
return fallback;
|
|
27
|
+
return Math.min(parsed, max);
|
|
28
|
+
}
|
|
29
|
+
function parsePrivacyFilterMode(value) {
|
|
30
|
+
if (value === "fast" || value === "pii" || value === "strict")
|
|
31
|
+
return value;
|
|
32
|
+
console.error("--mode must be one of: fast, pii, strict");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
function parsePrivacyFilterBackend(value) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (value === "fast" || value === "opf")
|
|
39
|
+
return value;
|
|
40
|
+
console.error("--backend must be one of: fast, opf");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
function parseSoftwareAgentEnvPolicy(value) {
|
|
44
|
+
const normalized = (value || "inherit").trim().toLowerCase();
|
|
45
|
+
if (normalized === "inherit" || normalized === "allowlist")
|
|
46
|
+
return normalized;
|
|
47
|
+
console.error("--software-agent-env must be one of: inherit, allowlist");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
function parseCommaSeparatedCliOption(value) {
|
|
51
|
+
if (!value)
|
|
52
|
+
return undefined;
|
|
53
|
+
const items = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
54
|
+
return items.length ? items : undefined;
|
|
55
|
+
}
|
|
56
|
+
function parsePositiveIntCliOption(value, optionName) {
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
60
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
61
|
+
console.error(`${optionName} must be a positive integer`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
function printSoftwareAgentTaskList(tasks) {
|
|
67
|
+
if (!tasks.length) {
|
|
68
|
+
console.log("No software-agent tasks found.");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const task of tasks) {
|
|
72
|
+
const result = task.result?.success === true ? "ok" : task.result?.success === false ? "error" : "pending";
|
|
73
|
+
const duration = typeof task.durationMs === "number" ? `${task.durationMs}ms` : "-";
|
|
74
|
+
const git = task.workdirStatus?.isRepo
|
|
75
|
+
? (task.workdirStatus.dirty ? `dirty:${task.workdirStatus.changedFiles?.length || 0}` : "clean")
|
|
76
|
+
: "no-git";
|
|
77
|
+
const goal = truncateOneLine(task.envelope?.goal || "", 90);
|
|
78
|
+
console.log(`${task.taskId} ${task.status}/${result} ${duration} ${git} ${task.updatedAt || task.startedAt}`);
|
|
79
|
+
if (goal)
|
|
80
|
+
console.log(` ${goal}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function truncateOneLine(value, max) {
|
|
84
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
85
|
+
if (oneLine.length <= max)
|
|
86
|
+
return oneLine;
|
|
87
|
+
return `${oneLine.slice(0, Math.max(0, max - 3))}...`;
|
|
88
|
+
}
|
|
21
89
|
async function callLocalOwnerEndpoint(path, opts, init = {}) {
|
|
22
|
-
const
|
|
23
|
-
const port = parsePortOption(opts.port);
|
|
24
|
-
const headers = {
|
|
25
|
-
Authorization: `Bearer ${credentials.secretKey}`,
|
|
26
|
-
};
|
|
27
|
-
if (init.body !== undefined)
|
|
28
|
-
headers["Content-Type"] = "application/json";
|
|
29
|
-
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
30
|
-
...init,
|
|
31
|
-
headers: {
|
|
32
|
-
...headers,
|
|
33
|
-
...init.headers,
|
|
34
|
-
},
|
|
35
|
-
});
|
|
90
|
+
const res = await fetchLocalOwnerEndpoint(path, opts, init);
|
|
36
91
|
const text = await res.text();
|
|
37
92
|
let data;
|
|
38
93
|
try {
|
|
@@ -47,6 +102,80 @@ async function callLocalOwnerEndpoint(path, opts, init = {}) {
|
|
|
47
102
|
}
|
|
48
103
|
return data;
|
|
49
104
|
}
|
|
105
|
+
async function fetchLocalOwnerEndpoint(path, opts, init = {}) {
|
|
106
|
+
const credentials = await getOrCreateRelayCredentials();
|
|
107
|
+
const port = parsePortOption(opts.port);
|
|
108
|
+
const headers = {
|
|
109
|
+
Authorization: `Bearer ${credentials.secretKey}`,
|
|
110
|
+
};
|
|
111
|
+
if (init.body !== undefined)
|
|
112
|
+
headers["Content-Type"] = "application/json";
|
|
113
|
+
let res;
|
|
114
|
+
try {
|
|
115
|
+
res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
116
|
+
...init,
|
|
117
|
+
headers: {
|
|
118
|
+
...headers,
|
|
119
|
+
...init.headers,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const cause = error.cause;
|
|
125
|
+
if (error instanceof TypeError && error.message === "fetch failed" && cause?.message === "bad port") {
|
|
126
|
+
console.error(`Port ${port} cannot be used for the local akemon serve connection. Choose a different --port.`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
if (error instanceof TypeError && error.message === "fetch failed") {
|
|
130
|
+
console.error(`Cannot connect to local akemon serve on port ${port}. Start it with: akemon serve --port ${port}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
return res;
|
|
136
|
+
}
|
|
137
|
+
async function streamLocalOwnerEndpoint(path, opts, body) {
|
|
138
|
+
const res = await fetchLocalOwnerEndpoint(path, opts, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
body: JSON.stringify(body),
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const text = await res.text();
|
|
144
|
+
let data;
|
|
145
|
+
try {
|
|
146
|
+
data = text ? JSON.parse(text) : {};
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
data = { error: text };
|
|
150
|
+
}
|
|
151
|
+
console.error(data.error || text || `Request failed with HTTP ${res.status}`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
if (!res.body)
|
|
155
|
+
return;
|
|
156
|
+
const decoder = new TextDecoder();
|
|
157
|
+
let buffer = "";
|
|
158
|
+
let failed = false;
|
|
159
|
+
const streamRenderer = new SoftwareAgentStreamCliRenderer();
|
|
160
|
+
const reader = res.body.getReader();
|
|
161
|
+
while (true) {
|
|
162
|
+
const { done, value } = await reader.read();
|
|
163
|
+
if (done)
|
|
164
|
+
break;
|
|
165
|
+
buffer += decoder.decode(value, { stream: true });
|
|
166
|
+
const lines = buffer.split(/\r?\n/);
|
|
167
|
+
buffer = lines.pop() || "";
|
|
168
|
+
for (const line of lines) {
|
|
169
|
+
if (streamRenderer.handleLine(line))
|
|
170
|
+
failed = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
buffer += decoder.decode();
|
|
174
|
+
if (buffer.trim() && streamRenderer.handleLine(buffer))
|
|
175
|
+
failed = true;
|
|
176
|
+
if (failed)
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
50
179
|
program
|
|
51
180
|
.name("akemon")
|
|
52
181
|
.description("Agent work marketplace — train your agent, let it work for others")
|
|
@@ -75,6 +204,8 @@ program
|
|
|
75
204
|
.option("--without <modules>", "Disable specific modules (comma-separated: biostate,memory)")
|
|
76
205
|
.option("--script <name>", "Script to load for ScriptModule (default: daily-life)", "daily-life")
|
|
77
206
|
.option("--terminal", "Enable remote terminal access (PTY)")
|
|
207
|
+
.option("--software-agent-env <policy>", "Software-agent child environment policy: inherit or allowlist", process.env.AKEMON_SOFTWARE_AGENT_ENV_POLICY || "inherit")
|
|
208
|
+
.option("--software-agent-env-allow <vars>", "Comma-separated extra env vars for software-agent allowlist")
|
|
78
209
|
.option("--relay <url>", "Relay WebSocket URL", RELAY_WS)
|
|
79
210
|
.action(async (opts) => {
|
|
80
211
|
const port = parseInt(opts.port);
|
|
@@ -110,6 +241,8 @@ program
|
|
|
110
241
|
notifyUrl: opts.notify,
|
|
111
242
|
enabledModules,
|
|
112
243
|
scriptName: opts.script,
|
|
244
|
+
softwareAgentEnvPolicy: parseSoftwareAgentEnvPolicy(opts.softwareAgentEnv),
|
|
245
|
+
softwareAgentEnvAllowlist: parseCommaSeparatedCliOption(opts.softwareAgentEnvAllow),
|
|
113
246
|
});
|
|
114
247
|
console.log(`\nakemon v${pkg.version}`);
|
|
115
248
|
if (!opts.public) {
|
|
@@ -171,12 +304,15 @@ program
|
|
|
171
304
|
.argument("<goal...>", "Task goal to send to the software agent")
|
|
172
305
|
.option("-p, --port <port>", "Local akemon serve port", "3000")
|
|
173
306
|
.option("-w, --workdir <path>", "Workdir for the software agent (default: serve workdir)")
|
|
307
|
+
.option("--allow-outside-workdir", "Allow the software agent workdir to be outside the serve workdir")
|
|
174
308
|
.option("--role-scope <scope>", "Role scope: owner|public|order|agent|system", "owner")
|
|
175
309
|
.option("--memory-scope <scope>", "Memory scope: none|public|task|owner", "owner")
|
|
176
310
|
.option("--risk <level>", "Risk level: low|medium|high", "medium")
|
|
177
311
|
.option("--memory-summary <text>", "Pre-filtered memory/context text to include")
|
|
312
|
+
.option("--session <id>", "Akemon-side context session id for explicit software-agent continuity")
|
|
178
313
|
.option("--deliverable <text>", "Expected output shape")
|
|
179
314
|
.option("--timeout-ms <ms>", "Task timeout in milliseconds")
|
|
315
|
+
.option("--no-stream", "Disable local streaming and wait for the final response")
|
|
180
316
|
.action(async (goalParts, opts) => {
|
|
181
317
|
const body = {
|
|
182
318
|
goal: goalParts.join(" "),
|
|
@@ -186,8 +322,12 @@ program
|
|
|
186
322
|
};
|
|
187
323
|
if (opts.workdir)
|
|
188
324
|
body.workdir = opts.workdir;
|
|
325
|
+
if (opts.allowOutsideWorkdir)
|
|
326
|
+
body.allowOutsideWorkdir = true;
|
|
189
327
|
if (opts.memorySummary)
|
|
190
328
|
body.memorySummary = opts.memorySummary;
|
|
329
|
+
if (opts.session)
|
|
330
|
+
body.contextSessionId = opts.session;
|
|
191
331
|
if (opts.deliverable)
|
|
192
332
|
body.deliverable = opts.deliverable;
|
|
193
333
|
if (opts.timeoutMs) {
|
|
@@ -198,6 +338,10 @@ program
|
|
|
198
338
|
}
|
|
199
339
|
body.timeoutMs = timeoutMs;
|
|
200
340
|
}
|
|
341
|
+
if (opts.stream !== false) {
|
|
342
|
+
await streamLocalOwnerEndpoint("/self/software-agent/run-stream", opts, body);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
201
345
|
const data = await callLocalOwnerEndpoint("/self/software-agent/run", opts, {
|
|
202
346
|
method: "POST",
|
|
203
347
|
body: JSON.stringify(body),
|
|
@@ -217,6 +361,26 @@ program
|
|
|
217
361
|
});
|
|
218
362
|
console.log(JSON.stringify(data, null, 2));
|
|
219
363
|
});
|
|
364
|
+
program
|
|
365
|
+
.command("software-agent-tasks")
|
|
366
|
+
.description("List recent owner-only software-agent task ledger records")
|
|
367
|
+
.argument("[taskId]", "Task id to inspect")
|
|
368
|
+
.option("-p, --port <port>", "Local akemon serve port", "3000")
|
|
369
|
+
.option("-l, --limit <n>", "Maximum recent tasks to list", "20")
|
|
370
|
+
.option("--json", "Print raw JSON")
|
|
371
|
+
.action(async (taskId, opts) => {
|
|
372
|
+
const path = taskId
|
|
373
|
+
? `/self/software-agent/tasks/${encodeURIComponent(taskId)}`
|
|
374
|
+
: `/self/software-agent/tasks?limit=${clampPositiveInt(opts.limit, 20, 100)}`;
|
|
375
|
+
const data = await callLocalOwnerEndpoint(path, opts, {
|
|
376
|
+
method: "GET",
|
|
377
|
+
});
|
|
378
|
+
if (opts.json || taskId) {
|
|
379
|
+
console.log(JSON.stringify(taskId ? data.task : data, null, 2));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
printSoftwareAgentTaskList(Array.isArray(data.tasks) ? data.tasks : []);
|
|
383
|
+
});
|
|
220
384
|
program
|
|
221
385
|
.command("software-agent-reset")
|
|
222
386
|
.description("Reset the owner-only local software-agent peripheral session")
|
|
@@ -227,6 +391,46 @@ program
|
|
|
227
391
|
});
|
|
228
392
|
console.log(JSON.stringify(data, null, 2));
|
|
229
393
|
});
|
|
394
|
+
program
|
|
395
|
+
.command("privacy-filter")
|
|
396
|
+
.description("Sanitize text with built-in redaction and optional OpenAI Privacy Filter")
|
|
397
|
+
.argument("<text...>", "Text to sanitize")
|
|
398
|
+
.option("--mode <mode>", "Mode: fast, pii, or strict", "fast")
|
|
399
|
+
.option("--backend <backend>", "Backend: fast or opf")
|
|
400
|
+
.option("--command <command>", "OPF command (default: opf)")
|
|
401
|
+
.option("--device <device>", "OPF device, e.g. cpu or cuda")
|
|
402
|
+
.option("--checkpoint <path>", "OPF checkpoint directory")
|
|
403
|
+
.option("--timeout-ms <ms>", "OPF timeout in milliseconds")
|
|
404
|
+
.option("--max-input-chars <n>", "Maximum text length to pass to OPF")
|
|
405
|
+
.option("--json", "Print result metadata as JSON")
|
|
406
|
+
.action(async (textParts, opts) => {
|
|
407
|
+
try {
|
|
408
|
+
const result = await sanitizeText(textParts.join(" "), {
|
|
409
|
+
mode: parsePrivacyFilterMode(opts.mode),
|
|
410
|
+
backend: parsePrivacyFilterBackend(opts.backend),
|
|
411
|
+
command: opts.command,
|
|
412
|
+
device: opts.device,
|
|
413
|
+
checkpoint: opts.checkpoint,
|
|
414
|
+
timeoutMs: parsePositiveIntCliOption(opts.timeoutMs, "--timeout-ms"),
|
|
415
|
+
maxInputChars: parsePositiveIntCliOption(opts.maxInputChars, "--max-input-chars"),
|
|
416
|
+
});
|
|
417
|
+
if (opts.json) {
|
|
418
|
+
console.log(JSON.stringify(result, null, 2));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
console.log(result.text);
|
|
422
|
+
for (const warning of result.warnings) {
|
|
423
|
+
console.error(`[privacy-filter] ${warning}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (error instanceof PrivacyFilterUnavailableError || error instanceof TypeError) {
|
|
428
|
+
console.error(error.message);
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
});
|
|
230
434
|
program
|
|
231
435
|
.command("dashboard")
|
|
232
436
|
.description("Open your agent dashboard in the browser")
|
|
@@ -17,7 +17,7 @@ import { callAgent, sendTaskEnd, sendTaskStart, sendTaskStream } from "./relay-c
|
|
|
17
17
|
import { SIG, sig } from "./types.js";
|
|
18
18
|
import { updateMetrics, pushExecMs } from "./metrics.js";
|
|
19
19
|
import { sendFailureEvent } from "./relay-client.js";
|
|
20
|
-
import {
|
|
20
|
+
import { resolveEngineRoute, } from "./engine-routing.js";
|
|
21
21
|
export const LLM_ENGINES = new Set(["claude", "codex", "opencode", "gemini", "raw"]);
|
|
22
22
|
const defaultTaskRelay = {
|
|
23
23
|
sendTaskStart,
|
|
@@ -100,11 +100,12 @@ export class EnginePeripheral {
|
|
|
100
100
|
// ---------------------------------------------------------------------------
|
|
101
101
|
// Unified engine runner
|
|
102
102
|
// ---------------------------------------------------------------------------
|
|
103
|
-
async runEngine(task, allowAll, extraAllowedTools, signal, origin, routing, taskId) {
|
|
104
|
-
const
|
|
103
|
+
async runEngine(task, allowAll, extraAllowedTools, signal, origin, routing, taskId, routeRequest) {
|
|
104
|
+
const resolution = resolveEngineRoute(routing, { origin, ...routeRequest });
|
|
105
|
+
const entry = resolution.entry;
|
|
105
106
|
const cfg = entry ? applyRoutingEntry(this.config, entry) : this.config;
|
|
106
107
|
if (origin && entry) {
|
|
107
|
-
console.log(`[engine] using ${cfg.engine}${cfg.model ? `/${cfg.model}` : ""} (origin=${origin})`);
|
|
108
|
+
console.log(`[engine] using ${cfg.engine}${cfg.model ? `/${cfg.model}` : ""} (origin=${origin}, source=${resolution.source})`);
|
|
108
109
|
}
|
|
109
110
|
const t0 = Date.now();
|
|
110
111
|
try {
|
package/dist/engine-routing.js
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
* deriveChildOrigin — returns the origin a child/sub-task should carry
|
|
7
7
|
* downgradeForRetry — downgrades any origin to "retry" when a task retries
|
|
8
8
|
*/
|
|
9
|
+
export class EngineRegistry {
|
|
10
|
+
routing;
|
|
11
|
+
constructor(routing) {
|
|
12
|
+
this.routing = routing;
|
|
13
|
+
}
|
|
14
|
+
resolve(request = {}) {
|
|
15
|
+
return resolveEngineRoute(this.routing, request);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
9
18
|
/**
|
|
10
19
|
* Resolve which engine routing entry to use for a given origin.
|
|
11
20
|
*
|
|
@@ -27,6 +36,34 @@ export function resolveEngineConfig(routing, origin) {
|
|
|
27
36
|
}
|
|
28
37
|
return routing.default ?? null;
|
|
29
38
|
}
|
|
39
|
+
export function resolveEngineRoute(routing, request = {}) {
|
|
40
|
+
if (!routing) {
|
|
41
|
+
return { entry: null, source: "none", reason: "no routing configured" };
|
|
42
|
+
}
|
|
43
|
+
const route = selectRoute(routing.routes, request);
|
|
44
|
+
if (route) {
|
|
45
|
+
return {
|
|
46
|
+
entry: stripRouteMetadata(route),
|
|
47
|
+
source: "route",
|
|
48
|
+
reason: "matched registry route",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (request.origin && routing[request.origin]) {
|
|
52
|
+
return {
|
|
53
|
+
entry: routing[request.origin],
|
|
54
|
+
source: "origin",
|
|
55
|
+
reason: `matched legacy origin route ${request.origin}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (routing.default) {
|
|
59
|
+
return {
|
|
60
|
+
entry: routing.default,
|
|
61
|
+
source: "default",
|
|
62
|
+
reason: "matched legacy default route",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { entry: null, source: "none", reason: "no matching route" };
|
|
66
|
+
}
|
|
30
67
|
/**
|
|
31
68
|
* Derive the origin that a child task should carry.
|
|
32
69
|
*
|
|
@@ -50,3 +87,65 @@ export function deriveChildOrigin(_parentOrigin) {
|
|
|
50
87
|
export function downgradeForRetry(_origin) {
|
|
51
88
|
return "retry";
|
|
52
89
|
}
|
|
90
|
+
function selectRoute(routes, request) {
|
|
91
|
+
if (!routes?.length)
|
|
92
|
+
return null;
|
|
93
|
+
let best = null;
|
|
94
|
+
for (let index = 0; index < routes.length; index++) {
|
|
95
|
+
const route = routes[index];
|
|
96
|
+
if (!routeMatches(route, request))
|
|
97
|
+
continue;
|
|
98
|
+
const score = scoreRoute(route, request);
|
|
99
|
+
if (!best || score > best.score || (score === best.score && index < best.index)) {
|
|
100
|
+
best = { route, score, index };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return best?.route ?? null;
|
|
104
|
+
}
|
|
105
|
+
function routeMatches(route, request) {
|
|
106
|
+
if (request.origin && route.origins?.length && !route.origins.includes(request.origin))
|
|
107
|
+
return false;
|
|
108
|
+
if (!hasRequiredCapabilities(route.capabilities, request.requiredCapabilities))
|
|
109
|
+
return false;
|
|
110
|
+
if (request.privacy && route.privacy && route.privacy !== request.privacy)
|
|
111
|
+
return false;
|
|
112
|
+
if (request.maxCost && route.cost && tierRank(route.cost) > tierRank(request.maxCost))
|
|
113
|
+
return false;
|
|
114
|
+
if (request.maxLatency && route.latency && tierRank(route.latency) > tierRank(request.maxLatency))
|
|
115
|
+
return false;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
function hasRequiredCapabilities(available, required) {
|
|
119
|
+
if (!required?.length)
|
|
120
|
+
return true;
|
|
121
|
+
if (!available?.length)
|
|
122
|
+
return false;
|
|
123
|
+
return required.every((capability) => available.includes(capability));
|
|
124
|
+
}
|
|
125
|
+
function scoreRoute(route, request) {
|
|
126
|
+
let score = route.priority ?? 0;
|
|
127
|
+
if (request.origin && route.origins?.includes(request.origin))
|
|
128
|
+
score += 100;
|
|
129
|
+
if (request.origin && !route.origins?.length)
|
|
130
|
+
score += 10;
|
|
131
|
+
if (request.requiredCapabilities?.length)
|
|
132
|
+
score += (route.capabilities?.length || 0) * 2;
|
|
133
|
+
if (request.privacy && route.privacy === request.privacy)
|
|
134
|
+
score += 20;
|
|
135
|
+
if (route.cost)
|
|
136
|
+
score += 6 - tierRank(route.cost);
|
|
137
|
+
if (route.latency)
|
|
138
|
+
score += 6 - tierRank(route.latency);
|
|
139
|
+
return score;
|
|
140
|
+
}
|
|
141
|
+
function stripRouteMetadata(route) {
|
|
142
|
+
const { origins: _origins, priority: _priority, ...entry } = route;
|
|
143
|
+
return entry;
|
|
144
|
+
}
|
|
145
|
+
function tierRank(tier) {
|
|
146
|
+
switch (tier) {
|
|
147
|
+
case "low": return 1;
|
|
148
|
+
case "medium": return 2;
|
|
149
|
+
case "high": return 3;
|
|
150
|
+
}
|
|
151
|
+
}
|
package/dist/event-bus.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* SimpleEventBus: in-memory fire-and-forget. Async handlers don't block emitter.
|
|
5
5
|
* PersistentEventBus: wraps SimpleEventBus + append-only jsonl log for crash recovery.
|
|
6
6
|
*/
|
|
7
|
-
import { appendFileSync, readFileSync, writeFileSync,
|
|
7
|
+
import { appendFileSync, existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
8
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
9
|
+
import { redactSecrets } from "./redaction.js";
|
|
8
10
|
export class SimpleEventBus {
|
|
9
11
|
handlers = new Map();
|
|
10
12
|
on(event, handler) {
|
|
@@ -66,19 +68,23 @@ export class SimpleEventBus {
|
|
|
66
68
|
this.handlers.clear();
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
71
|
+
const DEFAULT_EVENT_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
72
|
+
const DEFAULT_EVENT_LOG_MAX_FILES = 5;
|
|
72
73
|
export class FileEventLog {
|
|
73
74
|
path;
|
|
74
|
-
|
|
75
|
+
maxBytes;
|
|
76
|
+
maxFiles;
|
|
77
|
+
constructor(path, options = {}) {
|
|
75
78
|
this.path = path;
|
|
79
|
+
this.maxBytes = normalizePositiveInt(options.maxBytes, DEFAULT_EVENT_LOG_MAX_BYTES);
|
|
80
|
+
this.maxFiles = normalizePositiveInt(options.maxFiles, DEFAULT_EVENT_LOG_MAX_FILES);
|
|
76
81
|
if (!existsSync(path))
|
|
77
82
|
writeFileSync(path, "");
|
|
78
83
|
}
|
|
79
84
|
append(event, signal) {
|
|
80
85
|
try {
|
|
81
|
-
const line = JSON.stringify({ e: event, s: signal }) + "\n";
|
|
86
|
+
const line = JSON.stringify(redactSecrets({ e: event, s: signal })) + "\n";
|
|
87
|
+
this.rotateIfNeeded(Buffer.byteLength(line, "utf8"));
|
|
82
88
|
appendFileSync(this.path, line);
|
|
83
89
|
}
|
|
84
90
|
catch {
|
|
@@ -86,24 +92,64 @@ export class FileEventLog {
|
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
94
|
async replay(handler) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const content = readFileSync(this.path, "utf-8");
|
|
92
|
-
for (const line of content.split("\n")) {
|
|
93
|
-
if (!line.trim())
|
|
95
|
+
for (const file of this.replayPaths()) {
|
|
96
|
+
if (!existsSync(file))
|
|
94
97
|
continue;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
const content = readFileSync(file, "utf-8");
|
|
99
|
+
for (const line of content.split("\n")) {
|
|
100
|
+
if (!line.trim())
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
const { e, s } = JSON.parse(line);
|
|
104
|
+
handler(e, s);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Skip corrupted lines
|
|
108
|
+
}
|
|
101
109
|
}
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
112
|
close() {
|
|
105
113
|
// No-op for sync file writes
|
|
106
114
|
}
|
|
115
|
+
rotateIfNeeded(incomingBytes) {
|
|
116
|
+
if (this.maxBytes <= 0)
|
|
117
|
+
return;
|
|
118
|
+
if (!existsSync(this.path)) {
|
|
119
|
+
writeFileSync(this.path, "");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const currentBytes = statSync(this.path).size;
|
|
123
|
+
if (currentBytes <= 0 || currentBytes + incomingBytes <= this.maxBytes)
|
|
124
|
+
return;
|
|
125
|
+
const oldest = this.rotatedPath(this.maxFiles);
|
|
126
|
+
if (existsSync(oldest))
|
|
127
|
+
unlinkSync(oldest);
|
|
128
|
+
for (let index = this.maxFiles - 1; index >= 1; index--) {
|
|
129
|
+
const from = this.rotatedPath(index);
|
|
130
|
+
if (existsSync(from))
|
|
131
|
+
renameSync(from, this.rotatedPath(index + 1));
|
|
132
|
+
}
|
|
133
|
+
renameSync(this.path, this.rotatedPath(1));
|
|
134
|
+
writeFileSync(this.path, "");
|
|
135
|
+
}
|
|
136
|
+
replayPaths() {
|
|
137
|
+
const paths = [];
|
|
138
|
+
for (let index = this.maxFiles; index >= 1; index--) {
|
|
139
|
+
paths.push(this.rotatedPath(index));
|
|
140
|
+
}
|
|
141
|
+
paths.push(this.path);
|
|
142
|
+
return paths;
|
|
143
|
+
}
|
|
144
|
+
rotatedPath(index) {
|
|
145
|
+
const dir = dirname(this.path);
|
|
146
|
+
const ext = extname(this.path);
|
|
147
|
+
const base = basename(this.path, ext);
|
|
148
|
+
return join(dir, `${base}.${index}${ext}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function normalizePositiveInt(value, fallback) {
|
|
152
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
107
153
|
}
|
|
108
154
|
// ---------------------------------------------------------------------------
|
|
109
155
|
// PersistentEventBus — SimpleEventBus + append-only log
|