akemon 0.3.4 → 0.3.6

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/dist/cli.js CHANGED
@@ -6,6 +6,10 @@ 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";
11
+ import { appendWorkMemoryNote, buildWorkMemoryContext, } from "./work-memory.js";
12
+ import { renderSoftwareAgentRunResult } from "./software-agent-result-cli.js";
9
13
  import { readFileSync } from "fs";
10
14
  import { fileURLToPath } from "url";
11
15
  import { dirname, join } from "path";
@@ -24,6 +28,43 @@ function clampPositiveInt(value, fallback, max) {
24
28
  return fallback;
25
29
  return Math.min(parsed, max);
26
30
  }
31
+ function parsePrivacyFilterMode(value) {
32
+ if (value === "fast" || value === "pii" || value === "strict")
33
+ return value;
34
+ console.error("--mode must be one of: fast, pii, strict");
35
+ process.exit(1);
36
+ }
37
+ function parsePrivacyFilterBackend(value) {
38
+ if (value === undefined)
39
+ return undefined;
40
+ if (value === "fast" || value === "opf")
41
+ return value;
42
+ console.error("--backend must be one of: fast, opf");
43
+ process.exit(1);
44
+ }
45
+ function parseSoftwareAgentEnvPolicy(value) {
46
+ const normalized = (value || "inherit").trim().toLowerCase();
47
+ if (normalized === "inherit" || normalized === "allowlist")
48
+ return normalized;
49
+ console.error("--software-agent-env must be one of: inherit, allowlist");
50
+ process.exit(1);
51
+ }
52
+ function parseCommaSeparatedCliOption(value) {
53
+ if (!value)
54
+ return undefined;
55
+ const items = value.split(",").map((item) => item.trim()).filter(Boolean);
56
+ return items.length ? items : undefined;
57
+ }
58
+ function parsePositiveIntCliOption(value, optionName) {
59
+ if (value === undefined)
60
+ return undefined;
61
+ const parsed = typeof value === "number" ? value : Number(value);
62
+ if (!Number.isInteger(parsed) || parsed <= 0) {
63
+ console.error(`${optionName} must be a positive integer`);
64
+ process.exit(1);
65
+ }
66
+ return parsed;
67
+ }
27
68
  function printSoftwareAgentTaskList(tasks) {
28
69
  if (!tasks.length) {
29
70
  console.log("No software-agent tasks found.");
@@ -37,10 +78,36 @@ function printSoftwareAgentTaskList(tasks) {
37
78
  : "no-git";
38
79
  const goal = truncateOneLine(task.envelope?.goal || "", 90);
39
80
  console.log(`${task.taskId} ${task.status}/${result} ${duration} ${git} ${task.updatedAt || task.startedAt}`);
81
+ if (task.contextSession?.sessionId)
82
+ console.log(` session: ${task.contextSession.sessionId}`);
83
+ const workMemoryDir = task.envelope?.workMemoryDir || task.result?.workMemoryDir;
84
+ if (workMemoryDir)
85
+ console.log(` work memory: ${workMemoryDir}`);
40
86
  if (goal)
41
87
  console.log(` ${goal}`);
42
88
  }
43
89
  }
90
+ function printSoftwareAgentSessionList(sessions) {
91
+ if (!sessions.length) {
92
+ console.log("No software-agent context sessions found.");
93
+ return;
94
+ }
95
+ for (const session of sessions) {
96
+ const result = session.lastResult?.success === true ? "ok" : session.lastResult?.success === false ? "error" : "pending";
97
+ const duration = typeof session.lastResult?.durationMs === "number" ? `${session.lastResult.durationMs}ms` : "-";
98
+ const updatedAt = session.updatedAt || "-";
99
+ const goal = truncateOneLine(session.lastGoal || "", 90);
100
+ console.log(`${session.sessionId} ${result} ${duration} ${updatedAt}`);
101
+ if (session.lastTaskId)
102
+ console.log(` last task: ${session.lastTaskId}`);
103
+ if (goal)
104
+ console.log(` ${goal}`);
105
+ if (session.packetPath)
106
+ console.log(` context: ${session.packetPath}`);
107
+ if (session.workMemoryDir)
108
+ console.log(` work memory: ${session.workMemoryDir}`);
109
+ }
110
+ }
44
111
  function truncateOneLine(value, max) {
45
112
  const oneLine = value.replace(/\s+/g, " ").trim();
46
113
  if (oneLine.length <= max)
@@ -117,6 +184,7 @@ async function streamLocalOwnerEndpoint(path, opts, body) {
117
184
  const decoder = new TextDecoder();
118
185
  let buffer = "";
119
186
  let failed = false;
187
+ const streamRenderer = new SoftwareAgentStreamCliRenderer();
120
188
  const reader = res.body.getReader();
121
189
  while (true) {
122
190
  const { done, value } = await reader.read();
@@ -126,57 +194,74 @@ async function streamLocalOwnerEndpoint(path, opts, body) {
126
194
  const lines = buffer.split(/\r?\n/);
127
195
  buffer = lines.pop() || "";
128
196
  for (const line of lines) {
129
- if (handleSoftwareAgentStreamLine(line))
197
+ if (streamRenderer.handleLine(line))
130
198
  failed = true;
131
199
  }
132
200
  }
133
201
  buffer += decoder.decode();
134
- if (buffer.trim() && handleSoftwareAgentStreamLine(buffer))
202
+ if (buffer.trim() && streamRenderer.handleLine(buffer))
135
203
  failed = true;
136
204
  if (failed)
137
205
  process.exit(1);
138
206
  }
139
- function handleSoftwareAgentStreamLine(line) {
140
- const trimmed = line.trim();
141
- if (!trimmed)
142
- return false;
143
- let event;
207
+ async function runSoftwareAgentCli(goalParts, opts, forcedSessionId) {
208
+ const body = {
209
+ goal: goalParts.join(" "),
210
+ roleScope: opts.roleScope,
211
+ memoryScope: opts.memoryScope,
212
+ riskLevel: opts.risk,
213
+ };
214
+ if (opts.workdir)
215
+ body.workdir = opts.workdir;
216
+ if (opts.allowOutsideWorkdir)
217
+ body.allowOutsideWorkdir = true;
218
+ if (opts.memorySummary)
219
+ body.memorySummary = opts.memorySummary;
220
+ const workContextBudget = parsePositiveIntCliOption(opts.workContextBudget, "--work-context-budget");
221
+ if (opts.workContext || workContextBudget !== undefined)
222
+ body.includeWorkMemoryContext = true;
223
+ if (workContextBudget !== undefined)
224
+ body.workMemoryContextBudget = workContextBudget;
225
+ const sessionId = forcedSessionId || opts.session;
226
+ if (sessionId)
227
+ body.contextSessionId = sessionId;
228
+ if (opts.deliverable)
229
+ body.deliverable = opts.deliverable;
230
+ if (opts.timeoutMs) {
231
+ const timeoutMs = Number(opts.timeoutMs);
232
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
233
+ console.error("--timeout-ms must be a positive integer");
234
+ process.exit(1);
235
+ }
236
+ body.timeoutMs = timeoutMs;
237
+ }
238
+ if (opts.stream !== false) {
239
+ await streamLocalOwnerEndpoint("/self/software-agent/run-stream", opts, body);
240
+ return;
241
+ }
242
+ const res = await fetchLocalOwnerEndpoint("/self/software-agent/run", opts, {
243
+ method: "POST",
244
+ body: JSON.stringify(body),
245
+ });
246
+ const text = await res.text();
247
+ let data;
144
248
  try {
145
- event = JSON.parse(trimmed);
249
+ data = text ? JSON.parse(text) : {};
146
250
  }
147
251
  catch {
148
- process.stderr.write(`${trimmed}\n`);
149
- return true;
150
- }
151
- if (event.type === "start" && event.taskId) {
152
- process.stderr.write(`[software-agent] started ${event.taskId}\n`);
153
- return false;
154
- }
155
- if (event.type === "stdout" && typeof event.chunk === "string") {
156
- process.stdout.write(event.chunk);
157
- return false;
158
- }
159
- if (event.type === "stderr" && typeof event.chunk === "string") {
160
- process.stderr.write(event.chunk);
161
- return false;
162
- }
163
- if (event.type === "end") {
164
- const result = event.result;
165
- if (result?.success === false && result.error) {
166
- process.stderr.write(`${result.error}\n`);
167
- return true;
168
- }
169
- return false;
252
+ data = { output: text };
170
253
  }
171
- if (event.type === "error") {
172
- process.stderr.write(`${event.error || "Software-agent stream failed"}\n`);
173
- return true;
254
+ if (!res.ok) {
255
+ console.error(data.error || text || `Request failed with HTTP ${res.status}`);
256
+ process.exit(1);
174
257
  }
175
- return false;
258
+ const failed = renderSoftwareAgentRunResult(data);
259
+ if (failed)
260
+ process.exit(1);
176
261
  }
177
262
  program
178
263
  .name("akemon")
179
- .description("Agent work marketplace train your agent, let it work for others")
264
+ .description("Local AI companion runtime with memory, modules, relay sync, and software-agent control")
180
265
  .version(pkg.version);
181
266
  program
182
267
  .command("serve")
@@ -202,6 +287,8 @@ program
202
287
  .option("--without <modules>", "Disable specific modules (comma-separated: biostate,memory)")
203
288
  .option("--script <name>", "Script to load for ScriptModule (default: daily-life)", "daily-life")
204
289
  .option("--terminal", "Enable remote terminal access (PTY)")
290
+ .option("--software-agent-env <policy>", "Software-agent child environment policy: inherit or allowlist", process.env.AKEMON_SOFTWARE_AGENT_ENV_POLICY || "inherit")
291
+ .option("--software-agent-env-allow <vars>", "Comma-separated extra env vars for software-agent allowlist")
205
292
  .option("--relay <url>", "Relay WebSocket URL", RELAY_WS)
206
293
  .action(async (opts) => {
207
294
  const port = parseInt(opts.port);
@@ -237,6 +324,8 @@ program
237
324
  notifyUrl: opts.notify,
238
325
  enabledModules,
239
326
  scriptName: opts.script,
327
+ softwareAgentEnvPolicy: parseSoftwareAgentEnvPolicy(opts.softwareAgentEnv),
328
+ softwareAgentEnvAllowlist: parseCommaSeparatedCliOption(opts.softwareAgentEnvAllow),
240
329
  });
241
330
  console.log(`\nakemon v${pkg.version}`);
242
331
  if (!opts.public) {
@@ -303,44 +392,34 @@ program
303
392
  .option("--memory-scope <scope>", "Memory scope: none|public|task|owner", "owner")
304
393
  .option("--risk <level>", "Risk level: low|medium|high", "medium")
305
394
  .option("--memory-summary <text>", "Pre-filtered memory/context text to include")
395
+ .option("--work-context", "Embed a bounded work-memory context packet in the task envelope")
396
+ .option("--work-context-budget <chars>", "Maximum embedded work-memory context size; also enables --work-context")
397
+ .option("--session <id>", "Akemon-side context session id for explicit software-agent continuity")
306
398
  .option("--deliverable <text>", "Expected output shape")
307
399
  .option("--timeout-ms <ms>", "Task timeout in milliseconds")
308
400
  .option("--no-stream", "Disable local streaming and wait for the final response")
309
401
  .action(async (goalParts, opts) => {
310
- const body = {
311
- goal: goalParts.join(" "),
312
- roleScope: opts.roleScope,
313
- memoryScope: opts.memoryScope,
314
- riskLevel: opts.risk,
315
- };
316
- if (opts.workdir)
317
- body.workdir = opts.workdir;
318
- if (opts.allowOutsideWorkdir)
319
- body.allowOutsideWorkdir = true;
320
- if (opts.memorySummary)
321
- body.memorySummary = opts.memorySummary;
322
- if (opts.deliverable)
323
- body.deliverable = opts.deliverable;
324
- if (opts.timeoutMs) {
325
- const timeoutMs = Number(opts.timeoutMs);
326
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
327
- console.error("--timeout-ms must be a positive integer");
328
- process.exit(1);
329
- }
330
- body.timeoutMs = timeoutMs;
331
- }
332
- if (opts.stream !== false) {
333
- await streamLocalOwnerEndpoint("/self/software-agent/run-stream", opts, body);
334
- return;
335
- }
336
- const data = await callLocalOwnerEndpoint("/self/software-agent/run", opts, {
337
- method: "POST",
338
- body: JSON.stringify(body),
339
- });
340
- if (data.output)
341
- console.log(data.output);
342
- else
343
- console.log(JSON.stringify(data, null, 2));
402
+ await runSoftwareAgentCli(goalParts, opts);
403
+ });
404
+ program
405
+ .command("software-agent-continue")
406
+ .description("Continue an Akemon-side software-agent context session")
407
+ .argument("<sessionId>", "Akemon-side context session id to continue")
408
+ .argument("<goal...>", "Task goal to send to the software agent")
409
+ .option("-p, --port <port>", "Local akemon serve port", "3000")
410
+ .option("-w, --workdir <path>", "Workdir for the software agent (default: serve workdir)")
411
+ .option("--allow-outside-workdir", "Allow the software agent workdir to be outside the serve workdir")
412
+ .option("--role-scope <scope>", "Role scope: owner|public|order|agent|system", "owner")
413
+ .option("--memory-scope <scope>", "Memory scope: none|public|task|owner", "owner")
414
+ .option("--risk <level>", "Risk level: low|medium|high", "medium")
415
+ .option("--memory-summary <text>", "Pre-filtered memory/context text to include")
416
+ .option("--work-context", "Embed a bounded work-memory context packet in the task envelope")
417
+ .option("--work-context-budget <chars>", "Maximum embedded work-memory context size; also enables --work-context")
418
+ .option("--deliverable <text>", "Expected output shape")
419
+ .option("--timeout-ms <ms>", "Task timeout in milliseconds")
420
+ .option("--no-stream", "Disable local streaming and wait for the final response")
421
+ .action(async (sessionId, goalParts, opts) => {
422
+ await runSoftwareAgentCli(goalParts, opts, sessionId);
344
423
  });
345
424
  program
346
425
  .command("software-agent-status")
@@ -358,20 +437,78 @@ program
358
437
  .argument("[taskId]", "Task id to inspect")
359
438
  .option("-p, --port <port>", "Local akemon serve port", "3000")
360
439
  .option("-l, --limit <n>", "Maximum recent tasks to list", "20")
440
+ .option("--session <id>", "Filter listed tasks by Akemon-side context session id")
441
+ .option("--context", "Print the task's Akemon TASK_CONTEXT.md content when inspecting one task")
361
442
  .option("--json", "Print raw JSON")
362
443
  .action(async (taskId, opts) => {
444
+ if (!taskId && opts.context) {
445
+ console.error("--context requires a taskId");
446
+ process.exit(1);
447
+ }
448
+ if (taskId && opts.session) {
449
+ console.error("--session cannot be used when inspecting a single taskId");
450
+ process.exit(1);
451
+ }
363
452
  const path = taskId
364
- ? `/self/software-agent/tasks/${encodeURIComponent(taskId)}`
365
- : `/self/software-agent/tasks?limit=${clampPositiveInt(opts.limit, 20, 100)}`;
453
+ ? `/self/software-agent/tasks/${encodeURIComponent(taskId)}${opts.context ? "?includeContext=1" : ""}`
454
+ : `/self/software-agent/tasks?limit=${clampPositiveInt(opts.limit, 20, 100)}${opts.session ? `&session=${encodeURIComponent(opts.session)}` : ""}`;
366
455
  const data = await callLocalOwnerEndpoint(path, opts, {
367
456
  method: "GET",
368
457
  });
458
+ if (taskId && opts.context) {
459
+ const contextPacket = data.contextSession?.contextPacket;
460
+ if (typeof contextPacket === "string" && contextPacket.length > 0) {
461
+ process.stdout.write(contextPacket);
462
+ if (!contextPacket.endsWith("\n"))
463
+ process.stdout.write("\n");
464
+ return;
465
+ }
466
+ console.error("No TASK_CONTEXT.md content found for this task.");
467
+ process.exit(1);
468
+ }
369
469
  if (opts.json || taskId) {
370
470
  console.log(JSON.stringify(taskId ? data.task : data, null, 2));
371
471
  return;
372
472
  }
373
473
  printSoftwareAgentTaskList(Array.isArray(data.tasks) ? data.tasks : []);
374
474
  });
475
+ program
476
+ .command("software-agent-sessions")
477
+ .description("List or inspect owner-only Akemon-side software-agent context sessions")
478
+ .argument("[sessionId]", "Context session id to inspect")
479
+ .option("-p, --port <port>", "Local akemon serve port", "3000")
480
+ .option("-l, --limit <n>", "Maximum recent sessions to list", "20")
481
+ .option("--context", "Print the session TASK_CONTEXT.md content")
482
+ .option("--json", "Print raw JSON")
483
+ .action(async (sessionId, opts) => {
484
+ const query = sessionId && opts.context ? "?includeContext=1" : "";
485
+ const path = sessionId
486
+ ? `/self/software-agent/sessions/${encodeURIComponent(sessionId)}${query}`
487
+ : `/self/software-agent/sessions?limit=${clampPositiveInt(opts.limit, 20, 100)}`;
488
+ const data = await callLocalOwnerEndpoint(path, opts, {
489
+ method: "GET",
490
+ });
491
+ if (sessionId) {
492
+ if (opts.context) {
493
+ const contextPacket = data.session?.contextPacket;
494
+ if (typeof contextPacket === "string" && contextPacket.length > 0) {
495
+ process.stdout.write(contextPacket);
496
+ if (!contextPacket.endsWith("\n"))
497
+ process.stdout.write("\n");
498
+ return;
499
+ }
500
+ console.error("No TASK_CONTEXT.md content found for this session.");
501
+ process.exit(1);
502
+ }
503
+ console.log(JSON.stringify(data.session, null, 2));
504
+ return;
505
+ }
506
+ if (opts.json) {
507
+ console.log(JSON.stringify(data, null, 2));
508
+ return;
509
+ }
510
+ printSoftwareAgentSessionList(Array.isArray(data.sessions) ? data.sessions : []);
511
+ });
375
512
  program
376
513
  .command("software-agent-reset")
377
514
  .description("Reset the owner-only local software-agent peripheral session")
@@ -382,6 +519,109 @@ program
382
519
  });
383
520
  console.log(JSON.stringify(data, null, 2));
384
521
  });
