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 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 credentials = await getOrCreateRelayCredentials();
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 { 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