akemon 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/context.js CHANGED
@@ -1,57 +1,180 @@
1
1
  /**
2
- * Context helpers — session context and product context for conversations.
3
- * Extracted from server.ts (Phase 1 code organization).
2
+ * Context helpers — local conversation storage and product context.
3
+ *
4
+ * Conversations are stored as per-user markdown files in
5
+ * .akemon/agents/{name}/conversations/{id}.md
6
+ *
7
+ * File format:
8
+ * ## Summary
9
+ * (compressed older rounds, initially empty)
10
+ *
11
+ * ## Recent
12
+ * [2026-04-15 10:30] User: message
13
+ * [2026-04-15 10:30] Agent: reply
4
14
  */
5
- import { readFile, writeFile, mkdir, appendFile } from "fs/promises";
15
+ import { readFile, writeFile, mkdir, appendFile, readdir, stat, unlink } from "fs/promises";
6
16
  import { join } from "path";
7
17
  import { localNow } from "./self.js";
8
- // ---------------------------------------------------------------------------
9
- // Session Context API
10
- // ---------------------------------------------------------------------------
11
- const MAX_CONTEXT_BYTES = 8192;
12
- export async function fetchContext(relayHttp, agentName, secretKey, publisherId) {
18
+ function conversationsDir(workdir, agentName) {
19
+ return join(workdir, ".akemon", "agents", agentName, "conversations");
20
+ }
21
+ function conversationPath(workdir, agentName, id) {
22
+ return join(conversationsDir(workdir, agentName), `${id}.md`);
23
+ }
24
+ /** Determine conversation ID from publisherId / sessionId. */
25
+ export function resolveConvId(publisherId, sessionId) {
26
+ if (publisherId)
27
+ return `pub_${publisherId}`;
28
+ if (sessionId)
29
+ return `ses_${sessionId}`;
30
+ return "ses_anonymous";
31
+ }
32
+ /** Parse a conversation markdown file into structured data. */
33
+ function parseConversation(content) {
34
+ let summary = "";
35
+ const rounds = [];
36
+ const summaryMatch = content.match(/## Summary\n([\s\S]*?)(?=\n## Recent|$)/);
37
+ if (summaryMatch) {
38
+ summary = summaryMatch[1].trim();
39
+ }
40
+ const recentMatch = content.match(/## Recent\n([\s\S]*)$/);
41
+ if (recentMatch) {
42
+ const lines = recentMatch[1].split("\n");
43
+ for (const line of lines) {
44
+ const m = line.match(/^\[(.+?)\] (User|Agent): (.*)$/);
45
+ if (m) {
46
+ rounds.push({
47
+ ts: m[1],
48
+ role: m[2].toLowerCase(),
49
+ content: m[3],
50
+ });
51
+ }
52
+ }
53
+ }
54
+ return { summary, rounds };
55
+ }
56
+ /** Load and parse a conversation. Returns empty conversation if file doesn't exist. */
57
+ export async function loadConversation(workdir, agentName, convId) {
13
58
  try {
14
- const url = `${relayHttp}/v1/agent/${agentName}/sessions/${publisherId}/context`;
15
- const res = await fetch(url, {
16
- headers: { Authorization: `Bearer ${secretKey}` },
17
- });
18
- if (!res.ok)
19
- return "";
20
- return await res.text();
59
+ const content = await readFile(conversationPath(workdir, agentName, convId), "utf-8");
60
+ return parseConversation(content);
21
61
  }
22
- catch (err) {
23
- console.log(`[context] GET failed: ${err}`);
24
- return "";
62
+ catch {
63
+ return { summary: "", rounds: [] };
25
64
  }
26
65
  }
27
- export async function storeContext(relayHttp, agentName, secretKey, publisherId, context) {
66
+ /** Append a user+agent round to a conversation file. Creates file if needed. */
67
+ export async function appendRound(workdir, agentName, convId, userMsg, agentMsg) {
68
+ const dir = conversationsDir(workdir, agentName);
69
+ await mkdir(dir, { recursive: true });
70
+ const p = conversationPath(workdir, agentName, convId);
71
+ let content = "";
28
72
  try {
29
- const url = `${relayHttp}/v1/agent/${agentName}/sessions/${publisherId}/context`;
30
- await fetch(url, {
31
- method: "PUT",
32
- headers: { Authorization: `Bearer ${secretKey}`, "Content-Type": "text/plain" },
33
- body: context,
34
- });
73
+ content = await readFile(p, "utf-8");
35
74
  }
36
- catch (err) {
37
- console.log(`[context] PUT failed: ${err}`);
75
+ catch { }
76
+ if (!content) {
77
+ content = "## Summary\n\n\n## Recent\n";
38
78
  }
79
+ const ts = localNow();
80
+ const entry = `[${ts}] User: ${userMsg}\n[${ts}] Agent: ${agentMsg}\n`;
81
+ content = content.trimEnd() + "\n" + entry;
82
+ await writeFile(p, content);
39
83
  }
40
- export function buildContextPayload(prevContext, task, response) {
41
- // Append the new round
42
- let newRound = `\n\n[Round]\nUser: ${task}\nAssistant: ${response}`;
43
- let context = prevContext + newRound;
44
- // Trim oldest rounds if over limit
45
- while (Buffer.byteLength(context, "utf-8") > MAX_CONTEXT_BYTES) {
46
- const firstRound = context.indexOf("\n\n[Round]\n", 1);
47
- if (firstRound === -1) {
48
- // Single round too large truncate response
49
- context = context.slice(context.length - MAX_CONTEXT_BYTES);
84
+ /**
85
+ * Build LLM context string from a conversation, respecting a character budget.
86
+ * Takes recent rounds from the end, prepends summary if space remains.
87
+ */
88
+ export function buildLLMContext(conv, budget) {
89
+ if (!conv.rounds.length && !conv.summary) {
90
+ return { text: "", recentStartIndex: 0 };
91
+ }
92
+ // Build recent rounds text from end, fitting within budget
93
+ const recentLines = [];
94
+ let recentSize = 0;
95
+ let recentStartIndex = conv.rounds.length; // all rounds are "old" by default
96
+ for (let i = conv.rounds.length - 1; i >= 0; i--) {
97
+ const r = conv.rounds[i];
98
+ const line = `[${r.ts}] ${r.role === "user" ? "User" : "Agent"}: ${r.content}`;
99
+ if (recentSize + line.length + 1 > budget)
50
100
  break;
101
+ recentLines.unshift(line);
102
+ recentSize += line.length + 1;
103
+ recentStartIndex = i;
104
+ }
105
+ const recentText = recentLines.join("\n");
106
+ // Fill remaining budget with summary
107
+ const remaining = budget - recentSize;
108
+ let summaryText = "";
109
+ if (conv.summary && remaining > 50) {
110
+ summaryText = conv.summary.length <= remaining
111
+ ? conv.summary
112
+ : conv.summary.slice(0, remaining - 3) + "...";
113
+ }
114
+ const parts = [];
115
+ if (summaryText)
116
+ parts.push(`[Conversation summary]\n${summaryText}`);
117
+ if (recentText)
118
+ parts.push(`[Recent conversation]\n${recentText}`);
119
+ return { text: parts.join("\n\n"), recentStartIndex };
120
+ }
121
+ /** List all conversations for an agent. */
122
+ export async function listConversations(workdir, agentName) {
123
+ const dir = conversationsDir(workdir, agentName);
124
+ try {
125
+ const files = await readdir(dir);
126
+ const results = [];
127
+ for (const f of files) {
128
+ if (!f.endsWith(".md"))
129
+ continue;
130
+ const id = f.replace(/\.md$/, "");
131
+ try {
132
+ const st = await stat(join(dir, f));
133
+ const content = await readFile(join(dir, f), "utf-8");
134
+ const conv = parseConversation(content);
135
+ results.push({
136
+ id,
137
+ lastActive: st.mtime.toISOString(),
138
+ roundCount: conv.rounds.length,
139
+ });
140
+ }
141
+ catch {
142
+ continue;
143
+ }
144
+ }
145
+ results.sort((a, b) => b.lastActive.localeCompare(a.lastActive));
146
+ return results;
147
+ }
148
+ catch {
149
+ return [];
150
+ }
151
+ }
152
+ /** Delete session-only conversations older than maxAgeDays. */
153
+ export async function cleanStaleSessions(workdir, agentName, maxAgeDays = 7) {
154
+ const dir = conversationsDir(workdir, agentName);
155
+ const cutoff = Date.now() - maxAgeDays * 86400_000;
156
+ let cleaned = 0;
157
+ try {
158
+ const files = await readdir(dir);
159
+ for (const f of files) {
160
+ if (!f.startsWith("ses_") || !f.endsWith(".md"))
161
+ continue;
162
+ try {
163
+ const st = await stat(join(dir, f));
164
+ if (st.mtimeMs < cutoff) {
165
+ await unlink(join(dir, f));
166
+ cleaned++;
167
+ }
168
+ }
169
+ catch {
170
+ continue;
171
+ }
51
172
  }
52
- context = context.slice(firstRound);
53
173
  }
54
- return context;
174
+ catch { }
175
+ if (cleaned)
176
+ console.log(`[context] Cleaned ${cleaned} stale session conversations`);
177
+ return cleaned;
55
178
  }
56
179
  // ---------------------------------------------------------------------------
57
180
  // Product Context
@@ -9,8 +9,8 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
9
9
  import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
10
10
  import { z } from "zod";
11
11
  import { callAgent } from "./relay-client.js";
12
- import { fetchContext, storeContext, buildContextPayload, loadProductContext, appendProductLog } from "./context.js";
13
- import { biosPath, loadBioState, saveBioState, localNow, bioStatePromptModifier, feedHunger, appendBioEvent, SHOP_ITEMS, } from "./self.js";
12
+ import { loadConversation, appendRound, buildLLMContext, resolveConvId, loadProductContext, appendProductLog } from "./context.js";
13
+ import { biosPath, loadBioState, saveBioState, localNow, bioStatePromptModifier, feedHunger, appendBioEvent, SHOP_ITEMS, loadAgentConfig, } from "./self.js";
14
14
  // ---------------------------------------------------------------------------
15
15
  // createMcpServer
16
16
  // ---------------------------------------------------------------------------
@@ -21,7 +21,7 @@ export function createMcpServer(opts, deps) {
21
21
  version: "0.1.0",
22
22
  });
23
23
  const isHuman = engine === "human";
24
- const contextEnabled = !!(relayHttp && secretKey);
24
+ const contextEnabled = !!workdir;
25
25
  server.tool("submit_task", "Submit a task to this agent. Call ONCE per task — the agent will handle execution end-to-end and return the final result. Do NOT call again to verify or confirm; the response IS the final answer.", {
26
26
  task: z.string().describe("The task description for the agent to complete"),
27
27
  require_human: z.union([z.boolean(), z.string()]).optional().describe("Request the agent owner to review and respond personally."),
@@ -36,19 +36,21 @@ export function createMcpServer(opts, deps) {
36
36
  content: [{ type: "text", text: "[busy] Agent is currently processing another task. Please try again later." }],
37
37
  };
38
38
  }
39
- // Resolve publisher ID from session
39
+ // Resolve conversation ID from publisher/session
40
40
  const publisherId = publisherIds.get(extra.sessionId || "") || "";
41
- // Fetch context if available
42
- let prevContext = "";
43
- if (contextEnabled && publisherId) {
44
- prevContext = await fetchContext(relayHttp, agentName, secretKey, publisherId);
45
- if (prevContext) {
46
- console.log(`[context] Loaded ${prevContext.length} bytes for publisher=${publisherId.slice(0, 8)}`);
41
+ const convId = resolveConvId(publisherId, extra.sessionId || "");
42
+ // Load local conversation context
43
+ let contextPrefix = "";
44
+ if (contextEnabled) {
45
+ const conv = await loadConversation(workdir, agentName, convId);
46
+ const config = await loadAgentConfig(workdir, agentName);
47
+ const budget = config.context_budget ?? 4096;
48
+ const { text } = buildLLMContext(conv, budget);
49
+ if (text) {
50
+ contextPrefix = `${text}\n\n---\n\n`;
51
+ console.log(`[context] Loaded ${text.length} chars for conv=${convId.slice(0, 16)}`);
47
52
  }
48
53
  }
49
- const contextPrefix = prevContext
50
- ? `[Previous conversation context]\n${prevContext}\n\n---\n\n`
51
- : "";
52
54
  // Product purchase detection — load product-specific context
53
55
  let productContext = "";
54
56
  let productName = "";
@@ -72,9 +74,8 @@ You are ${agentName}, an AI agent on the Akemon network.${bioMod}Read ${bios} to
72
74
  ${productPrefix}${contextPrefix}Current task: ${task}`;
73
75
  if (mock) {
74
76
  const output = `[${agentName}] Mock response for: "${task}"\n\n模拟回复:这是 ${agentName} agent 的模拟响应。`;
75
- if (contextEnabled && publisherId) {
76
- const newContext = buildContextPayload(prevContext, task, output);
77
- storeContext(relayHttp, agentName, secretKey, publisherId, newContext);
77
+ if (contextEnabled) {
78
+ await appendRound(workdir, agentName, convId, task, output);
78
79
  }
79
80
  return {
80
81
  content: [{ type: "text", text: output }],
@@ -92,9 +93,8 @@ ${productPrefix}${contextPrefix}Current task: ${task}`;
92
93
  if (answer.trim().length > 0) {
93
94
  console.log(`[${isHuman ? "human" : "approve"}] Owner replied.`);
94
95
  // Store context for human replies too
95
- if (contextEnabled && publisherId) {
96
- const newContext = buildContextPayload(prevContext, task, answer);
97
- storeContext(relayHttp, agentName, secretKey, publisherId, newContext);
96
+ if (contextEnabled) {
97
+ await appendRound(workdir, agentName, convId, task, answer);
98
98
  }
99
99
  return {
100
100
  content: [{ type: "text", text: answer }],
@@ -121,9 +121,8 @@ ${productPrefix}${contextPrefix}Current task: ${task}`;
121
121
  output = await deps.runEngine(engine, model, allowAll, safeTask, workdir);
122
122
  }
123
123
  // Store updated context
124
- if (contextEnabled && publisherId) {
125
- const newContext = buildContextPayload(prevContext, task, output);
126
- storeContext(relayHttp, agentName, secretKey, publisherId, newContext);
124
+ if (contextEnabled) {
125
+ await appendRound(workdir, agentName, convId, task, output);
127
126
  }
128
127
  // Log product purchase interaction
129
128
  if (productName) {
@@ -298,7 +298,7 @@ function handleMCPRequest(ws, msg, localPort) {
298
298
  const req = http.request({
299
299
  hostname: "127.0.0.1",
300
300
  port: localPort,
301
- path: "/mcp",
301
+ path: msg.path || "/mcp",
302
302
  method: msg.method || "POST",
303
303
  headers: {
304
304
  ...headers,
package/dist/self.js CHANGED
@@ -129,6 +129,7 @@ const DEFAULT_CONFIG = {
129
129
  token_limit_daily: 0,
130
130
  auto_offline_enabled: true,
131
131
  hunger_decay_interval: 300_000, // 5 minutes per hunger point (was 30s — way too fast)
132
+ context_budget: 4096,
132
133
  };
133
134
  export async function initAgentConfig(workdir, agentName) {
134
135
  const p = agentConfigPath(workdir, agentName);
package/dist/server.js CHANGED
@@ -82,6 +82,7 @@ import { LongTermModule } from "./longterm-module.js";
82
82
  import { ReflectionModule } from "./reflection-module.js";
83
83
  import { ScriptModule } from "./script-module.js";
84
84
  import { SIG, sig } from "./types.js";
85
+ import { loadConversation, listConversations, buildLLMContext } from "./context.js";
85
86
  import { createMcpServer, initMcpProxy, createMcpProxyServer } from "./mcp-server.js";
86
87
  import { autoRoute, runCollaborativeQuery } from "./agent-utils.js";
87
88
  // createMcpServer, initMcpProxy, createMcpProxyServer → see mcp-server.ts
@@ -228,6 +229,28 @@ export async function serve(options) {
228
229
  res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(entries, null, 2));
229
230
  return;
230
231
  }
232
+ if (req.url === "/self/conversations" && req.method === "GET") {
233
+ const list = await listConversations(workdir, options.agentName);
234
+ res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(list, null, 2));
235
+ return;
236
+ }
237
+ if (req.url?.startsWith("/self/conversation/") && req.method === "GET") {
238
+ const convId = decodeURIComponent(req.url.slice("/self/conversation/".length));
239
+ if (!convId) {
240
+ res.writeHead(400).end("Missing conversation ID");
241
+ return;
242
+ }
243
+ const conv = await loadConversation(workdir, options.agentName, convId);
244
+ const config = await loadAgentConfig(workdir, options.agentName);
245
+ const budget = config.context_budget ?? 4096;
246
+ const { recentStartIndex } = buildLLMContext(conv, budget);
247
+ res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
248
+ summary: conv.summary,
249
+ rounds: conv.rounds,
250
+ recentStartIndex,
251
+ }, null, 2));
252
+ return;
253
+ }
231
254
  // Track publisher ID per session
232
255
  const publisherId = req.headers["x-publisher-id"];
233
256
  // Extract session ID from header
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akemon",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Agent work marketplace — train your agent, let it work for others",
5
5
  "type": "module",
6
6
  "license": "MIT",