522
+ program
523
+ .command("privacy-filter")
524
+ .description("Sanitize text with built-in redaction and optional OpenAI Privacy Filter")
525
+ .argument("<text...>", "Text to sanitize")
526
+ .option("--mode <mode>", "Mode: fast, pii, or strict", "fast")
527
+ .option("--backend <backend>", "Backend: fast or opf")
528
+ .option("--command <command>", "OPF command (default: opf)")
529
+ .option("--device <device>", "OPF device, e.g. cpu or cuda")
530
+ .option("--checkpoint <path>", "OPF checkpoint directory")
531
+ .option("--timeout-ms <ms>", "OPF timeout in milliseconds")
532
+ .option("--max-input-chars <n>", "Maximum text length to pass to OPF")
533
+ .option("--json", "Print result metadata as JSON")
534
+ .action(async (textParts, opts) => {
535
+ try {
536
+ const result = await sanitizeText(textParts.join(" "), {
537
+ mode: parsePrivacyFilterMode(opts.mode),
538
+ backend: parsePrivacyFilterBackend(opts.backend),
539
+ command: opts.command,
540
+ device: opts.device,
541
+ checkpoint: opts.checkpoint,
542
+ timeoutMs: parsePositiveIntCliOption(opts.timeoutMs, "--timeout-ms"),
543
+ maxInputChars: parsePositiveIntCliOption(opts.maxInputChars, "--max-input-chars"),
544
+ });
545
+ if (opts.json) {
546
+ console.log(JSON.stringify(result, null, 2));
547
+ return;
548
+ }
549
+ console.log(result.text);
550
+ for (const warning of result.warnings) {
551
+ console.error(`[privacy-filter] ${warning}`);
552
+ }
553
+ }
554
+ catch (error) {
555
+ if (error instanceof PrivacyFilterUnavailableError || error instanceof TypeError) {
556
+ console.error(error.message);
557
+ process.exit(1);
558
+ }
559
+ throw error;
560
+ }
561
+ });
562
+ program
563
+ .command("work-context")
564
+ .description("Print a work-memory context packet for external software agents")
565
+ .option("-w, --workdir <path>", "Akemon workdir (default: cwd)")
566
+ .option("-n, --name <name>", "Agent name", "my-agent")
567
+ .option("--purpose <text>", "Purpose of this context packet", "external software-agent work context")
568
+ .option("--budget <chars>", "Maximum packet size in characters", "12000")
569
+ .option("--json", "Print raw JSON")
570
+ .action(async (opts) => {
571
+ try {
572
+ const packet = await buildWorkMemoryContext({
573
+ workdir: opts.workdir || process.cwd(),
574
+ agentName: opts.name,
575
+ purpose: opts.purpose,
576
+ budget: parsePositiveIntCliOption(opts.budget, "--budget"),
577
+ });
578
+ if (opts.json) {
579
+ console.log(JSON.stringify(packet, null, 2));
580
+ return;
581
+ }
582
+ process.stdout.write(packet.text);
583
+ if (!packet.text.endsWith("\n"))
584
+ process.stdout.write("\n");
585
+ }
586
+ catch (error) {
587
+ console.error(error instanceof Error ? error.message : String(error));
588
+ process.exit(1);
589
+ }
590
+ });
591
+ program
592
+ .command("work-note")
593
+ .description("Append a note to Akemon work memory")
594
+ .argument("<text...>", "Durable work-memory note")
595
+ .option("-w, --workdir <path>", "Akemon workdir (default: cwd)")
596
+ .option("-n, --name <name>", "Agent name", "my-agent")
597
+ .option("--source <source>", "Note source, e.g. user, codex, or claude-code", "user")
598
+ .option("--session <id>", "External or Akemon-side session id")
599
+ .option("--kind <kind>", "Work-memory kind, e.g. note, decision, command, project", "note")
600
+ .option("--target <path>", "Optional target file under the work memory directory")
601
+ .option("--json", "Print raw JSON")
602
+ .action(async (textParts, opts) => {
603
+ try {
604
+ const result = await appendWorkMemoryNote({
605
+ workdir: opts.workdir || process.cwd(),
606
+ agentName: opts.name,
607
+ text: textParts.join(" "),
608
+ source: opts.source,
609
+ sessionId: opts.session,
610
+ kind: opts.kind,
611
+ target: opts.target,
612
+ });
613
+ if (opts.json) {
614
+ console.log(JSON.stringify(result, null, 2));
615
+ return;
616
+ }
617
+ console.log(`Work memory note appended: ${result.note.id}`);
618
+ console.log(`Path: ${result.path}`);
619
+ }
620
+ catch (error) {
621
+ console.error(error instanceof Error ? error.message : String(error));
622
+ process.exit(1);
623
+ }
624
+ });
385
625
  program
386
626
  .command("dashboard")
387
627
  .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 { resolveEngineConfig, } from "./engine-routing.js";
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 entry = resolveEngineConfig(routing, origin);
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 {
@@ -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
+ }