akemon 0.3.4 → 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 CHANGED
@@ -175,6 +175,24 @@ Current Batch 5 status: the Codex integration uses `codex exec` as a one-shot ba
175
175
 
176
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
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.
195
+
178
196
  ## Serve Options
179
197
 
180
198
  ```bash
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";
@@ -24,6 +26,43 @@ function clampPositiveInt(value, fallback, max) {
24
26
  return fallback;
25
27
  return Math.min(parsed, max);
26
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
+ }
27
66
  function printSoftwareAgentTaskList(tasks) {
28
67
  if (!tasks.length) {
29
68
  console.log("No software-agent tasks found.");
@@ -117,6 +156,7 @@ async function streamLocalOwnerEndpoint(path, opts, body) {
117
156
  const decoder = new TextDecoder();
118
157
  let buffer = "";
119
158
  let failed = false;
159
+ const streamRenderer = new SoftwareAgentStreamCliRenderer();
120
160
  const reader = res.body.getReader();
121
161
  while (true) {
122
162
  const { done, value } = await reader.read();
@@ -126,54 +166,16 @@ async function streamLocalOwnerEndpoint(path, opts, body) {
126
166
  const lines = buffer.split(/\r?\n/);
127
167
  buffer = lines.pop() || "";
128
168
  for (const line of lines) {
129
- if (handleSoftwareAgentStreamLine(line))
169
+ if (streamRenderer.handleLine(line))
130
170
  failed = true;
131
171
  }
132
172
  }
133
173
  buffer += decoder.decode();
134
- if (buffer.trim() && handleSoftwareAgentStreamLine(buffer))
174
+ if (buffer.trim() && streamRenderer.handleLine(buffer))
135
175
  failed = true;
136
176
  if (failed)
137
177
  process.exit(1);
138
178
  }
139
- function handleSoftwareAgentStreamLine(line) {
140
- const trimmed = line.trim();
141
- if (!trimmed)
142
- return false;
143
- let event;
144
- try {
145
- event = JSON.parse(trimmed);
146
- }
147
- 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;
170
- }
171
- if (event.type === "error") {
172
- process.stderr.write(`${event.error || "Software-agent stream failed"}\n`);
173
- return true;
174
- }
175
- return false;
176
- }
177
179
  program
178
180
  .name("akemon")
179
181
  .description("Agent work marketplace — train your agent, let it work for others")
@@ -202,6 +204,8 @@ program
202
204
  .option("--without <modules>", "Disable specific modules (comma-separated: biostate,memory)")
203
205
  .option("--script <name>", "Script to load for ScriptModule (default: daily-life)", "daily-life")
204
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")
205
209
  .option("--relay <url>", "Relay WebSocket URL", RELAY_WS)
206
210
  .action(async (opts) => {
207
211
  const port = parseInt(opts.port);
@@ -237,6 +241,8 @@ program
237
241
  notifyUrl: opts.notify,
238
242
  enabledModules,
239
243
  scriptName: opts.script,
244
+ softwareAgentEnvPolicy: parseSoftwareAgentEnvPolicy(opts.softwareAgentEnv),
245
+ softwareAgentEnvAllowlist: parseCommaSeparatedCliOption(opts.softwareAgentEnvAllow),
240
246
  });
241
247
  console.log(`\nakemon v${pkg.version}`);
242
248
  if (!opts.public) {
@@ -303,6 +309,7 @@ program
303
309
  .option("--memory-scope <scope>", "Memory scope: none|public|task|owner", "owner")
304
310
  .option("--risk <level>", "Risk level: low|medium|high", "medium")
305
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")
306
313
  .option("--deliverable <text>", "Expected output shape")
307
314
  .option("--timeout-ms <ms>", "Task timeout in milliseconds")
308
315
  .option("--no-stream", "Disable local streaming and wait for the final response")
@@ -319,6 +326,8 @@ program
319
326
  body.allowOutsideWorkdir = true;
320
327
  if (opts.memorySummary)
321
328
  body.memorySummary = opts.memorySummary;
329
+ if (opts.session)
330
+ body.contextSessionId = opts.session;
322
331
  if (opts.deliverable)
323
332
  body.deliverable = opts.deliverable;
324
333
  if (opts.timeoutMs) {
@@ -382,6 +391,46 @@ program
382
391
  });
383
392
  console.log(JSON.stringify(data, null, 2));
384
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
+ });
385
434
  program
386
435
  .command("dashboard")
387
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 { 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
+ }
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, existsSync } from "node:fs";
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
- // FileEventLog append-only jsonl file for event persistence
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
- constructor(path) {
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
- if (!existsSync(this.path))
90
- return;
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
- try {
96
- const { e, s } = JSON.parse(line);
97
- handler(e, s);
98
- }
99
- catch {
100
- // Skip corrupted lines
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