akemon 0.3.5 → 0.3.7
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/DATA_POLICY.md +128 -0
- package/README.md +156 -19
- package/TRADEMARK.md +74 -0
- package/dist/akemon-home.js +56 -0
- package/dist/akemon-message.js +107 -0
- package/dist/best-effort.js +8 -0
- package/dist/cli.js +1411 -132
- package/dist/cognitive-artifact-store.js +101 -0
- package/dist/cognitive-event-log.js +47 -0
- package/dist/config.js +45 -9
- package/dist/context.js +27 -6
- package/dist/core/contracts/layers.js +1 -0
- package/dist/core/contracts/permission.js +1 -0
- package/dist/core/contracts/workspace.js +1 -0
- package/dist/core-cognitive-module.js +768 -0
- package/dist/engine-peripheral.js +127 -26
- package/dist/engine-routing.js +58 -17
- package/dist/interactive-session.js +361 -0
- package/dist/local-interconnect.js +156 -0
- package/dist/local-registry.js +178 -0
- package/dist/mcp-server.js +4 -1
- package/dist/memory-proposal.js +379 -0
- package/dist/memory-recorder.js +368 -0
- package/dist/orphan-scan.js +36 -24
- package/dist/passive-reflection-cognitive-module.js +172 -0
- package/dist/peripheral-registry.js +235 -0
- package/dist/permission-audit.js +132 -0
- package/dist/relay-client.js +68 -9
- package/dist/relay-mode.js +34 -0
- package/dist/relay-peripheral.js +139 -49
- package/dist/runtime-platform.js +122 -0
- package/dist/secretariat/client.js +87 -0
- package/dist/self.js +15 -6
- package/dist/server.js +3695 -439
- package/dist/social-discovery.js +231 -0
- package/dist/software-agent-peripheral.js +314 -235
- package/dist/software-agent-result-cli.js +69 -0
- package/dist/software-agent-stream-cli.js +23 -0
- package/dist/software-agent-transport.js +177 -0
- package/dist/task-module.js +243 -0
- package/dist/task-registry.js +756 -0
- package/dist/vendor/xterm/addon-fit.js +2 -0
- package/dist/vendor/xterm/addon-search.js +2 -0
- package/dist/vendor/xterm/addon-web-links.js +2 -0
- package/dist/vendor/xterm/xterm.css +285 -0
- package/dist/vendor/xterm/xterm.js +2 -0
- package/dist/work-memory.js +339 -0
- package/dist/workbench-peripheral-guide.js +79 -0
- package/dist/workbench-session.js +1074 -0
- package/dist/workbench.html +4011 -0
- package/package.json +11 -4
- package/scripts/build.cjs +24 -0
- package/scripts/check-architecture-baseline.cjs +68 -0
- package/scripts/test.cjs +38 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { WorkbenchSessionManager, normalizeWorkbenchInputMode, normalizeWorkbenchResizeRequest, normalizeWorkbenchStartRequest, } from "./workbench-session.js";
|
|
2
|
+
import { sig } from "./types.js";
|
|
3
|
+
export const INTERACTIVE_SESSION_REQUEST = "interactive-session:request";
|
|
4
|
+
export const INTERACTIVE_SESSION_RESPONSE = "interactive-session:response";
|
|
5
|
+
const DEFAULT_SEND_INPUT_WAIT_MS = 600;
|
|
6
|
+
const MAX_SEND_INPUT_WAIT_MS = 10_000;
|
|
7
|
+
const OUTPUT_POLL_INTERVAL_MS = 100;
|
|
8
|
+
export class InteractiveSessionManager extends WorkbenchSessionManager {
|
|
9
|
+
}
|
|
10
|
+
export class InteractiveSessionPeripheral {
|
|
11
|
+
id = "interactive-session:local";
|
|
12
|
+
name = "Local Interactive Sessions";
|
|
13
|
+
capabilities = [
|
|
14
|
+
"interactive-session",
|
|
15
|
+
"list_sessions",
|
|
16
|
+
"inspect_session",
|
|
17
|
+
"start_session",
|
|
18
|
+
"send_input",
|
|
19
|
+
"set_input_mode",
|
|
20
|
+
"stop_session",
|
|
21
|
+
"resize_session",
|
|
22
|
+
"capture_output",
|
|
23
|
+
];
|
|
24
|
+
tags = ["interactive-session", "terminal", "local"];
|
|
25
|
+
manager;
|
|
26
|
+
bus = null;
|
|
27
|
+
constructor(manager) {
|
|
28
|
+
this.manager = manager;
|
|
29
|
+
}
|
|
30
|
+
async start(bus) {
|
|
31
|
+
this.bus = bus;
|
|
32
|
+
}
|
|
33
|
+
async stop() {
|
|
34
|
+
this.bus = null;
|
|
35
|
+
}
|
|
36
|
+
async send(signal) {
|
|
37
|
+
if (signal.type !== INTERACTIVE_SESSION_REQUEST)
|
|
38
|
+
return null;
|
|
39
|
+
const action = normalizeInteractiveSessionAction(signal.data);
|
|
40
|
+
const observation = await this.execute(action);
|
|
41
|
+
return sig(INTERACTIVE_SESSION_RESPONSE, observation, this.id);
|
|
42
|
+
}
|
|
43
|
+
async explore() {
|
|
44
|
+
const sessions = this.manager.listSessions();
|
|
45
|
+
if (!sessions.length)
|
|
46
|
+
return "No interactive sessions are currently tracked.";
|
|
47
|
+
return sessions.map((session) => [
|
|
48
|
+
`${session.sessionId}: ${session.tool} ${session.status}`,
|
|
49
|
+
`command: ${session.commandLineDisplay}`,
|
|
50
|
+
`workdir: ${session.workdir}`,
|
|
51
|
+
`updated: ${session.updatedAt}`,
|
|
52
|
+
].join("\n")).join("\n\n");
|
|
53
|
+
}
|
|
54
|
+
async execute(action) {
|
|
55
|
+
return executeInteractiveSessionAction(this.manager, action);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function executeInteractiveSessionAction(manager, action) {
|
|
59
|
+
try {
|
|
60
|
+
switch (action.capability) {
|
|
61
|
+
case "list_sessions": {
|
|
62
|
+
const sessions = manager.listSessions();
|
|
63
|
+
return interactiveSessionObservation(action, true, narrateInteractiveSessions(sessions), {
|
|
64
|
+
sessions: sessions.map(interactiveSessionObservationData),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
case "inspect_session": {
|
|
68
|
+
const sessionId = resolveInteractiveSessionId(manager, stringArg(action.args, "sessionId"));
|
|
69
|
+
return inspectInteractiveSessionObservation(manager.getSession(sessionId), sessionId, action.reason);
|
|
70
|
+
}
|
|
71
|
+
case "start_session": {
|
|
72
|
+
const session = await manager.startSession(normalizeWorkbenchStartRequest(action.args || {}));
|
|
73
|
+
return interactiveSessionObservation(action, true, narrateInteractiveSessionAction("started", session), {
|
|
74
|
+
session: interactiveSessionObservationData(session),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
case "send_input": {
|
|
78
|
+
const sessionId = resolveInteractiveSessionId(manager, stringArg(action.args, "sessionId"), { runningOnly: true });
|
|
79
|
+
const before = manager.getSession(sessionId)?.tail || "";
|
|
80
|
+
const ownerInput = stringArg(action.args, "input");
|
|
81
|
+
const session = manager.writeInput(sessionId, {
|
|
82
|
+
input: withSubmitInput(ownerInput),
|
|
83
|
+
});
|
|
84
|
+
const waitMs = normalizeSendInputWaitMs(action.args?.waitMs);
|
|
85
|
+
const latestSession = await waitForInteractiveSessionOutput(manager, sessionId, before, session, waitMs);
|
|
86
|
+
const output = tailDelta(before, latestSession.tail);
|
|
87
|
+
return interactiveSessionObservation(action, true, narrateInteractiveSessionInputSent(latestSession, ownerInput, output, waitMs), {
|
|
88
|
+
session: interactiveSessionObservationData(latestSession),
|
|
89
|
+
input: ownerInput,
|
|
90
|
+
waitMs,
|
|
91
|
+
newOutput: outputPreviewText(output),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
case "set_input_mode": {
|
|
95
|
+
const sessionId = resolveInteractiveSessionId(manager, stringArg(action.args, "sessionId"));
|
|
96
|
+
const session = manager.setInputMode(sessionId, normalizeWorkbenchInputMode(stringArg(action.args, "inputMode")));
|
|
97
|
+
return interactiveSessionObservation(action, true, narrateInteractiveSessionInputModeChanged(session), {
|
|
98
|
+
session: interactiveSessionObservationData(session),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
case "stop_session": {
|
|
102
|
+
const sessionId = resolveInteractiveSessionId(manager, stringArg(action.args, "sessionId"), { runningOnly: true });
|
|
103
|
+
const session = manager.stopSession(sessionId);
|
|
104
|
+
return interactiveSessionObservation(action, true, narrateInteractiveSessionAction("stopped", session), {
|
|
105
|
+
session: interactiveSessionObservationData(session),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
case "resize_session": {
|
|
109
|
+
const sessionId = resolveInteractiveSessionId(manager, stringArg(action.args, "sessionId"), { runningOnly: true });
|
|
110
|
+
const session = manager.resizeSession(sessionId, normalizeWorkbenchResizeRequest(action.args || {}));
|
|
111
|
+
return interactiveSessionObservation(action, true, narrateInteractiveSessionResize(session), {
|
|
112
|
+
session: interactiveSessionObservationData(session),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
case "capture_output": {
|
|
116
|
+
const sessionId = resolveInteractiveSessionId(manager, stringArg(action.args, "sessionId"));
|
|
117
|
+
const session = manager.getSession(sessionId);
|
|
118
|
+
return interactiveSessionObservation(action, !!session, narrateInteractiveSessionInspection(session, sessionId), {
|
|
119
|
+
...(session ? { session: interactiveSessionObservationData(session), output: session.tail } : {}),
|
|
120
|
+
}, session ? undefined : `Interactive session ${sessionId} was not found.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return interactiveSessionObservation(action, false, `I could not complete this interactive session action: ${error?.message || String(error)}`, undefined, error?.message || String(error));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function normalizeInteractiveSessionAction(data) {
|
|
129
|
+
const capability = typeof data.capability === "string" ? data.capability : "";
|
|
130
|
+
if (!isInteractiveSessionCapability(capability)) {
|
|
131
|
+
throw new Error("Invalid interactive session capability");
|
|
132
|
+
}
|
|
133
|
+
const args = data.args && typeof data.args === "object" && !Array.isArray(data.args)
|
|
134
|
+
? data.args
|
|
135
|
+
: {};
|
|
136
|
+
const reason = typeof data.reason === "string" && data.reason.trim() ? data.reason.trim() : undefined;
|
|
137
|
+
return { capability, args, reason };
|
|
138
|
+
}
|
|
139
|
+
function isInteractiveSessionCapability(value) {
|
|
140
|
+
return value === "list_sessions"
|
|
141
|
+
|| value === "inspect_session"
|
|
142
|
+
|| value === "start_session"
|
|
143
|
+
|| value === "send_input"
|
|
144
|
+
|| value === "set_input_mode"
|
|
145
|
+
|| value === "stop_session"
|
|
146
|
+
|| value === "resize_session"
|
|
147
|
+
|| value === "capture_output";
|
|
148
|
+
}
|
|
149
|
+
function interactiveSessionObservation(action, ok, summary, data, error) {
|
|
150
|
+
return {
|
|
151
|
+
kind: "peripheral_observation",
|
|
152
|
+
peripheral: "interactive-session",
|
|
153
|
+
capability: action.capability,
|
|
154
|
+
ok,
|
|
155
|
+
reason: action.reason,
|
|
156
|
+
summary,
|
|
157
|
+
...(data ? { data } : {}),
|
|
158
|
+
...(error ? { error } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function stringArg(args, key) {
|
|
162
|
+
const value = args?.[key];
|
|
163
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
164
|
+
throw new Error(`Missing required string field: ${key}`);
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
function withSubmitInput(input) {
|
|
169
|
+
return input.endsWith("\r") || input.endsWith("\n") ? input : `${input}\r`;
|
|
170
|
+
}
|
|
171
|
+
function normalizeSendInputWaitMs(value) {
|
|
172
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
173
|
+
return DEFAULT_SEND_INPUT_WAIT_MS;
|
|
174
|
+
return Math.max(0, Math.min(Math.round(value), MAX_SEND_INPUT_WAIT_MS));
|
|
175
|
+
}
|
|
176
|
+
function resolveInteractiveSessionId(manager, value, options) {
|
|
177
|
+
const normalized = value.trim().toLowerCase();
|
|
178
|
+
const byStartedAt = normalized === "latest" || normalized === "last";
|
|
179
|
+
const byActive = normalized === "active" || normalized === "current";
|
|
180
|
+
const byUpdatedAt = normalized === "recent" || byActive;
|
|
181
|
+
if (!byStartedAt && !byUpdatedAt)
|
|
182
|
+
return value;
|
|
183
|
+
if (byActive) {
|
|
184
|
+
const active = manager.getActiveSession();
|
|
185
|
+
if (active) {
|
|
186
|
+
if (options?.runningOnly === true && active.status !== "running") {
|
|
187
|
+
throw new Error(`Active Workbench session ${active.sessionId} is not running`);
|
|
188
|
+
}
|
|
189
|
+
return active.sessionId;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const sessions = manager.listSessions()
|
|
193
|
+
.filter((session) => options?.runningOnly !== true || session.status === "running")
|
|
194
|
+
.sort((left, right) => {
|
|
195
|
+
if (byStartedAt) {
|
|
196
|
+
const byStart = right.startedAt.localeCompare(left.startedAt);
|
|
197
|
+
return byStart || right.updatedAt.localeCompare(left.updatedAt);
|
|
198
|
+
}
|
|
199
|
+
const byUpdate = right.updatedAt.localeCompare(left.updatedAt);
|
|
200
|
+
return byUpdate || right.startedAt.localeCompare(left.startedAt);
|
|
201
|
+
});
|
|
202
|
+
const session = sessions[0];
|
|
203
|
+
if (!session) {
|
|
204
|
+
throw new Error(options?.runningOnly === true
|
|
205
|
+
? `No running Workbench session is available for ${value}`
|
|
206
|
+
: `No Workbench session is available for ${value}`);
|
|
207
|
+
}
|
|
208
|
+
return session.sessionId;
|
|
209
|
+
}
|
|
210
|
+
async function waitForInteractiveSessionOutput(manager, sessionId, beforeTail, initialSession, waitMs) {
|
|
211
|
+
if (waitMs <= 0)
|
|
212
|
+
return manager.getSession(sessionId) || initialSession;
|
|
213
|
+
const deadline = Date.now() + waitMs;
|
|
214
|
+
let latestSession = manager.getSession(sessionId) || initialSession;
|
|
215
|
+
while (Date.now() < deadline) {
|
|
216
|
+
latestSession = manager.getSession(sessionId) || latestSession;
|
|
217
|
+
if (tailDelta(beforeTail, latestSession.tail) || latestSession.status !== "running") {
|
|
218
|
+
return latestSession;
|
|
219
|
+
}
|
|
220
|
+
await delay(Math.min(OUTPUT_POLL_INTERVAL_MS, Math.max(1, deadline - Date.now())));
|
|
221
|
+
}
|
|
222
|
+
return manager.getSession(sessionId) || latestSession;
|
|
223
|
+
}
|
|
224
|
+
function delay(ms) {
|
|
225
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
226
|
+
}
|
|
227
|
+
function tailDelta(before, after) {
|
|
228
|
+
if (!after || after === before)
|
|
229
|
+
return "";
|
|
230
|
+
if (after.startsWith(before))
|
|
231
|
+
return after.slice(before.length).trim();
|
|
232
|
+
return after.trim();
|
|
233
|
+
}
|
|
234
|
+
function narrateInteractiveSessions(sessions) {
|
|
235
|
+
if (!sessions.length) {
|
|
236
|
+
return "There are no Workbench sessions right now. You can ask me to start Codex, Claude, a shell, or a custom command.";
|
|
237
|
+
}
|
|
238
|
+
const running = sessions.filter((session) => session.status === "running").length;
|
|
239
|
+
const lines = [
|
|
240
|
+
`I see ${sessions.length} Workbench session(s). ${running} are still running.`,
|
|
241
|
+
"",
|
|
242
|
+
];
|
|
243
|
+
for (const session of sessions) {
|
|
244
|
+
lines.push(`- ${session.sessionId}: ${session.tool}, status ${session.status}, input ${session.inputMode}, command ${session.commandLineDisplay}.`);
|
|
245
|
+
}
|
|
246
|
+
return lines.join("\n");
|
|
247
|
+
}
|
|
248
|
+
function inspectInteractiveSessionObservation(session, sessionId, reason) {
|
|
249
|
+
return {
|
|
250
|
+
kind: "peripheral_observation",
|
|
251
|
+
peripheral: "interactive-session",
|
|
252
|
+
capability: "inspect_session",
|
|
253
|
+
ok: session !== null,
|
|
254
|
+
reason,
|
|
255
|
+
summary: narrateInteractiveSessionInspection(session, sessionId),
|
|
256
|
+
...(session ? { data: { session: interactiveSessionObservationData(session) } } : {}),
|
|
257
|
+
...(!session ? { error: `Interactive session ${sessionId} was not found.` } : {}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function interactiveSessionObservationData(session) {
|
|
261
|
+
return {
|
|
262
|
+
sessionId: session.sessionId,
|
|
263
|
+
tool: session.tool,
|
|
264
|
+
status: session.status,
|
|
265
|
+
command: session.commandLineDisplay,
|
|
266
|
+
inputMode: session.inputMode,
|
|
267
|
+
workdir: session.workdir,
|
|
268
|
+
cols: session.cols,
|
|
269
|
+
rows: session.rows,
|
|
270
|
+
logPath: session.logPath,
|
|
271
|
+
outputChars: session.outputChars,
|
|
272
|
+
tail: outputPreviewText(session.tail),
|
|
273
|
+
startedAt: session.startedAt,
|
|
274
|
+
updatedAt: session.updatedAt,
|
|
275
|
+
endedAt: session.endedAt,
|
|
276
|
+
exitCode: session.exitCode,
|
|
277
|
+
error: session.error,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function narrateInteractiveSessionInspection(session, sessionId) {
|
|
281
|
+
if (!session) {
|
|
282
|
+
return `I could not find Workbench session ${sessionId}.`;
|
|
283
|
+
}
|
|
284
|
+
const lines = [
|
|
285
|
+
`Session ${session.sessionId} is ${session.status}.`,
|
|
286
|
+
`Tool: ${session.tool}.`,
|
|
287
|
+
`Command: ${session.commandLineDisplay}.`,
|
|
288
|
+
`Input mode: ${session.inputMode}.`,
|
|
289
|
+
`Workdir: ${session.workdir}.`,
|
|
290
|
+
`Log: ${session.logPath}.`,
|
|
291
|
+
];
|
|
292
|
+
const outputPreview = outputPreviewText(session.tail);
|
|
293
|
+
if (outputPreview) {
|
|
294
|
+
lines.push("", "Recent output:", outputPreview);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
lines.push("", "I do not see output in this session yet.");
|
|
298
|
+
}
|
|
299
|
+
return lines.join("\n");
|
|
300
|
+
}
|
|
301
|
+
function narrateInteractiveSessionAction(action, session) {
|
|
302
|
+
if (action === "started") {
|
|
303
|
+
return [
|
|
304
|
+
`I started a ${session.tool} session: ${session.sessionId}.`,
|
|
305
|
+
`Command: ${session.commandLineDisplay}.`,
|
|
306
|
+
`Input mode: ${session.inputMode}.`,
|
|
307
|
+
`Workdir: ${session.workdir}.`,
|
|
308
|
+
"You can ask me to send text to it, or take over directly in the terminal.",
|
|
309
|
+
].join("\n");
|
|
310
|
+
}
|
|
311
|
+
return [
|
|
312
|
+
`I stopped session ${session.sessionId}.`,
|
|
313
|
+
`It was running ${session.tool}; final status is ${session.status}.`,
|
|
314
|
+
`Session log: ${session.logPath}.`,
|
|
315
|
+
].join("\n");
|
|
316
|
+
}
|
|
317
|
+
function narrateInteractiveSessionInputModeChanged(session) {
|
|
318
|
+
return [
|
|
319
|
+
`I set session ${session.sessionId} input mode to ${session.inputMode}.`,
|
|
320
|
+
session.inputMode === "tui"
|
|
321
|
+
? "This uses bracketed paste plus a delayed Enter for interactive TUIs such as Codex or Cursor running inside a shell."
|
|
322
|
+
: "This writes input as normal line-oriented terminal input.",
|
|
323
|
+
].join("\n");
|
|
324
|
+
}
|
|
325
|
+
function narrateInteractiveSessionInputSent(session, input, output = "", waitMs = DEFAULT_SEND_INPUT_WAIT_MS) {
|
|
326
|
+
const trimmed = input.replace(/\s+$/g, "");
|
|
327
|
+
const preview = trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
|
|
328
|
+
const lines = [
|
|
329
|
+
`I sent that input to session ${session.sessionId}.`,
|
|
330
|
+
preview ? `Content: ${preview}` : "Content: an empty Enter key.",
|
|
331
|
+
`Input mode was ${session.inputMode}.`,
|
|
332
|
+
`Session status is ${session.status}.`,
|
|
333
|
+
];
|
|
334
|
+
const outputPreview = outputPreviewText(output);
|
|
335
|
+
if (outputPreview) {
|
|
336
|
+
lines.push("", "Recent output:", outputPreview);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
const waited = waitMs > DEFAULT_SEND_INPUT_WAIT_MS
|
|
340
|
+
? ` I waited about ${formatWaitSeconds(waitMs)}.`
|
|
341
|
+
: "";
|
|
342
|
+
lines.push("", `I do not see new output from the session yet.${waited}`);
|
|
343
|
+
}
|
|
344
|
+
return lines.join("\n");
|
|
345
|
+
}
|
|
346
|
+
function formatWaitSeconds(waitMs) {
|
|
347
|
+
const seconds = Math.round(waitMs / 100) / 10;
|
|
348
|
+
return `${seconds}s`;
|
|
349
|
+
}
|
|
350
|
+
function outputPreviewText(output) {
|
|
351
|
+
const trimmed = output.trim();
|
|
352
|
+
if (!trimmed)
|
|
353
|
+
return "";
|
|
354
|
+
return trimmed.length > 2000 ? `${trimmed.slice(-2000)}` : trimmed;
|
|
355
|
+
}
|
|
356
|
+
function narrateInteractiveSessionResize(session) {
|
|
357
|
+
return [
|
|
358
|
+
`I synced session ${session.sessionId} to ${session.cols} columns and ${session.rows} rows.`,
|
|
359
|
+
`Session status is ${session.status}.`,
|
|
360
|
+
].join("\n");
|
|
361
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import { mkdir, appendFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { agentContactsDir, cleanAgentName } from "./akemon-home.js";
|
|
5
|
+
import { actorRef, createAkemonMessage, isAkemonMessageEnvelope, textFromAkemonMessage, } from "./akemon-message.js";
|
|
6
|
+
export function createLocalAkemonMessage(input) {
|
|
7
|
+
const sourceAgent = cleanAgentName(input.sourceAgent);
|
|
8
|
+
const targetAgent = cleanAgentName(input.targetAgent);
|
|
9
|
+
return createAkemonMessage({
|
|
10
|
+
type: "akemon.message",
|
|
11
|
+
source: actorRef("agent", sourceAgent, "local"),
|
|
12
|
+
target: actorRef("agent", targetAgent, "local"),
|
|
13
|
+
conversationId: input.conversationId,
|
|
14
|
+
memoryScope: input.memoryScope || "task",
|
|
15
|
+
permissions: {
|
|
16
|
+
...(input.requiresOwnerApproval ? { requiresOwnerApproval: true } : {}),
|
|
17
|
+
},
|
|
18
|
+
transport: "local",
|
|
19
|
+
payload: {
|
|
20
|
+
text: input.text,
|
|
21
|
+
format: "text",
|
|
22
|
+
kind: "chat",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function requireLocalAkemonMessageEnvelope(value) {
|
|
27
|
+
const candidate = (value && typeof value === "object" && "message" in value)
|
|
28
|
+
? value.message
|
|
29
|
+
: value;
|
|
30
|
+
if (!isAkemonMessageEnvelope(candidate)) {
|
|
31
|
+
throw new Error("Invalid Akemon message envelope");
|
|
32
|
+
}
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
export function buildLocalAkemonMessageTask(message) {
|
|
36
|
+
const text = textFromAkemonMessage(message);
|
|
37
|
+
return [
|
|
38
|
+
"[Akemon local message]",
|
|
39
|
+
`Message id: ${message.id}`,
|
|
40
|
+
`From: ${message.source.kind}:${message.source.id}`,
|
|
41
|
+
`To: ${message.target.kind}:${message.target.id}`,
|
|
42
|
+
`Memory scope: ${message.memoryScope}`,
|
|
43
|
+
`Owner approval requested: ${message.permissions.requiresOwnerApproval === true ? "yes" : "no"}`,
|
|
44
|
+
"",
|
|
45
|
+
text,
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
48
|
+
export function createLocalAkemonResponseMessage(input) {
|
|
49
|
+
return createAkemonMessage({
|
|
50
|
+
type: "akemon.message",
|
|
51
|
+
source: actorRef("agent", cleanAgentName(input.responderAgent), "local"),
|
|
52
|
+
target: input.request.source,
|
|
53
|
+
conversationId: input.request.conversationId,
|
|
54
|
+
memoryScope: "public",
|
|
55
|
+
transport: "local",
|
|
56
|
+
metadata: { inReplyTo: input.request.id },
|
|
57
|
+
payload: {
|
|
58
|
+
text: input.result,
|
|
59
|
+
format: "text",
|
|
60
|
+
kind: "chat",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
export async function appendLocalPeerContact(record) {
|
|
65
|
+
const ownerAgent = cleanAgentName(record.ownerAgent);
|
|
66
|
+
const peerAgent = cleanAgentName(record.peerAgent);
|
|
67
|
+
const dir = agentContactsDir(ownerAgent);
|
|
68
|
+
await mkdir(dir, { recursive: true });
|
|
69
|
+
const normalized = {
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
ts: record.ts,
|
|
72
|
+
ownerAgent,
|
|
73
|
+
peerAgent,
|
|
74
|
+
direction: record.direction,
|
|
75
|
+
messageId: record.messageId,
|
|
76
|
+
...(record.conversationId ? { conversationId: record.conversationId } : {}),
|
|
77
|
+
};
|
|
78
|
+
await appendFile(join(dir, "local-peers.jsonl"), `${JSON.stringify(normalized)}\n`, "utf-8");
|
|
79
|
+
}
|
|
80
|
+
export async function submitLocalAkemonMessageToMcp(input) {
|
|
81
|
+
const initBody = JSON.stringify({
|
|
82
|
+
jsonrpc: "2.0",
|
|
83
|
+
id: 1,
|
|
84
|
+
method: "initialize",
|
|
85
|
+
params: {
|
|
86
|
+
protocolVersion: "2025-03-26",
|
|
87
|
+
capabilities: {},
|
|
88
|
+
clientInfo: { name: "akemon-local-message", version: "1.0" },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const doRequest = (body, sessionId) => {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const headers = {
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
"Accept": "application/json, text/event-stream",
|
|
96
|
+
"Content-Length": String(Buffer.byteLength(body)),
|
|
97
|
+
"x-publisher-id": input.message.source.id,
|
|
98
|
+
};
|
|
99
|
+
if (sessionId)
|
|
100
|
+
headers["mcp-session-id"] = sessionId;
|
|
101
|
+
const req = http.request({
|
|
102
|
+
hostname: "127.0.0.1",
|
|
103
|
+
port: input.localPort,
|
|
104
|
+
path: "/mcp",
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers,
|
|
107
|
+
}, (res) => {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
110
|
+
res.on("end", () => {
|
|
111
|
+
const sid = res.headers["mcp-session-id"];
|
|
112
|
+
resolve({ data: Buffer.concat(chunks).toString(), sessionId: sid || sessionId });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
req.on("error", reject);
|
|
116
|
+
req.write(body);
|
|
117
|
+
req.end();
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
const initialized = await doRequest(initBody);
|
|
121
|
+
const task = buildLocalAkemonMessageTask(input.message);
|
|
122
|
+
const callBody = JSON.stringify({
|
|
123
|
+
jsonrpc: "2.0",
|
|
124
|
+
id: 2,
|
|
125
|
+
method: "tools/call",
|
|
126
|
+
params: {
|
|
127
|
+
name: "submit_task",
|
|
128
|
+
arguments: {
|
|
129
|
+
task,
|
|
130
|
+
require_human: input.message.permissions.requiresOwnerApproval === true,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const { data } = await doRequest(callBody, initialized.sessionId);
|
|
135
|
+
return extractMcpTextResponse(data);
|
|
136
|
+
}
|
|
137
|
+
export function extractMcpTextResponse(data) {
|
|
138
|
+
let result = data;
|
|
139
|
+
try {
|
|
140
|
+
const lines = data.split("\n");
|
|
141
|
+
let lastData = "";
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
if (line.startsWith("data: "))
|
|
144
|
+
lastData = line.slice(6);
|
|
145
|
+
}
|
|
146
|
+
const parsed = JSON.parse(lastData || data);
|
|
147
|
+
const content = parsed?.result?.content;
|
|
148
|
+
if (Array.isArray(content)) {
|
|
149
|
+
result = content.map((item) => item?.text || "").join("\n");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// use raw response
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { akemonHome, cleanAgentName } from "./akemon-home.js";
|
|
4
|
+
import { isProcessAlive } from "./runtime-platform.js";
|
|
5
|
+
export class LocalInstanceLookupError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
candidates;
|
|
8
|
+
constructor(code, message, candidates = []) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.candidates = candidates;
|
|
12
|
+
this.name = "LocalInstanceLookupError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function localInstanceRegistryPath() {
|
|
16
|
+
return join(akemonHome(), "runtime", "instances.json");
|
|
17
|
+
}
|
|
18
|
+
export async function registerLocalInstance(input) {
|
|
19
|
+
const now = new Date().toISOString();
|
|
20
|
+
const pid = input.pid ?? process.pid;
|
|
21
|
+
const name = cleanAgentName(input.name);
|
|
22
|
+
const host = input.host || "127.0.0.1";
|
|
23
|
+
const record = {
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
name,
|
|
26
|
+
pid,
|
|
27
|
+
port: normalizePort(input.port),
|
|
28
|
+
host,
|
|
29
|
+
endpoint: `http://${host}:${normalizePort(input.port)}`,
|
|
30
|
+
workdir: input.workdir,
|
|
31
|
+
mode: input.mode,
|
|
32
|
+
health: "running",
|
|
33
|
+
startedAt: input.startedAt || now,
|
|
34
|
+
updatedAt: now,
|
|
35
|
+
...(input.relayHttp ? { relayHttp: input.relayHttp } : {}),
|
|
36
|
+
};
|
|
37
|
+
const instances = await loadRegistryRecords();
|
|
38
|
+
const next = instances
|
|
39
|
+
.filter((existing) => isProcessAlive(existing.pid))
|
|
40
|
+
.filter((existing) => !(existing.pid === pid && existing.name === name));
|
|
41
|
+
next.push(record);
|
|
42
|
+
await saveRegistryRecords(next);
|
|
43
|
+
return record;
|
|
44
|
+
}
|
|
45
|
+
export async function touchLocalInstance(name, pid = process.pid) {
|
|
46
|
+
const cleanedName = cleanAgentName(name);
|
|
47
|
+
const instances = await loadRegistryRecords();
|
|
48
|
+
let changed = false;
|
|
49
|
+
const now = new Date().toISOString();
|
|
50
|
+
const next = instances.filter((record) => {
|
|
51
|
+
const alive = isProcessAlive(record.pid);
|
|
52
|
+
if (!alive)
|
|
53
|
+
changed = true;
|
|
54
|
+
return alive;
|
|
55
|
+
}).map((record) => {
|
|
56
|
+
if (record.name === cleanedName && record.pid === pid) {
|
|
57
|
+
changed = true;
|
|
58
|
+
return { ...record, updatedAt: now };
|
|
59
|
+
}
|
|
60
|
+
return record;
|
|
61
|
+
});
|
|
62
|
+
if (changed)
|
|
63
|
+
await saveRegistryRecords(next);
|
|
64
|
+
}
|
|
65
|
+
export async function unregisterLocalInstance(name, pid = process.pid) {
|
|
66
|
+
const cleanedName = cleanAgentName(name);
|
|
67
|
+
const instances = await loadRegistryRecords();
|
|
68
|
+
const next = instances.filter((record) => !(record.name === cleanedName && record.pid === pid));
|
|
69
|
+
if (next.length !== instances.length)
|
|
70
|
+
await saveRegistryRecords(next);
|
|
71
|
+
}
|
|
72
|
+
export async function listRunningLocalInstances() {
|
|
73
|
+
const instances = await loadRegistryRecords();
|
|
74
|
+
const running = instances.filter((record) => isProcessAlive(record.pid));
|
|
75
|
+
if (running.length !== instances.length)
|
|
76
|
+
await saveRegistryRecords(running);
|
|
77
|
+
return running.sort((a, b) => a.name.localeCompare(b.name) || a.port - b.port || a.pid - b.pid);
|
|
78
|
+
}
|
|
79
|
+
export async function resolveLocalInstanceByName(name) {
|
|
80
|
+
const cleanedName = cleanAgentName(name);
|
|
81
|
+
const matches = (await listRunningLocalInstances()).filter((record) => record.name === cleanedName);
|
|
82
|
+
if (matches.length === 1)
|
|
83
|
+
return matches[0];
|
|
84
|
+
if (matches.length === 0) {
|
|
85
|
+
throw new LocalInstanceLookupError("not_found", `No running local Akemon named "${cleanedName}". Start it with: akemon run --name ${cleanedName}`);
|
|
86
|
+
}
|
|
87
|
+
throw new LocalInstanceLookupError("ambiguous", `Multiple running local Akemon instances are registered as "${cleanedName}". Use --port to select one explicitly.`, matches);
|
|
88
|
+
}
|
|
89
|
+
export async function resolveDefaultLocalInstance() {
|
|
90
|
+
const instances = await listRunningLocalInstances();
|
|
91
|
+
if (instances.length === 1)
|
|
92
|
+
return instances[0];
|
|
93
|
+
if (instances.length === 0) {
|
|
94
|
+
throw new LocalInstanceLookupError("not_found", "No running local Akemon instances are registered. Start one with: akemon run");
|
|
95
|
+
}
|
|
96
|
+
throw new LocalInstanceLookupError("ambiguous", "Multiple running local Akemon instances are registered. Use --name or --port to select one explicitly.", instances);
|
|
97
|
+
}
|
|
98
|
+
async function loadRegistryRecords() {
|
|
99
|
+
let raw;
|
|
100
|
+
try {
|
|
101
|
+
raw = await readFile(localInstanceRegistryPath(), "utf-8");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(raw);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const instances = Array.isArray(parsed)
|
|
114
|
+
? parsed
|
|
115
|
+
: Array.isArray(parsed?.instances)
|
|
116
|
+
? (parsed.instances ?? [])
|
|
117
|
+
: [];
|
|
118
|
+
return instances
|
|
119
|
+
.map((item) => {
|
|
120
|
+
try {
|
|
121
|
+
return normalizeRecord(item);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
.filter((record) => record !== null);
|
|
128
|
+
}
|
|
129
|
+
async function saveRegistryRecords(instances) {
|
|
130
|
+
const path = localInstanceRegistryPath();
|
|
131
|
+
await mkdir(dirname(path), { recursive: true });
|
|
132
|
+
const data = { schemaVersion: 1, instances };
|
|
133
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
134
|
+
await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
135
|
+
await rename(tmp, path);
|
|
136
|
+
}
|
|
137
|
+
function normalizeRecord(value) {
|
|
138
|
+
if (!value || typeof value !== "object")
|
|
139
|
+
return null;
|
|
140
|
+
const record = value;
|
|
141
|
+
if (typeof record.name !== "string")
|
|
142
|
+
return null;
|
|
143
|
+
const pid = record.pid;
|
|
144
|
+
const rawPort = record.port;
|
|
145
|
+
if (typeof pid !== "number" || typeof rawPort !== "number")
|
|
146
|
+
return null;
|
|
147
|
+
if (!Number.isInteger(pid) || !Number.isInteger(rawPort))
|
|
148
|
+
return null;
|
|
149
|
+
if (typeof record.workdir !== "string" || !record.workdir)
|
|
150
|
+
return null;
|
|
151
|
+
const mode = record.mode === "relay" ? "relay" : "local-only";
|
|
152
|
+
const host = typeof record.host === "string" && record.host ? record.host : "127.0.0.1";
|
|
153
|
+
const port = normalizePort(rawPort);
|
|
154
|
+
const endpoint = typeof record.endpoint === "string" && record.endpoint
|
|
155
|
+
? record.endpoint
|
|
156
|
+
: `http://${host}:${port}`;
|
|
157
|
+
const now = new Date().toISOString();
|
|
158
|
+
return {
|
|
159
|
+
schemaVersion: 1,
|
|
160
|
+
name: cleanAgentName(record.name),
|
|
161
|
+
pid,
|
|
162
|
+
port,
|
|
163
|
+
host,
|
|
164
|
+
endpoint,
|
|
165
|
+
workdir: record.workdir,
|
|
166
|
+
mode,
|
|
167
|
+
health: "running",
|
|
168
|
+
startedAt: typeof record.startedAt === "string" && record.startedAt ? record.startedAt : now,
|
|
169
|
+
updatedAt: typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : now,
|
|
170
|
+
...(typeof record.relayHttp === "string" && record.relayHttp ? { relayHttp: record.relayHttp } : {}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function normalizePort(port) {
|
|
174
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
175
|
+
throw new Error("Invalid local instance port");
|
|
176
|
+
}
|
|
177
|
+
return port;
|
|
178
|
+
}
|