@agentprojectcontext/apx 1.0.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/package.json +52 -0
  4. package/skills/apx/SKILL.md +77 -0
  5. package/src/cli/commands/a2a.js +66 -0
  6. package/src/cli/commands/agent.js +181 -0
  7. package/src/cli/commands/chat.js +84 -0
  8. package/src/cli/commands/command.js +42 -0
  9. package/src/cli/commands/config.js +56 -0
  10. package/src/cli/commands/daemon.js +148 -0
  11. package/src/cli/commands/exec.js +56 -0
  12. package/src/cli/commands/identity.js +146 -0
  13. package/src/cli/commands/init.js +23 -0
  14. package/src/cli/commands/mcp.js +147 -0
  15. package/src/cli/commands/memory.js +69 -0
  16. package/src/cli/commands/messages.js +61 -0
  17. package/src/cli/commands/plugins.js +23 -0
  18. package/src/cli/commands/project.js +124 -0
  19. package/src/cli/commands/routine.js +99 -0
  20. package/src/cli/commands/runtime.js +64 -0
  21. package/src/cli/commands/session.js +387 -0
  22. package/src/cli/commands/skills.js +153 -0
  23. package/src/cli/commands/telegram.js +48 -0
  24. package/src/cli/http.js +102 -0
  25. package/src/cli/index.js +481 -0
  26. package/src/cli/postinstall.js +25 -0
  27. package/src/core/apc-context-skill.md +150 -0
  28. package/src/core/apx-skill.md +78 -0
  29. package/src/core/config.js +129 -0
  30. package/src/core/identity.js +23 -0
  31. package/src/core/messages-store.js +421 -0
  32. package/src/core/parser.js +217 -0
  33. package/src/core/routines-store.js +144 -0
  34. package/src/core/scaffold.js +417 -0
  35. package/src/core/session-store.js +36 -0
  36. package/src/daemon/apc-runtime-context.js +123 -0
  37. package/src/daemon/api.js +946 -0
  38. package/src/daemon/compact.js +140 -0
  39. package/src/daemon/conversations.js +108 -0
  40. package/src/daemon/db.js +81 -0
  41. package/src/daemon/engines/anthropic.js +58 -0
  42. package/src/daemon/engines/gemini.js +55 -0
  43. package/src/daemon/engines/index.js +65 -0
  44. package/src/daemon/engines/mock.js +18 -0
  45. package/src/daemon/engines/ollama.js +66 -0
  46. package/src/daemon/engines/openai.js +58 -0
  47. package/src/daemon/env-detect.js +69 -0
  48. package/src/daemon/index.js +156 -0
  49. package/src/daemon/mcp-runner.js +218 -0
  50. package/src/daemon/mcp-sources.js +114 -0
  51. package/src/daemon/plugins/index.js +91 -0
  52. package/src/daemon/plugins/telegram.js +549 -0
  53. package/src/daemon/project-config.js +98 -0
  54. package/src/daemon/routines.js +211 -0
  55. package/src/daemon/runtimes/_spawn.js +44 -0
  56. package/src/daemon/runtimes/aider.js +32 -0
  57. package/src/daemon/runtimes/claude-code.js +60 -0
  58. package/src/daemon/runtimes/codex.js +30 -0
  59. package/src/daemon/runtimes/index.js +39 -0
  60. package/src/daemon/runtimes/opencode.js +28 -0
  61. package/src/daemon/smoke.js +54 -0
  62. package/src/daemon/super-agent-tools.js +539 -0
  63. package/src/daemon/super-agent.js +188 -0
  64. package/src/daemon/thinking.js +45 -0
  65. package/src/daemon/tool-call-parser.js +116 -0
  66. package/src/daemon/wakeup.js +92 -0
  67. package/src/mcp/index.js +220 -0
