akemon 0.3.1 → 0.3.3
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 +18 -4
- package/dist/cli.js +95 -0
- package/dist/server.js +161 -6
- package/dist/software-agent-peripheral.js +382 -0
- package/dist/types.js +3 -0
- package/package.json +4 -4
- package/dist/context.test.js +0 -90
- package/dist/dashboard.html +0 -552
- package/dist/engine-queue.test.js +0 -99
- package/dist/engine-routing.test.js +0 -122
- package/dist/engine-stream.test.js +0 -103
- package/dist/orphan-scan.test.js +0 -81
- package/dist/reflection-module.integration.test.js +0 -180
- package/dist/reflection-module.test.js +0 -66
- package/dist/role-module.test.js +0 -208
- package/dist/task-helpers.test.js +0 -88
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SoftwareAgentPeripheral — wraps full agent software as an Akemon peripheral.
|
|
3
|
+
*
|
|
4
|
+
* This is distinct from EnginePeripheral. Engines are pure compute: modules
|
|
5
|
+
* prepare context and the engine returns text. Software agent peripherals are
|
|
6
|
+
* external software bodies such as Codex CLI or Claude Code: they may manage
|
|
7
|
+
* their own repo context, tools, skills, and multi-step execution loop.
|
|
8
|
+
*
|
|
9
|
+
* Batch 5 starts with a conservative Codex `exec` baseline. It gives Akemon a
|
|
10
|
+
* stable task envelope, streaming, reset, and event shape before switching the
|
|
11
|
+
* transport to app-server or a true persistent interactive session.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "crypto";
|
|
14
|
+
import { spawn } from "child_process";
|
|
15
|
+
import { StringDecoder } from "string_decoder";
|
|
16
|
+
import { SIG, sig } from "./types.js";
|
|
17
|
+
import { sendTaskEnd, sendTaskStart, sendTaskStream } from "./relay-client.js";
|
|
18
|
+
const defaultTaskRelay = {
|
|
19
|
+
sendTaskStart,
|
|
20
|
+
sendTaskStream,
|
|
21
|
+
sendTaskEnd,
|
|
22
|
+
};
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
24
|
+
const MAX_OWNER_TIMEOUT_MS = 60 * 60 * 1000;
|
|
25
|
+
const DEFAULT_OWNER_ALLOWED_ACTIONS = ["read repository files", "edit files in workdir", "run project tests"];
|
|
26
|
+
const DEFAULT_OWNER_FORBIDDEN_ACTIONS = [
|
|
27
|
+
"read Akemon private memory outside this envelope",
|
|
28
|
+
"access files outside the stated workdir unless explicitly needed and reported",
|
|
29
|
+
];
|
|
30
|
+
const ROLE_SCOPES = ["owner", "public", "order", "agent", "system"];
|
|
31
|
+
const MEMORY_SCOPES = ["none", "public", "task", "owner"];
|
|
32
|
+
const RISK_LEVELS = ["low", "medium", "high"];
|
|
33
|
+
export class CodexSoftwareAgentPeripheral {
|
|
34
|
+
id;
|
|
35
|
+
name;
|
|
36
|
+
capabilities = ["code-agent", "repo-inspect", "repo-edit", "tool-use", "skill-use", "streaming"];
|
|
37
|
+
tags = ["software-agent", "codex"];
|
|
38
|
+
config;
|
|
39
|
+
bus = null;
|
|
40
|
+
activeChild = null;
|
|
41
|
+
activeTaskId = null;
|
|
42
|
+
sessionId = randomUUID();
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this.config = config;
|
|
45
|
+
this.id = config.id || "software-agent:codex";
|
|
46
|
+
this.name = config.name || "Codex CLI Software Agent";
|
|
47
|
+
}
|
|
48
|
+
async start(bus) {
|
|
49
|
+
this.bus = bus;
|
|
50
|
+
}
|
|
51
|
+
async stop() {
|
|
52
|
+
await this.resetSession();
|
|
53
|
+
this.bus = null;
|
|
54
|
+
}
|
|
55
|
+
async startSession() {
|
|
56
|
+
// Codex `exec` baseline is oneshot per task. Keep a session id so callers
|
|
57
|
+
// can observe resets and future transports can preserve this interface.
|
|
58
|
+
if (!this.sessionId)
|
|
59
|
+
this.sessionId = randomUUID();
|
|
60
|
+
}
|
|
61
|
+
async resetSession() {
|
|
62
|
+
if (this.activeChild?.pid) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(-this.activeChild.pid, "SIGTERM");
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(-this.activeChild.pid, "SIGKILL");
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
}, 3000).unref();
|
|
73
|
+
}
|
|
74
|
+
this.activeChild = null;
|
|
75
|
+
this.activeTaskId = null;
|
|
76
|
+
this.sessionId = randomUUID();
|
|
77
|
+
}
|
|
78
|
+
getState() {
|
|
79
|
+
return {
|
|
80
|
+
id: this.id,
|
|
81
|
+
sessionId: this.sessionId,
|
|
82
|
+
activeTaskId: this.activeTaskId,
|
|
83
|
+
busy: !!this.activeChild,
|
|
84
|
+
transport: "codex-exec",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async send(signal) {
|
|
88
|
+
if (signal.type !== SIG.SOFTWARE_AGENT_TASK)
|
|
89
|
+
return null;
|
|
90
|
+
const envelope = signal.data.envelope;
|
|
91
|
+
if (!envelope) {
|
|
92
|
+
return sig(SIG.SOFTWARE_AGENT_RESPONSE, {
|
|
93
|
+
success: false,
|
|
94
|
+
error: "Missing task envelope",
|
|
95
|
+
}, this.id);
|
|
96
|
+
}
|
|
97
|
+
const result = await this.sendTask(envelope);
|
|
98
|
+
return sig(SIG.SOFTWARE_AGENT_RESPONSE, { ...result }, this.id);
|
|
99
|
+
}
|
|
100
|
+
async sendTask(envelope, signal) {
|
|
101
|
+
if (this.activeChild) {
|
|
102
|
+
throw new Error(`Software agent busy (task=${this.activeTaskId})`);
|
|
103
|
+
}
|
|
104
|
+
const taskId = envelope.taskId || `sw_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
|
105
|
+
const workdir = envelope.workdir || this.config.workdir;
|
|
106
|
+
const prompt = buildTaskEnvelopePrompt({ ...envelope, taskId, workdir });
|
|
107
|
+
const { cmd, args } = buildCodexExecCommand({
|
|
108
|
+
command: this.config.command || "codex",
|
|
109
|
+
workdir,
|
|
110
|
+
model: this.config.model,
|
|
111
|
+
sandbox: this.config.sandbox || "workspace-write",
|
|
112
|
+
});
|
|
113
|
+
const startedAt = Date.now();
|
|
114
|
+
const relay = this.config.taskRelay || defaultTaskRelay;
|
|
115
|
+
const commandLine = [cmd, ...args].join(" ");
|
|
116
|
+
const spawnImpl = this.config.spawnImpl || spawn;
|
|
117
|
+
const timeoutMs = envelope.timeoutMs || this.config.defaultTimeoutMs || DEFAULT_TIMEOUT_MS;
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
this.activeTaskId = taskId;
|
|
120
|
+
relay.sendTaskStart(taskId, "software_agent", commandLine);
|
|
121
|
+
this.bus?.emit(SIG.TASK_STARTED, sig(SIG.TASK_STARTED, {
|
|
122
|
+
taskId,
|
|
123
|
+
taskType: "software_agent",
|
|
124
|
+
description: envelope.goal,
|
|
125
|
+
peripheral: this.id,
|
|
126
|
+
sessionId: this.sessionId,
|
|
127
|
+
}, this.id));
|
|
128
|
+
let child;
|
|
129
|
+
try {
|
|
130
|
+
child = spawnImpl(cmd, args, {
|
|
131
|
+
cwd: workdir,
|
|
132
|
+
env: process.env,
|
|
133
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
134
|
+
detached: true,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
this.activeChild = null;
|
|
139
|
+
this.activeTaskId = null;
|
|
140
|
+
const durationMs = Date.now() - startedAt;
|
|
141
|
+
relay.sendTaskEnd(taskId, null, durationMs);
|
|
142
|
+
const result = {
|
|
143
|
+
success: false,
|
|
144
|
+
taskId,
|
|
145
|
+
output: "",
|
|
146
|
+
error: err.message || String(err),
|
|
147
|
+
exitCode: null,
|
|
148
|
+
durationMs,
|
|
149
|
+
};
|
|
150
|
+
this.bus?.emit(SIG.TASK_FAILED, sig(SIG.TASK_FAILED, result, this.id));
|
|
151
|
+
resolve(result);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.activeChild = child;
|
|
155
|
+
let stdout = "";
|
|
156
|
+
let stderr = "";
|
|
157
|
+
let finished = false;
|
|
158
|
+
let aborted = false;
|
|
159
|
+
const outDecoder = new StringDecoder("utf8");
|
|
160
|
+
const errDecoder = new StringDecoder("utf8");
|
|
161
|
+
const finish = (exitCode, error) => {
|
|
162
|
+
if (finished)
|
|
163
|
+
return;
|
|
164
|
+
finished = true;
|
|
165
|
+
signal?.removeEventListener("abort", onAbort);
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
const tailOut = outDecoder.end();
|
|
168
|
+
const tailErr = errDecoder.end();
|
|
169
|
+
if (tailOut) {
|
|
170
|
+
stdout += tailOut;
|
|
171
|
+
relay.sendTaskStream(taskId, "stdout", tailOut);
|
|
172
|
+
}
|
|
173
|
+
if (tailErr) {
|
|
174
|
+
stderr += tailErr;
|
|
175
|
+
relay.sendTaskStream(taskId, "stderr", tailErr);
|
|
176
|
+
}
|
|
177
|
+
const durationMs = Date.now() - startedAt;
|
|
178
|
+
relay.sendTaskEnd(taskId, exitCode, durationMs);
|
|
179
|
+
this.activeChild = null;
|
|
180
|
+
this.activeTaskId = null;
|
|
181
|
+
const output = stdout.trim() || stderr.trim();
|
|
182
|
+
const success = !error && !aborted && exitCode === 0;
|
|
183
|
+
const result = {
|
|
184
|
+
success,
|
|
185
|
+
taskId,
|
|
186
|
+
output,
|
|
187
|
+
error: success ? undefined : error || stderr.trim() || `codex exited with code ${exitCode}`,
|
|
188
|
+
exitCode,
|
|
189
|
+
durationMs,
|
|
190
|
+
};
|
|
191
|
+
this.bus?.emit(success ? SIG.TASK_COMPLETED : SIG.TASK_FAILED, sig(success ? SIG.TASK_COMPLETED : SIG.TASK_FAILED, {
|
|
192
|
+
...result,
|
|
193
|
+
taskLabel: `software_agent:${this.id}`,
|
|
194
|
+
}, this.id));
|
|
195
|
+
resolve(result);
|
|
196
|
+
};
|
|
197
|
+
const onAbort = () => {
|
|
198
|
+
if (aborted || !child.pid)
|
|
199
|
+
return;
|
|
200
|
+
aborted = true;
|
|
201
|
+
try {
|
|
202
|
+
process.kill(-child.pid, "SIGTERM");
|
|
203
|
+
}
|
|
204
|
+
catch { }
|
|
205
|
+
setTimeout(() => {
|
|
206
|
+
try {
|
|
207
|
+
process.kill(-child.pid, "SIGKILL");
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
}, 3000).unref();
|
|
211
|
+
};
|
|
212
|
+
const timer = setTimeout(() => {
|
|
213
|
+
if (!child.pid)
|
|
214
|
+
return;
|
|
215
|
+
aborted = true;
|
|
216
|
+
try {
|
|
217
|
+
process.kill(-child.pid, "SIGTERM");
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
220
|
+
setTimeout(() => {
|
|
221
|
+
try {
|
|
222
|
+
process.kill(-child.pid, "SIGKILL");
|
|
223
|
+
}
|
|
224
|
+
catch { }
|
|
225
|
+
}, 3000).unref();
|
|
226
|
+
}, timeoutMs);
|
|
227
|
+
if (signal) {
|
|
228
|
+
if (signal.aborted)
|
|
229
|
+
onAbort();
|
|
230
|
+
else
|
|
231
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
232
|
+
}
|
|
233
|
+
child.stdin?.on("error", () => { });
|
|
234
|
+
child.stdin?.write(prompt);
|
|
235
|
+
child.stdin?.end();
|
|
236
|
+
child.stdout?.on("data", (chunk) => {
|
|
237
|
+
const text = outDecoder.write(chunk);
|
|
238
|
+
if (!text)
|
|
239
|
+
return;
|
|
240
|
+
stdout += text;
|
|
241
|
+
relay.sendTaskStream(taskId, "stdout", text);
|
|
242
|
+
});
|
|
243
|
+
child.stderr?.on("data", (chunk) => {
|
|
244
|
+
const text = errDecoder.write(chunk);
|
|
245
|
+
if (!text)
|
|
246
|
+
return;
|
|
247
|
+
stderr += text;
|
|
248
|
+
relay.sendTaskStream(taskId, "stderr", text);
|
|
249
|
+
});
|
|
250
|
+
child.on("close", (code) => {
|
|
251
|
+
child.unref();
|
|
252
|
+
finish(code);
|
|
253
|
+
});
|
|
254
|
+
child.on("error", (err) => {
|
|
255
|
+
child.unref();
|
|
256
|
+
finish(null, err.message);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
export function buildTaskEnvelopePrompt(envelope) {
|
|
262
|
+
const lines = [
|
|
263
|
+
"[Akemon Software Peripheral Task Envelope]",
|
|
264
|
+
"",
|
|
265
|
+
`Task ID: ${envelope.taskId || "(unspecified)"}`,
|
|
266
|
+
`Source module: ${envelope.sourceModule}`,
|
|
267
|
+
`Purpose: ${envelope.purpose}`,
|
|
268
|
+
`Role scope: ${envelope.roleScope}`,
|
|
269
|
+
`Memory scope: ${envelope.memoryScope}`,
|
|
270
|
+
`Risk level: ${envelope.riskLevel}`,
|
|
271
|
+
`Workdir: ${envelope.workdir}`,
|
|
272
|
+
"",
|
|
273
|
+
"Goal:",
|
|
274
|
+
envelope.goal,
|
|
275
|
+
"",
|
|
276
|
+
];
|
|
277
|
+
if (envelope.memorySummary?.trim()) {
|
|
278
|
+
lines.push("Visible Akemon memory/context:");
|
|
279
|
+
lines.push(envelope.memorySummary.trim());
|
|
280
|
+
lines.push("");
|
|
281
|
+
}
|
|
282
|
+
if (envelope.allowedActions?.length) {
|
|
283
|
+
lines.push("Allowed actions:");
|
|
284
|
+
for (const item of envelope.allowedActions)
|
|
285
|
+
lines.push(`- ${item}`);
|
|
286
|
+
lines.push("");
|
|
287
|
+
}
|
|
288
|
+
if (envelope.forbiddenActions?.length) {
|
|
289
|
+
lines.push("Forbidden actions:");
|
|
290
|
+
for (const item of envelope.forbiddenActions)
|
|
291
|
+
lines.push(`- ${item}`);
|
|
292
|
+
lines.push("");
|
|
293
|
+
}
|
|
294
|
+
if (envelope.deliverable?.trim()) {
|
|
295
|
+
lines.push("Expected deliverable:");
|
|
296
|
+
lines.push(envelope.deliverable.trim());
|
|
297
|
+
lines.push("");
|
|
298
|
+
}
|
|
299
|
+
lines.push("Instructions:");
|
|
300
|
+
lines.push("- Treat this envelope as the complete Akemon-provided context for this task.");
|
|
301
|
+
lines.push("- Do not attempt to read Akemon private memory outside the visible context above.");
|
|
302
|
+
lines.push("- Work only in the stated workdir unless the envelope explicitly allows otherwise.");
|
|
303
|
+
lines.push("- Report what changed, what you verified, and any remaining risk.");
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
306
|
+
export function createOwnerTaskEnvelope(body, defaultWorkdir) {
|
|
307
|
+
const goal = typeof body?.goal === "string" ? body.goal.trim() : "";
|
|
308
|
+
if (!goal)
|
|
309
|
+
throw new Error("Missing required string field: goal");
|
|
310
|
+
const callerForbiddenActions = readOptionalStringArray(body.forbiddenActions, "forbiddenActions");
|
|
311
|
+
return {
|
|
312
|
+
taskId: readOptionalString(body.taskId, "taskId"),
|
|
313
|
+
sourceModule: "owner-http",
|
|
314
|
+
purpose: readOptionalString(body.purpose, "purpose") || "owner software-agent task",
|
|
315
|
+
goal,
|
|
316
|
+
workdir: readOptionalString(body.workdir, "workdir") || defaultWorkdir,
|
|
317
|
+
roleScope: readEnum(body.roleScope, "roleScope", ROLE_SCOPES, "owner"),
|
|
318
|
+
memoryScope: readEnum(body.memoryScope, "memoryScope", MEMORY_SCOPES, "owner"),
|
|
319
|
+
riskLevel: readEnum(body.riskLevel, "riskLevel", RISK_LEVELS, "medium"),
|
|
320
|
+
allowedActions: body.allowedActions !== undefined
|
|
321
|
+
? readOptionalStringArray(body.allowedActions, "allowedActions")
|
|
322
|
+
: [...DEFAULT_OWNER_ALLOWED_ACTIONS],
|
|
323
|
+
forbiddenActions: [...new Set([...DEFAULT_OWNER_FORBIDDEN_ACTIONS, ...callerForbiddenActions])],
|
|
324
|
+
memorySummary: typeof body.memorySummary === "string" ? body.memorySummary : "",
|
|
325
|
+
deliverable: typeof body.deliverable === "string"
|
|
326
|
+
? body.deliverable
|
|
327
|
+
: "Return a concise engineering summary with changes, verification, and remaining risks.",
|
|
328
|
+
timeoutMs: readTimeoutMs(body.timeoutMs),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function readOptionalString(value, field) {
|
|
332
|
+
if (value === undefined || value === null)
|
|
333
|
+
return undefined;
|
|
334
|
+
if (typeof value !== "string")
|
|
335
|
+
throw new Error(`Invalid ${field}: expected string`);
|
|
336
|
+
const trimmed = value.trim();
|
|
337
|
+
return trimmed || undefined;
|
|
338
|
+
}
|
|
339
|
+
function readOptionalStringArray(value, field) {
|
|
340
|
+
if (value === undefined || value === null)
|
|
341
|
+
return [];
|
|
342
|
+
if (!Array.isArray(value))
|
|
343
|
+
throw new Error(`Invalid ${field}: expected array of strings`);
|
|
344
|
+
return value.map((item, index) => {
|
|
345
|
+
if (typeof item !== "string" || !item.trim()) {
|
|
346
|
+
throw new Error(`Invalid ${field}[${index}]: expected non-empty string`);
|
|
347
|
+
}
|
|
348
|
+
return item.trim();
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
function readEnum(value, field, allowed, fallback) {
|
|
352
|
+
if (value === undefined || value === null || value === "")
|
|
353
|
+
return fallback;
|
|
354
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
355
|
+
throw new Error(`Invalid ${field}: expected one of ${allowed.join(", ")}`);
|
|
356
|
+
}
|
|
357
|
+
return value;
|
|
358
|
+
}
|
|
359
|
+
function readTimeoutMs(value) {
|
|
360
|
+
if (value === undefined || value === null)
|
|
361
|
+
return undefined;
|
|
362
|
+
if (typeof value !== "number"
|
|
363
|
+
|| !Number.isInteger(value)
|
|
364
|
+
|| value <= 0
|
|
365
|
+
|| value > MAX_OWNER_TIMEOUT_MS) {
|
|
366
|
+
throw new Error(`Invalid timeoutMs: expected integer between 1 and ${MAX_OWNER_TIMEOUT_MS}`);
|
|
367
|
+
}
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
function buildCodexExecCommand(opts) {
|
|
371
|
+
const args = [
|
|
372
|
+
"exec",
|
|
373
|
+
"--skip-git-repo-check",
|
|
374
|
+
"--color", "never",
|
|
375
|
+
"-s", opts.sandbox,
|
|
376
|
+
"-C", opts.workdir,
|
|
377
|
+
];
|
|
378
|
+
if (opts.model)
|
|
379
|
+
args.push("-m", opts.model);
|
|
380
|
+
args.push("-");
|
|
381
|
+
return { cmd: opts.command, args };
|
|
382
|
+
}
|
package/dist/types.js
CHANGED
|
@@ -23,6 +23,9 @@ export const SIG = {
|
|
|
23
23
|
ENGINE_RESPONSE: "engine:response",
|
|
24
24
|
ENGINE_BUSY: "engine:busy",
|
|
25
25
|
ENGINE_FREE: "engine:free",
|
|
26
|
+
// Software agent peripherals (Codex CLI, Claude Code, etc.)
|
|
27
|
+
SOFTWARE_AGENT_TASK: "software-agent:task",
|
|
28
|
+
SOFTWARE_AGENT_RESPONSE: "software-agent:response",
|
|
26
29
|
// Task execution
|
|
27
30
|
TASK_RECEIVED: "task:received",
|
|
28
31
|
TASK_STARTED: "task:started",
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akemon",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Agent work marketplace — train your agent, let it work for others",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/lhead/akemon"
|
|
9
|
+
"url": "git+https://github.com/lhead/akemon.git"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"ai",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"gemini"
|
|
19
19
|
],
|
|
20
20
|
"bin": {
|
|
21
|
-
"akemon": "
|
|
21
|
+
"akemon": "dist/cli.js"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"dist",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"README.md"
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
|
-
"build": "tsc && cp src/live.html dist/live.html",
|
|
29
|
+
"build": "rm -rf dist && tsc && cp src/live.html dist/live.html",
|
|
30
30
|
"dev": "tsc --watch",
|
|
31
31
|
"start": "node dist/cli.js",
|
|
32
32
|
"postinstall": "node scripts/fix-node-pty.cjs",
|
package/dist/context.test.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it, before, after } from "node:test";
|
|
3
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
// We import the functions we need to test. appendMessage and loadConversation
|
|
7
|
-
// need a workdir on disk; parseConversation is internal — tested via loadConversation.
|
|
8
|
-
import { appendMessage, loadConversation } from "./context.js";
|
|
9
|
-
const agentName = "test-agent";
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// appendMessage + loadConversation round-trip
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
describe("appendMessage / loadConversation", () => {
|
|
14
|
-
let workdir = "";
|
|
15
|
-
before(async () => {
|
|
16
|
-
workdir = await mkdtemp(join(tmpdir(), "ctx-test-"));
|
|
17
|
-
});
|
|
18
|
-
after(async () => {
|
|
19
|
-
await rm(workdir, { recursive: true, force: true });
|
|
20
|
-
});
|
|
21
|
-
it("writes a chat message and reads it back with kind='chat'", async () => {
|
|
22
|
-
const convId = "test-chat";
|
|
23
|
-
await appendMessage(workdir, agentName, convId, "User", "hello", "chat");
|
|
24
|
-
const conv = await loadConversation(workdir, agentName, convId);
|
|
25
|
-
assert.equal(conv.rounds.length, 1);
|
|
26
|
-
assert.equal(conv.rounds[0].role, "user");
|
|
27
|
-
assert.equal(conv.rounds[0].content, "hello");
|
|
28
|
-
assert.equal(conv.rounds[0].kind, "chat");
|
|
29
|
-
});
|
|
30
|
-
it("writes an order message with [order] tag and reads kind='order'", async () => {
|
|
31
|
-
const convId = "test-order";
|
|
32
|
-
await appendMessage(workdir, agentName, convId, "User", "order request", "order");
|
|
33
|
-
await appendMessage(workdir, agentName, convId, "Agent", "order delivered", "order");
|
|
34
|
-
const conv = await loadConversation(workdir, agentName, convId);
|
|
35
|
-
assert.equal(conv.rounds.length, 2);
|
|
36
|
-
assert.equal(conv.rounds[0].kind, "order");
|
|
37
|
-
assert.equal(conv.rounds[0].role, "user");
|
|
38
|
-
assert.equal(conv.rounds[1].kind, "order");
|
|
39
|
-
assert.equal(conv.rounds[1].role, "agent");
|
|
40
|
-
});
|
|
41
|
-
it("defaults to kind='chat' when kind arg is omitted", async () => {
|
|
42
|
-
const convId = "test-default-kind";
|
|
43
|
-
await appendMessage(workdir, agentName, convId, "Agent", "hi there");
|
|
44
|
-
const conv = await loadConversation(workdir, agentName, convId);
|
|
45
|
-
assert.equal(conv.rounds[0].kind, "chat");
|
|
46
|
-
});
|
|
47
|
-
it("parses mixed chat + order rounds correctly", async () => {
|
|
48
|
-
const convId = "test-mixed";
|
|
49
|
-
await appendMessage(workdir, agentName, convId, "User", "chat message", "chat");
|
|
50
|
-
await appendMessage(workdir, agentName, convId, "User", "order task", "order");
|
|
51
|
-
await appendMessage(workdir, agentName, convId, "Agent", "chat reply", "chat");
|
|
52
|
-
await appendMessage(workdir, agentName, convId, "Agent", "order result", "order");
|
|
53
|
-
const conv = await loadConversation(workdir, agentName, convId);
|
|
54
|
-
assert.equal(conv.rounds.length, 4);
|
|
55
|
-
assert.equal(conv.rounds[0].kind, "chat");
|
|
56
|
-
assert.equal(conv.rounds[1].kind, "order");
|
|
57
|
-
assert.equal(conv.rounds[2].kind, "chat");
|
|
58
|
-
assert.equal(conv.rounds[3].kind, "order");
|
|
59
|
-
});
|
|
60
|
-
it("parses legacy file (no [kind] tag) as kind='chat' for backward-compat", async () => {
|
|
61
|
-
const convId = "test-legacy";
|
|
62
|
-
// Write a legacy-style file manually (no [order] tag)
|
|
63
|
-
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
64
|
-
const dir = join(workdir, ".akemon", "agents", agentName, "conversations");
|
|
65
|
-
await mkdir(dir, { recursive: true });
|
|
66
|
-
const legacyContent = "## Summary\n\n\n## Recent\n[2026-04-23 10:00] User: old message\n[2026-04-23 10:01] Agent: old reply\n";
|
|
67
|
-
await writeFile(join(dir, `${convId}.md`), legacyContent);
|
|
68
|
-
const conv = await loadConversation(workdir, agentName, convId);
|
|
69
|
-
assert.equal(conv.rounds.length, 2);
|
|
70
|
-
assert.equal(conv.rounds[0].kind, "chat");
|
|
71
|
-
assert.equal(conv.rounds[1].kind, "chat");
|
|
72
|
-
});
|
|
73
|
-
it("[order] tag appears in the raw file content", async () => {
|
|
74
|
-
const convId = "test-tag-on-disk";
|
|
75
|
-
await appendMessage(workdir, agentName, convId, "User", "buy something", "order");
|
|
76
|
-
const { readFile: rf } = await import("node:fs/promises");
|
|
77
|
-
const dir = join(workdir, ".akemon", "agents", agentName, "conversations");
|
|
78
|
-
const raw = await rf(join(dir, `${convId}.md`), "utf-8");
|
|
79
|
-
assert.ok(raw.includes("[order] User: buy something"), `raw file should contain [order] tag: ${raw}`);
|
|
80
|
-
});
|
|
81
|
-
it("chat message does NOT have [order] tag in raw file", async () => {
|
|
82
|
-
const convId = "test-no-tag-on-disk";
|
|
83
|
-
await appendMessage(workdir, agentName, convId, "User", "just chatting", "chat");
|
|
84
|
-
const { readFile: rf } = await import("node:fs/promises");
|
|
85
|
-
const dir = join(workdir, ".akemon", "agents", agentName, "conversations");
|
|
86
|
-
const raw = await rf(join(dir, `${convId}.md`), "utf-8");
|
|
87
|
-
assert.ok(!raw.includes("[order]"), `chat line should NOT contain [order] tag: ${raw}`);
|
|
88
|
-
assert.ok(raw.includes("User: just chatting"));
|
|
89
|
-
});
|
|
90
|
-
});
|