@@ -0,0 +1,946 @@
1
+ // Express REST API for APX. See docs/APX-DAEMON.md §4.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import express from "express";
5
+ import { readApfMcps, writeApfMcps, SOURCES } from "./mcp-sources.js";
6
+ import { callEngine, ENGINE_IDS } from "./engines/index.js";
7
+ import { getRuntime, RUNTIME_IDS } from "./runtimes/index.js";
8
+ import { detectAll } from "./env-detect.js";
9
+ import {
10
+ startConversation,
11
+ appendTurn,
12
+ readConversation,
13
+ listConversations,
14
+ conversationPath,
15
+ setStatus,
16
+ } from "./conversations.js";
17
+ import { compactConversation } from "./compact.js";
18
+ import {
19
+ readProjectConfig,
20
+ writeProjectConfig,
21
+ setDottedKey,
22
+ unsetDottedKey,
23
+ } from "./project-config.js";
24
+ import {
25
+ listRoutines,
26
+ getRoutine,
27
+ upsertRoutine,
28
+ deleteRoutine,
29
+ setEnabled as setRoutineEnabled,
30
+ runRoutineNow,
31
+ } from "./routines.js";
32
+ import {
33
+ buildApfHint,
34
+ createRuntimeSession,
35
+ closeRuntimeSession,
36
+ extractApfResult,
37
+ } from "./apc-runtime-context.js";
38
+ import { readSessionFrontmatter } from "../core/session-store.js";
39
+ import { runSuperAgent, isSuperAgentEnabled } from "./super-agent.js";
40
+ import { readGlobalMessages, readProjectMessages, searchProjectMessages } from "../core/messages-store.js";
41
+ import { readAgents } from "../core/parser.js";
42
+ import { parseSessionFrontmatter } from "../core/parser.js";
43
+ import { writeAgentFile, ensureAgentDir, regenerateAgentsMd } from "../core/scaffold.js";
44
+
45
+ const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
46
+
47
+ export function buildApi({ projects, registries, plugins, scheduler, version, startedAt, addProjectGlobally, config }) {
48
+ const telegram = plugins?.get("telegram");
49
+
50
+ const app = express();
51
+ app.use(express.json({ limit: "2mb" }));
52
+
53
+ // ---- Health -------------------------------------------------------
54
+ app.get("/health", (_req, res) => {
55
+ res.json({
56
+ status: "ok",
57
+ version,
58
+ uptime_s: Math.round((Date.now() - startedAt) / 1000),
59
+ });
60
+ });
61
+
62
+ // ---- Projects -----------------------------------------------------
63
+ app.get("/projects", (_req, res) => res.json(projects.list()));
64
+
65
+ app.post("/projects", (req, res) => {
66
+ const { path: p } = req.body || {};
67
+ if (!p) return res.status(400).json({ error: "path required" });
68
+ try {
69
+ const entry = projects.register(p);
70
+ addProjectGlobally(entry.path);
71
+ registries.ensure(entry);
72
+ res.status(201).json({ id: entry.id, path: entry.path });
73
+ } catch (e) {
74
+ res.status(400).json({ error: e.message });
75
+ }
76
+ });
77
+
78
+ app.delete("/projects/:id", (req, res) => {
79
+ const ok = projects.unregister(req.params.id);
80
+ res.status(ok ? 204 : 404).end();
81
+ });
82
+
83
+ app.post("/projects/:id/rebuild", (req, res) => {
84
+ try {
85
+ const result = projects.rebuild(req.params.id);
86
+ res.json({ ok: true, ...result });
87
+ } catch (e) {
88
+ res.status(400).json({ error: e.message });
89
+ }
90
+ });
91
+
92
+ // ---- Helper -------------------------------------------------------
93
+ function project(req, res) {
94
+ const p = projects.get(req.params.pid);
95
+ if (!p) {
96
+ res.status(404).json({ error: "project not found" });
97
+ return null;
98
+ }
99
+ return p;
100
+ }
101
+
102
+ // ---- Agents -------------------------------------------------------
103
+ app.get("/projects/:pid/agents", (req, res) => {
104
+ const p = project(req, res);
105
+ if (!p) return;
106
+ res.json(readAgents(p.path).map(agentToResponse));
107
+ });
108
+
109
+ app.get("/projects/:pid/agents/:slug", (req, res) => {
110
+ const p = project(req, res);
111
+ if (!p) return;
112
+ const agents = readAgents(p.path);
113
+ const a = agents.find((x) => x.slug === req.params.slug);
114
+ if (!a) return res.status(404).json({ error: "agent not found" });
115
+ const memPath = path.join(p.path, ".apc", "agents", a.slug, "memory.md");
116
+ const memory = fs.existsSync(memPath) ? fs.readFileSync(memPath, "utf8") : "";
117
+ res.json({ ...agentToResponse(a), memory });
118
+ });
119
+
120
+ app.post("/projects/:pid/agents", (req, res) => {
121
+ const p = project(req, res);
122
+ if (!p) return;
123
+ const { slug, role, model, skills, language, description, tools } = req.body || {};
124
+ if (!slug) return res.status(400).json({ error: "slug required" });
125
+ if (!/^[a-z][a-z0-9_-]*$/.test(slug))
126
+ return res.status(400).json({ error: "invalid slug" });
127
+ const existing = readAgents(p.path).find((a) => a.slug === slug);
128
+ if (existing) return res.status(400).json({ error: `agent ${slug} already exists` });
129
+ try {
130
+ writeAgentFile(p.path, slug, {
131
+ Role: role || null,
132
+ Model: model || null,
133
+ Language: language || null,
134
+ Description: description || null,
135
+ Skills: skills || [],
136
+ Tools: tools || [],
137
+ });
138
+ ensureAgentDir(p.path, slug);
139
+ regenerateAgentsMd(p.path);
140
+ projects.rebuild(p.id);
141
+ const created = readAgents(p.path).find((a) => a.slug === slug);
142
+ res.status(201).json(agentToResponse(created));
143
+ } catch (e) {
144
+ res.status(400).json({ error: e.message });
145
+ }
146
+ });
147
+
148
+ // ---- Memory -------------------------------------------------------
149
+ app.get("/projects/:pid/agents/:slug/memory", (req, res) => {
150
+ const p = project(req, res);
151
+ if (!p) return;
152
+ const memPath = path.join(p.path, ".apc", "agents", req.params.slug, "memory.md");
153
+ if (!fs.existsSync(memPath)) return res.json({ body: "" });
154
+ res.json({ body: fs.readFileSync(memPath, "utf8") });
155
+ });
156
+
157
+ app.put("/projects/:pid/agents/:slug/memory", (req, res) => {
158
+ const p = project(req, res);
159
+ if (!p) return;
160
+ const { body } = req.body || {};
161
+ if (typeof body !== "string")
162
+ return res.status(400).json({ error: "body must be string" });
163
+ const dir = path.join(p.path, ".apc", "agents", req.params.slug);
164
+ fs.mkdirSync(path.join(dir, "sessions"), { recursive: true });
165
+ const memPath = path.join(dir, "memory.md");
166
+ fs.writeFileSync(memPath, body);
167
+ projects.rebuild(p.id);
168
+ res.json({ ok: true, bytes: Buffer.byteLength(body, "utf8") });
169
+ });
170
+
171
+ // ---- Sessions -----------------------------------------------------
172
+ app.get("/projects/:pid/agents/:slug/sessions", (req, res) => {
173
+ const p = project(req, res);
174
+ if (!p) return;
175
+ const agents = readAgents(p.path);
176
+ if (!agents.find((a) => a.slug === req.params.slug))
177
+ return res.status(404).json({ error: "agent not found" });
178
+ const sessionsDir = path.join(p.path, ".apc", "agents", req.params.slug, "sessions");
179
+ if (!fs.existsSync(sessionsDir)) return res.json([]);
180
+ const sessions = fs
181
+ .readdirSync(sessionsDir)
182
+ .filter((f) => f.endsWith(".md"))
183
+ .sort()
184
+ .reverse()
185
+ .map((f) => {
186
+ const text = fs.readFileSync(path.join(sessionsDir, f), "utf8");
187
+ const fm = parseSessionFrontmatter(text);
188
+ const titleFromFile = f.replace(/^\d{4}-\d{2}-\d{2}-/, "").replace(/\.md$/, "");
189
+ return {
190
+ filename: f,
191
+ title: fm.title || titleFromFile,
192
+ started_at: fm.started || null,
193
+ ended_at: fm.ended || null,
194
+ };
195
+ });
196
+ res.json(sessions);
197
+ });
198
+
199
+ app.post("/projects/:pid/agents/:slug/sessions", (req, res) => {
200
+ const p = project(req, res);
201
+ if (!p) return;
202
+ const { title, body = "" } = req.body || {};
203
+ if (!title) return res.status(400).json({ error: "title required" });
204
+ const sessionsDir = path.join(p.path, ".apc", "agents", req.params.slug, "sessions");
205
+ fs.mkdirSync(sessionsDir, { recursive: true });
206
+ const titleSlug =
207
+ title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "session";
208
+ const today = new Date().toISOString().slice(0, 10);
209
+ let candidate = path.join(sessionsDir, `${today}-${titleSlug}.md`);
210
+ let n = 2;
211
+ while (fs.existsSync(candidate)) {
212
+ candidate = path.join(sessionsDir, `${today}-${titleSlug}-${n}.md`);
213
+ n++;
214
+ }
215
+ const started = nowIso();
216
+ const content = `---\ntitle: ${title}\nstarted: ${started}\n---\n\n# ${title}\n\n${body}\n`;
217
+ fs.writeFileSync(candidate, content);
218
+ projects.rebuild(p.id);
219
+ res.status(201).json({ filename: path.basename(candidate), path: candidate });
220
+ });
221
+
222
+ // GET session by filename (sid may include or omit the .md extension)
223
+ app.get("/projects/:pid/sessions/:sid", (req, res) => {
224
+ const p = project(req, res);
225
+ if (!p) return;
226
+ const sid = req.params.sid;
227
+ const filename = sid.endsWith(".md") ? sid : `${sid}.md`;
228
+ const agentsDir = path.join(p.path, ".apc", "agents");
229
+ let found = null;
230
+ if (fs.existsSync(agentsDir)) {
231
+ for (const slug of fs.readdirSync(agentsDir)) {
232
+ const f = path.join(agentsDir, slug, "sessions", filename);
233
+ if (fs.existsSync(f)) {
234
+ const text = fs.readFileSync(f, "utf8");
235
+ const fm = parseSessionFrontmatter(text);
236
+ found = { filename, agent: slug, ...fm, body_md: text };
237
+ break;
238
+ }
239
+ }
240
+ }
241
+ if (!found) return res.status(404).json({ error: "session not found" });
242
+ res.json(found);
243
+ });
244
+
245
+ // ---- MCPs ---------------------------------------------------------
246
+ app.get("/projects/:pid/mcps", (req, res) => {
247
+ const p = project(req, res);
248
+ if (!p) return;
249
+ res.json(registries.for(p).list());
250
+ });
251
+
252
+ app.post("/projects/:pid/mcps", (req, res) => {
253
+ const p = project(req, res);
254
+ if (!p) return;
255
+ const { name, command, args, env, url, headers, enabled } = req.body || {};
256
+ if (!name) return res.status(400).json({ error: "name required" });
257
+ if (!command && !url)
258
+ return res.status(400).json({ error: "either command or url required" });
259
+
260
+ const json = readApfMcps(p.path);
261
+ json.mcpServers = json.mcpServers || {};
262
+ const existing = json.mcpServers[name] || {};
263
+ json.mcpServers[name] = {
264
+ ...existing,
265
+ ...(command !== undefined ? { command } : {}),
266
+ ...(args !== undefined ? { args } : {}),
267
+ ...(env !== undefined ? { env } : {}),
268
+ ...(url !== undefined ? { url } : {}),
269
+ ...(headers !== undefined ? { headers } : {}),
270
+ ...(enabled !== undefined ? { enabled } : {}),
271
+ };
272
+ writeApfMcps(p.path, json);
273
+ registries.for(p).evict(name);
274
+ projects.rebuild(p.id);
275
+ const entry = registries.for(p).getByName(name);
276
+ res.status(201).json(entry);
277
+ });
278
+
279
+ app.delete("/projects/:pid/mcps/:name", (req, res) => {
280
+ const p = project(req, res);
281
+ if (!p) return;
282
+ const json = readApfMcps(p.path);
283
+ if (!json.mcpServers || !(req.params.name in (json.mcpServers || {}))) {
284
+ const all = registries.for(p).list();
285
+ const m = all.find((x) => x.name === req.params.name);
286
+ if (m && m.source !== "apc") {
287
+ return res.status(409).json({
288
+ error: `MCP "${req.params.name}" comes from "${m.source}" config — not APC-owned, cannot be removed by apx. Edit ${SOURCES.find((s) => s.id === m.source)?.file} directly.`,
289
+ });
290
+ }
291
+ return res.status(404).end();
292
+ }
293
+ delete json.mcpServers[req.params.name];
294
+ writeApfMcps(p.path, json);
295
+ registries.for(p).evict(req.params.name);
296
+ projects.rebuild(p.id);
297
+ res.status(204).end();
298
+ });
299
+
300
+ app.get("/projects/:pid/mcps/check", (req, res) => {
301
+ const p = project(req, res);
302
+ if (!p) return;
303
+ const reg = registries.for(p);
304
+ res.json({
305
+ sources: SOURCES.map((s) => ({
306
+ id: s.id,
307
+ file: s.file,
308
+ present: fs.existsSync(path.join(p.path, s.file)),
309
+ })),
310
+ entries: reg.list().map((m) => ({
311
+ name: m.name,
312
+ source: m.source,
313
+ transport: m.transport,
314
+ enabled: m.enabled,
315
+ })),
316
+ conflicts: reg.conflicts(),
317
+ });
318
+ });
319
+
320
+ app.post("/projects/:pid/mcps/:name/call", async (req, res) => {
321
+ const p = project(req, res);
322
+ if (!p) return;
323
+ const { tool, params } = req.body || {};
324
+ if (!tool) return res.status(400).json({ error: "tool required" });
325
+ try {
326
+ const result = await registries.for(p).call(req.params.name, tool, params);
327
+ res.json({ result });
328
+ } catch (e) {
329
+ res.status(500).json({ error: e.message });
330
+ }
331
+ });
332
+
333
+ // ---- Messages -----------------------------------------------------
334
+ app.get("/projects/:pid/messages", (req, res) => {
335
+ const p = project(req, res);
336
+ if (!p) return;
337
+ const { agent, channel, since, limit = "100" } = req.query;
338
+ const rows = readProjectMessages(p.path, {
339
+ channel: channel || undefined,
340
+ agent_slug: agent || undefined,
341
+ since: since || undefined,
342
+ limit: Math.min(parseInt(limit, 10) || 100, 1000),
343
+ });
344
+ res.json(rows);
345
+ });
346
+
347
+ app.post("/projects/:pid/messages", (req, res) => {
348
+ const p = project(req, res);
349
+ if (!p) return;
350
+ const { channel, direction, agent_slug, body, meta = {}, author = null } =
351
+ req.body || {};
352
+ if (!channel || !direction || !body)
353
+ return res.status(400).json({ error: "channel, direction, body required" });
354
+ if (!["in", "out"].includes(direction))
355
+ return res.status(400).json({ error: "direction must be in|out" });
356
+ const r = p.logMessage({ agent_slug: agent_slug || null, channel, direction, author, body, meta });
357
+ res.status(201).json({ ok: true, ts: r.ts });
358
+ });
359
+
360
+ app.get("/projects/:pid/messages/search", (req, res) => {
361
+ const p = project(req, res);
362
+ if (!p) return;
363
+ const { q, limit = "50" } = req.query;
364
+ if (!q) return res.status(400).json({ error: "q required" });
365
+ res.json(searchProjectMessages(p.path, q, Math.min(parseInt(limit, 10) || 50, 500)));
366
+ });
367
+
368
+ // ---- Global messages (cross-project channels: telegram, direct, …) ----
369
+ app.get("/messages/global", (req, res) => {
370
+ const { channel, limit = "100", since } = req.query;
371
+ const lim = Math.min(parseInt(limit, 10) || 100, 1000);
372
+ const rows = readGlobalMessages({ channel: channel || undefined, limit: lim, since });
373
+ res.json(rows);
374
+ });
375
+
376
+ // ---- Telegram -----------------------------------------------------
377
+ app.get("/telegram/status", (_req, res) => {
378
+ if (!telegram) return res.json({ enabled: false, channels: [] });
379
+ res.json(telegram.status());
380
+ });
381
+
382
+ app.post("/telegram/send", async (req, res) => {
383
+ const { chat_id, text, channel } = req.body || {};
384
+ if (!text) return res.status(400).json({ error: "text required" });
385
+ if (!telegram) return res.status(503).json({ error: "telegram plugin not loaded" });
386
+ try {
387
+ const r = await telegram.send({ chat_id, text, channel });
388
+ res.status(202).json({ ok: true, message_id: r.message_id });
389
+ } catch (e) {
390
+ res.status(502).json({ error: e.message });
391
+ }
392
+ });
393
+
394
+ // ---- Plugins -----------------------------------------------------
395
+ app.get("/plugins", (_req, res) => {
396
+ if (!plugins) return res.json({});
397
+ res.json(plugins.status());
398
+ });
399
+
400
+ app.get("/plugins/:id/status", (req, res) => {
401
+ if (!plugins) return res.status(404).end();
402
+ const inst = plugins.get(req.params.id);
403
+ if (!inst) return res.status(404).json({ error: `plugin ${req.params.id} not loaded` });
404
+ res.json(inst.status?.() || {});
405
+ });
406
+
407
+ // ---- Engines & Conversations -------------------------------------
408
+ app.get("/engines", (_req, res) => res.json({ engines: ENGINE_IDS }));
409
+
410
+ // POST /projects/:pid/agents/:slug/exec
411
+ app.post("/projects/:pid/agents/:slug/exec", async (req, res) => {
412
+ const p = project(req, res);
413
+ if (!p) return;
414
+ const { prompt, model: modelOverride, temperature, maxTokens } = req.body || {};
415
+ if (!prompt) return res.status(400).json({ error: "prompt required" });
416
+ const agents = readAgents(p.path);
417
+ const agent = agents.find((a) => a.slug === req.params.slug);
418
+ if (!agent) return res.status(404).json({ error: "agent not found" });
419
+ const modelId = modelOverride || agent.fields.Model;
420
+ if (!modelId) return res.status(400).json({ error: "agent has no model and none provided" });
421
+
422
+ try {
423
+ const system = buildAgentSystem(p, agent);
424
+ const conv = startConversation({ projectRoot: p.path, agentSlug: agent.slug, engine: modelId, system });
425
+ appendTurn({ filePath: conv.path, role: "user", content: prompt });
426
+
427
+ const result = await callEngine({
428
+ modelId,
429
+ system,
430
+ messages: [{ role: "user", content: prompt }],
431
+ config: p.config || config,
432
+ temperature,
433
+ maxTokens,
434
+ });
435
+
436
+ appendTurn({ filePath: conv.path, role: "assistant", content: result.text });
437
+ setStatus(conv.path, "closed");
438
+
439
+ p.logMessage({ agent_slug: agent.slug, channel: "engine", direction: "in", author: "user", body: prompt, meta: { conversation: conv.id } });
440
+ p.logMessage({ agent_slug: agent.slug, channel: "engine", direction: "out", author: agent.slug, body: result.text, meta: { conversation: conv.id, usage: result.usage } });
441
+
442
+ projects.rebuild(p.id);
443
+ res.json({
444
+ conversation: { id: conv.id, filename: conv.filename, path: conv.path },
445
+ text: result.text,
446
+ usage: result.usage,
447
+ engine: modelId,
448
+ });
449
+ } catch (e) {
450
+ res.status(500).json({ error: e.message });
451
+ }
452
+ });
453
+
454
+ // POST /projects/:pid/agents/:slug/chat
455
+ app.post("/projects/:pid/agents/:slug/chat", async (req, res) => {
456
+ const p = project(req, res);
457
+ if (!p) return;
458
+ const { prompt, conversation_id, model: modelOverride, temperature, maxTokens } = req.body || {};
459
+ if (!prompt) return res.status(400).json({ error: "prompt required" });
460
+ const agents = readAgents(p.path);
461
+ const agent = agents.find((a) => a.slug === req.params.slug);
462
+ if (!agent) return res.status(404).json({ error: "agent not found" });
463
+ const modelId = modelOverride || agent.fields.Model;
464
+ if (!modelId) return res.status(400).json({ error: "agent has no model and none provided" });
465
+
466
+ try {
467
+ let convPath;
468
+ let convId;
469
+ let history = [];
470
+ let compactSummary = null;
471
+
472
+ if (conversation_id) {
473
+ const existing = readConversation(p.path, agent.slug, conversation_id);
474
+ if (!existing) return res.status(404).json({ error: `conversation ${conversation_id} not found` });
475
+ convPath = existing.path;
476
+ convId = conversation_id;
477
+ // Extract compact summary if present — inject into system instead of messages.
478
+ const compactTurn = existing.turns.find((t) => t.role === "compact");
479
+ if (compactTurn) {
480
+ // Strip the "[Compacted N turns on ...]" header line from the summary body
481
+ compactSummary = compactTurn.content.replace(/^\[Compacted \d+ turns.*?\]\n\n?/, "").trim();
482
+ }
483
+ history = existing.turns
484
+ .filter((t) => t.role === "user" || t.role === "assistant")
485
+ .map((t) => ({ role: t.role, content: t.content }));
486
+ }
487
+
488
+ // Build system prompt — inject compact summary if this conversation was compacted.
489
+ const extraParts = compactSummary
490
+ ? [`## Contexto de conversación anterior (compactado)\n${compactSummary}`]
491
+ : [];
492
+ const system = buildAgentSystem(p, agent, { extraParts });
493
+
494
+ if (!conversation_id) {
495
+ const conv = startConversation({ projectRoot: p.path, agentSlug: agent.slug, engine: modelId, system });
496
+ convPath = conv.path;
497
+ convId = conv.id;
498
+ }
499
+
500
+ appendTurn({ filePath: convPath, role: "user", content: prompt });
501
+ history.push({ role: "user", content: prompt });
502
+
503
+ const result = await callEngine({ modelId, system, messages: history, config: p.config || config, temperature, maxTokens });
504
+ appendTurn({ filePath: convPath, role: "assistant", content: result.text });
505
+ projects.rebuild(p.id);
506
+
507
+ res.json({
508
+ conversation_id: convId,
509
+ text: result.text,
510
+ usage: result.usage,
511
+ engine: modelId,
512
+ compacted: !!compactSummary,
513
+ });
514
+ } catch (e) {
515
+ res.status(500).json({ error: e.message });
516
+ }
517
+ });
518
+
519
+ // GET /projects/:pid/agents/:slug/conversations
520
+ app.get("/projects/:pid/agents/:slug/conversations", (req, res) => {
521
+ const p = project(req, res);
522
+ if (!p) return;
523
+ const agents = readAgents(p.path);
524
+ if (!agents.find((a) => a.slug === req.params.slug))
525
+ return res.status(404).json({ error: "agent not found" });
526
+ res.json(listConversations(p.path, req.params.slug));
527
+ });
528
+
529
+ // GET /projects/:pid/agents/:slug/conversations/:id
530
+ app.get("/projects/:pid/agents/:slug/conversations/:id", (req, res) => {
531
+ const p = project(req, res);
532
+ if (!p) return;
533
+ const conv = readConversation(p.path, req.params.slug, req.params.id);
534
+ if (!conv) return res.status(404).json({ error: "conversation not found" });
535
+ res.json(conv);
536
+ });
537
+
538
+ // POST /projects/:pid/agents/:slug/compact ← compacts the latest conversation
539
+ // POST /projects/:pid/agents/:slug/conversations/:id/compact ← compacts a specific one
540
+ async function handleCompact(req, res, filename) {
541
+ const p = project(req, res);
542
+ if (!p) return;
543
+ const agents = readAgents(p.path);
544
+ const agent = agents.find((a) => a.slug === req.params.slug);
545
+ if (!agent) return res.status(404).json({ error: "agent not found" });
546
+ const modelId = (req.body || {}).model || agent.fields.Model;
547
+ if (!modelId) return res.status(400).json({ error: "agent has no model" });
548
+ try {
549
+ const result = await compactConversation({
550
+ projectRoot: p.path,
551
+ agentSlug: agent.slug,
552
+ filename: filename || null,
553
+ modelId,
554
+ config: p.config || config,
555
+ });
556
+ res.json(result);
557
+ } catch (e) {
558
+ res.status(500).json({ error: e.message });
559
+ }
560
+ }
561
+
562
+ app.post("/projects/:pid/agents/:slug/compact", (req, res) =>
563
+ handleCompact(req, res, null)
564
+ );
565
+
566
+ app.post("/projects/:pid/agents/:slug/conversations/:id/compact", (req, res) =>
567
+ handleCompact(req, res, req.params.id)
568
+ );
569
+
570
+ // ---- Agent-to-agent routing --------------------------------------
571
+ app.post("/projects/:pid/send", async (req, res) => {
572
+ const p = project(req, res);
573
+ if (!p) return;
574
+ const { from, to, body, deliver = false, _depth = 0 } = req.body || {};
575
+ if (!from || !to || !body)
576
+ return res.status(400).json({ error: "from, to, body required" });
577
+ if (_depth > 3)
578
+ return res.status(429).json({ error: "a2a depth limit (3) exceeded" });
579
+
580
+ const agents = readAgents(p.path);
581
+ const fromAgent = agents.find((a) => a.slug === from);
582
+ const toAgent = agents.find((a) => a.slug === to);
583
+ if (!fromAgent) return res.status(404).json({ error: `from agent "${from}" not found` });
584
+ if (!toAgent) return res.status(404).json({ error: `to agent "${to}" not found` });
585
+
586
+ const ts = nowIso();
587
+ p.logMessage({ agent_slug: from, channel: "a2a", direction: "out", author: from, body, meta: { to, depth: _depth }, ts });
588
+ p.logMessage({ agent_slug: to, channel: "a2a", direction: "in", author: from, body, meta: { from, depth: _depth }, ts });
589
+
590
+ let reply = null;
591
+ if (deliver && toAgent.fields.Model) {
592
+ try {
593
+ const tf = toAgent.fields;
594
+ const parts = [];
595
+ if (tf.Description) parts.push(tf.Description);
596
+ if (tf.Role) parts.push(`Role: ${tf.Role}`);
597
+ if (tf.Language) parts.push(`Default language: ${tf.Language}`);
598
+ parts.push(`You are ${toAgent.slug}. You just received a message from ${fromAgent.slug}. Reply concisely.`);
599
+ const memPath = path.join(p.path, ".apc", "agents", toAgent.slug, "memory.md");
600
+ if (fs.existsSync(memPath)) parts.push("## Memory\n" + fs.readFileSync(memPath, "utf8"));
601
+
602
+ const result = await callEngine({
603
+ modelId: toAgent.fields.Model,
604
+ system: parts.join("\n\n"),
605
+ messages: [{ role: "user", content: `From ${fromAgent.slug}:\n\n${body}` }],
606
+ config: p.config || config,
607
+ });
608
+
609
+ p.logMessage({ agent_slug: to, channel: "a2a", direction: "out", author: to, body: result.text, meta: { to: from, depth: _depth + 1, reply_to: fromAgent.slug, usage: result.usage } });
610
+ p.logMessage({ agent_slug: from, channel: "a2a", direction: "in", author: to, body: result.text, meta: { from: to, depth: _depth + 1 } });
611
+ reply = { text: result.text, usage: result.usage };
612
+ } catch (e) {
613
+ reply = { error: e.message };
614
+ }
615
+ }
616
+
617
+ res.json({ from, to, body, ts, reply });
618
+ });
619
+
620
+ // GET /projects/:pid/agents/:slug/connections
621
+ app.get("/projects/:pid/agents/:slug/connections", (req, res) => {
622
+ const p = project(req, res);
623
+ if (!p) return;
624
+ const agents = readAgents(p.path);
625
+ if (!agents.find((a) => a.slug === req.params.slug))
626
+ return res.status(404).json({ error: "agent not found" });
627
+
628
+ const messages = readProjectMessages(p.path, { agent_slug: req.params.slug });
629
+ const peers = new Map();
630
+ for (const m of messages) {
631
+ const peer = m.meta?.from || m.meta?.to || null;
632
+ if (!peer) continue;
633
+ const key = `${peer}|${m.channel}|${m.direction}`;
634
+ const existing = peers.get(key);
635
+ if (!existing) {
636
+ peers.set(key, { peer, channel: m.channel, direction: m.direction, n: 1, last_ts: m.ts });
637
+ } else {
638
+ existing.n++;
639
+ if (m.ts > existing.last_ts) existing.last_ts = m.ts;
640
+ }
641
+ }
642
+ res.json(
643
+ Array.from(peers.values()).sort((a, b) => (b.last_ts || "").localeCompare(a.last_ts || ""))
644
+ );
645
+ });
646
+
647
+ // ---- Runtimes (external CLI agents) -------------------------------
648
+ app.get("/runtimes", (_req, res) => res.json({ runtimes: RUNTIME_IDS }));
649
+
650
+ app.get("/env/detect", async (_req, res) => {
651
+ const detected = await detectAll();
652
+ res.json(detected);
653
+ });
654
+
655
+ // POST /projects/:pid/agents/:slug/runtime
656
+ app.post("/projects/:pid/agents/:slug/runtime", async (req, res) => {
657
+ const p = project(req, res);
658
+ if (!p) return;
659
+ const { runtime, prompt, timeoutMs } = req.body || {};
660
+ if (!runtime || !prompt)
661
+ return res.status(400).json({ error: "runtime and prompt required" });
662
+
663
+ const agents = readAgents(p.path);
664
+ const agent = agents.find((a) => a.slug === req.params.slug);
665
+ if (!agent) return res.status(404).json({ error: "agent not found" });
666
+
667
+ let rt;
668
+ try {
669
+ rt = getRuntime(runtime);
670
+ } catch (e) {
671
+ return res.status(400).json({ error: e.message });
672
+ }
673
+
674
+ let projectName = path.basename(p.path);
675
+ try {
676
+ const meta = JSON.parse(fs.readFileSync(path.join(p.path, ".apc", "project.json"), "utf8"));
677
+ if (meta.name) projectName = meta.name;
678
+ } catch {}
679
+
680
+ const session = createRuntimeSession({
681
+ projectRoot: p.path,
682
+ agentSlug: agent.slug,
683
+ runtime,
684
+ title: req.body?.title,
685
+ taskRef: req.body?.task_ref || "",
686
+ });
687
+
688
+ const system = buildAgentSystem(p, agent, {
689
+ extraParts: [
690
+ buildApfHint({
691
+ projectName,
692
+ projectPath: p.path,
693
+ agentSlug: agent.slug,
694
+ sessionId: session.id,
695
+ }),
696
+ ],
697
+ });
698
+
699
+ try {
700
+ const r = await rt.run({
701
+ system,
702
+ prompt,
703
+ cwd: p.path,
704
+ timeoutMs: timeoutMs || 5 * 60 * 1000,
705
+ });
706
+
707
+ const apfResult = extractApfResult(r.output) || (r.output || "").slice(0, 200);
708
+ closeRuntimeSession({ filePath: session.path, externalSessionPath: r.externalSessionPath || null, exitCode: r.exitCode, result: apfResult });
709
+
710
+ p.logMessage({ agent_slug: agent.slug, channel: "runtime", direction: "in", author: "user", body: prompt, meta: { runtime, apc_session: session.id } });
711
+ p.logMessage({ agent_slug: agent.slug, channel: "runtime", direction: "out", author: agent.slug, body: r.output || "", meta: { runtime, exit_code: r.exitCode, external_session_path: r.externalSessionPath || null, session_id: r.sessionId || null, apc_session: session.id } });
712
+ projects.rebuild(p.id);
713
+
714
+ res.json({
715
+ runtime,
716
+ exit_code: r.exitCode,
717
+ output: r.output,
718
+ stderr: r.stderr,
719
+ external_session_path: r.externalSessionPath || null,
720
+ session_id: r.sessionId || null,
721
+ apc_session: session.id,
722
+ });
723
+ } catch (e) {
724
+ try {
725
+ closeRuntimeSession({ filePath: session.path, exitCode: -1, result: `error: ${e.message.slice(0, 200)}` });
726
+ } catch {}
727
+ res.status(500).json({ error: e.message, apc_session: session.id });
728
+ }
729
+ });
730
+
731
+ // ---- Session resume -----------------------------------------------
732
+ app.get("/projects/:pid/sessions/:id/resume", async (req, res) => {
733
+ const p = project(req, res);
734
+ if (!p) return;
735
+ const { id } = req.params;
736
+
737
+ const agentsDir = path.join(p.path, ".apc", "agents");
738
+ let sessionFile = null;
739
+ let agentSlug = null;
740
+ if (fs.existsSync(agentsDir)) {
741
+ for (const slug of fs.readdirSync(agentsDir)) {
742
+ const f = path.join(agentsDir, slug, "sessions", `${id}.md`);
743
+ if (fs.existsSync(f)) {
744
+ sessionFile = f;
745
+ agentSlug = slug;
746
+ break;
747
+ }
748
+ }
749
+ }
750
+ if (!sessionFile) return res.status(404).json({ error: `session ${id} not found` });
751
+
752
+ const session = readSessionFrontmatter(sessionFile);
753
+ const out = {
754
+ id,
755
+ agent: agentSlug,
756
+ session_path: sessionFile,
757
+ frontmatter: session?.fm || {},
758
+ external_transcript: null,
759
+ summary: null,
760
+ };
761
+
762
+ const externalPath = session?.fm?.external_session_path;
763
+ if (externalPath && fs.existsSync(externalPath)) {
764
+ const stat = fs.statSync(externalPath);
765
+ const raw = fs.readFileSync(externalPath, "utf8");
766
+ out.external_transcript = {
767
+ path: externalPath,
768
+ size: stat.size,
769
+ tail: raw.length > 32 * 1024 ? raw.slice(-32 * 1024) : raw,
770
+ };
771
+ }
772
+
773
+ if (req.query.summarize === "true" && isSuperAgentEnabled(config)) {
774
+ try {
775
+ const prompt =
776
+ `Resumí qué pasó en esta sesión APC en 4 bullets concretos.\n\n` +
777
+ `Frontmatter:\n${JSON.stringify(out.frontmatter, null, 2)}\n\n` +
778
+ (out.external_transcript
779
+ ? `Transcript externo (últimos ${out.external_transcript.tail.length} chars):\n${out.external_transcript.tail}`
780
+ : `(sin transcript externo)`);
781
+ const sa = await runSuperAgent({ globalConfig: config, projects, plugins, registries, prompt, contextNote: `Resume request for session ${id}.` });
782
+ out.summary = sa.text;
783
+ } catch (e) {
784
+ out.summary = `(super-agent failed: ${e.message})`;
785
+ }
786
+ }
787
+
788
+ res.json(out);
789
+ });
790
+
791
+ // ---- Routines (per-project scheduled tasks) ----------------------
792
+ app.get("/projects/:pid/routines", (req, res) => {
793
+ const p = project(req, res);
794
+ if (!p) return;
795
+ res.json(listRoutines(p.path));
796
+ });
797
+
798
+ app.get("/projects/:pid/routines/:name", (req, res) => {
799
+ const p = project(req, res);
800
+ if (!p) return;
801
+ const r = getRoutine(p.path, req.params.name);
802
+ if (!r) return res.status(404).json({ error: "routine not found" });
803
+ res.json(r);
804
+ });
805
+
806
+ app.post("/projects/:pid/routines", (req, res) => {
807
+ const p = project(req, res);
808
+ if (!p) return;
809
+ try {
810
+ const r = upsertRoutine(p.path, req.body || {});
811
+ res.status(201).json(r);
812
+ } catch (e) {
813
+ res.status(400).json({ error: e.message });
814
+ }
815
+ });
816
+
817
+ app.delete("/projects/:pid/routines/:name", (req, res) => {
818
+ const p = project(req, res);
819
+ if (!p) return;
820
+ const ok = deleteRoutine(p.path, req.params.name);
821
+ res.status(ok ? 204 : 404).end();
822
+ });
823
+
824
+ app.post("/projects/:pid/routines/:name/enable", (req, res) => {
825
+ const p = project(req, res);
826
+ if (!p) return;
827
+ setRoutineEnabled(p.path, req.params.name, true);
828
+ res.json({ ok: true });
829
+ });
830
+
831
+ app.post("/projects/:pid/routines/:name/disable", (req, res) => {
832
+ const p = project(req, res);
833
+ if (!p) return;
834
+ setRoutineEnabled(p.path, req.params.name, false);
835
+ res.json({ ok: true });
836
+ });
837
+
838
+ app.post("/projects/:pid/routines/:name/run", async (req, res) => {
839
+ const p = project(req, res);
840
+ if (!p) return;
841
+ const r = getRoutine(p.path, req.params.name);
842
+ if (!r) return res.status(404).json({ error: "routine not found" });
843
+ try {
844
+ const result = await runRoutineNow({ project: p, plugins, globalConfig: config }, r);
845
+ res.json(result);
846
+ } catch (e) {
847
+ res.status(500).json({ error: e.message });
848
+ }
849
+ });
850
+
851
+ // ---- Per-project config (.apc/config.json) -----------------------
852
+ app.get("/projects/:pid/config", (req, res) => {
853
+ const p = project(req, res);
854
+ if (!p) return;
855
+ res.json({
856
+ effective: p.config || {},
857
+ project_only: readProjectConfig(p.path),
858
+ project_config_path: path.join(p.path, ".apc", "config.json"),
859
+ });
860
+ });
861
+
862
+ app.put("/projects/:pid/config", (req, res) => {
863
+ const p = project(req, res);
864
+ if (!p) return;
865
+ const body = req.body || {};
866
+ if (typeof body !== "object" || Array.isArray(body))
867
+ return res.status(400).json({ error: "body must be a JSON object" });
868
+ writeProjectConfig(p.path, body);
869
+ projects.rebuild(p.id);
870
+ res.json({ ok: true });
871
+ });
872
+
873
+ app.patch("/projects/:pid/config", (req, res) => {
874
+ const p = project(req, res);
875
+ if (!p) return;
876
+ const { set, unset } = req.body || {};
877
+ const cfg = readProjectConfig(p.path);
878
+ if (set && typeof set === "object") {
879
+ for (const [k, v] of Object.entries(set)) setDottedKey(cfg, k, v);
880
+ }
881
+ if (Array.isArray(unset)) {
882
+ for (const k of unset) unsetDottedKey(cfg, k);
883
+ }
884
+ writeProjectConfig(p.path, cfg);
885
+ projects.rebuild(p.id);
886
+ res.json({ ok: true, project_only: cfg });
887
+ });
888
+
889
+ // ---- Admin --------------------------------------------------------
890
+ app.post("/admin/shutdown", (_req, res) => {
891
+ res.json({ ok: true });
892
+ setTimeout(() => process.exit(0), 50);
893
+ });
894
+
895
+ // ---- 404 catchall -------------------------------------------------
896
+ app.use((req, res) => res.status(404).json({ error: `no route ${req.method} ${req.path}` }));
897
+
898
+ return app;
899
+ }
900
+
901
+ // ---------------------------------------------------------------------
902
+ // Helpers
903
+ // ---------------------------------------------------------------------
904
+
905
+ function agentToResponse(a) {
906
+ if (!a) return null;
907
+ const f = a.fields || {};
908
+ const reserved = new Set(["Role", "Model", "Language", "Description", "Skills", "Tools"]);
909
+ const extra = {};
910
+ for (const [k, v] of Object.entries(f)) {
911
+ if (!reserved.has(k)) extra[k] = v;
912
+ }
913
+ return {
914
+ slug: a.slug,
915
+ role: f.Role || null,
916
+ model: f.Model || null,
917
+ language: f.Language || null,
918
+ description: f.Description || null,
919
+ skills: Array.isArray(f.Skills) ? f.Skills : [],
920
+ tools: Array.isArray(f.Tools) ? f.Tools : [],
921
+ extra,
922
+ };
923
+ }
924
+
925
+ // Build system prompt from an agent's fields + memory + skills.
926
+ // Optional `extraParts` are appended at the end.
927
+ function buildAgentSystem(p, agent, { extraParts = [] } = {}) {
928
+ const f = agent.fields || {};
929
+ const parts = [];
930
+ if (f.Description) parts.push(f.Description);
931
+ if (f.Role) parts.push(`Role: ${f.Role}`);
932
+ if (f.Language) parts.push(`Default language: ${f.Language}`);
933
+ const memPath = path.join(p.path, ".apc", "agents", agent.slug, "memory.md");
934
+ if (fs.existsSync(memPath)) {
935
+ parts.push("## Memory\n" + fs.readFileSync(memPath, "utf8"));
936
+ }
937
+ const apxSkill = path.join(p.path, ".apc", "skills", "apx.md");
938
+ if (fs.existsSync(apxSkill)) parts.push("## APX\n" + fs.readFileSync(apxSkill, "utf8"));
939
+ const skills = Array.isArray(f.Skills) ? f.Skills : [];
940
+ for (const skill of skills) {
941
+ const sp = path.join(p.path, ".apc", "skills", `${skill}.md`);
942
+ if (fs.existsSync(sp)) parts.push(`## Skill: ${skill}\n` + fs.readFileSync(sp, "utf8"));
943
+ }
944
+ for (const ep of extraParts) parts.push(ep);
945
+ return parts.join("\n\n");
946
+ }