@agentprojectcontext/apx 1.10.3 → 1.11.0

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.
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: openrouter
3
+ description: "Activate ONLY when the user explicitly mentions OpenRouter, OPENROUTER_API_KEY, OpenRouter models, installing OpenRouter provider config, or using OpenRouter with APX, OpenCode, LiteLLM, or an OpenAI-compatible client."
4
+ homepage: https://openrouter.ai/docs
5
+ ---
6
+ # OpenRouter
7
+
8
+ Use this skill only for OpenRouter install/config, model listing, or usage through APX/OpenCode or
9
+ OpenAI-compatible clients.
10
+
11
+ ## Verify before acting
12
+
13
+ OpenRouter is an API/provider, not a local coding runtime by itself. First identify which client
14
+ will use it: APX engine, OpenCode provider, LiteLLM, OpenAI SDK, or another OpenAI-compatible tool.
15
+
16
+ Do not expose keys. Check only presence:
17
+
18
+ ```bash
19
+ test -n "$OPENROUTER_API_KEY" && echo "OPENROUTER_API_KEY present"
20
+ ```
21
+
22
+ ## OpenAI-compatible base URL
23
+
24
+ Typical API settings:
25
+
26
+ ```bash
27
+ OPENAI_BASE_URL=https://openrouter.ai/api/v1
28
+ OPENROUTER_API_KEY=...
29
+ ```
30
+
31
+ Use the selected client's current docs/help before writing config.
32
+
33
+ ## APX guidance
34
+
35
+ If configuring APX engine/provider, inspect current config schema first:
36
+
37
+ ```bash
38
+ apx config --help
39
+ apx status
40
+ ```
41
+
42
+ Then update only non-secret project-safe settings. Keep API keys in user config or environment, not
43
+ in `.apc/` or git.
44
+
45
+ ## OpenCode guidance
46
+
47
+ If using OpenRouter via OpenCode, inspect provider commands first:
48
+
49
+ ```bash
50
+ opencode providers
51
+ opencode models
52
+ ```
53
+
54
+ Then configure OpenRouter through OpenCode's current provider flow.
@@ -87,7 +87,7 @@ function buildSkillMd(content) {
87
87
  const frontmatter = [
88
88
  "---",
89
89
  "name: apx",
90
- "description: APX CLI skill. Activate when: user asks to run or coordinate agents, use MCP tools from .apc/mcps.json, install agents from a team workspace, or explicitly mentions apx commands. Do NOT activate just because .apc/ exists — that is handled by the apc-context skill. Activate on: 'apx run', 'apx exec', 'run an agent', 'coordinate agents', 'MCP not working', 'install agent', 'team agents', 'apx memory', 'daemon'.",
90
+ "description: \"APX CLI skill. Activate when: user asks to run or coordinate agents, use MCP tools from .apc/mcps.json, install agents from a team workspace, or explicitly mentions apx commands. Do NOT activate just because .apc/ exists — that is handled by the apc-context skill. Activate on: 'apx run', 'apx exec', 'run an agent', 'coordinate agents', 'MCP not working', 'install agent', 'team agents', 'apx memory', 'daemon'.\"",
91
91
  "homepage: https://github.com/agentprojectcontext/apx",
92
92
  "---",
93
93
  "",
@@ -95,6 +95,19 @@ function buildSkillMd(content) {
95
95
  return frontmatter + content;
96
96
  }
97
97
 
98
+ function readRuntimeSkillFiles() {
99
+ const skillsDir = path.join(__dirname, "runtime-skills");
100
+ if (!fs.existsSync(skillsDir)) return [];
101
+
102
+ return fs.readdirSync(skillsDir)
103
+ .filter((name) => name.endsWith(".md"))
104
+ .sort()
105
+ .map((name) => ({
106
+ slug: path.basename(name, ".md"),
107
+ md: fs.readFileSync(path.join(skillsDir, name), "utf8").trim(),
108
+ }));
109
+ }
110
+
98
111
  // Install APX + APC context skills into IDE rule files. Returns an array of result objects.
99
112
  // targetIds: array of target ids to install; null = all project targets.
100
113
  export function installIdeSkills(root, targetIds = null) {
@@ -159,6 +172,7 @@ export function installGlobalSkills() {
159
172
  skills.push({ slug: "apx", md: buildSkillMd(fs.readFileSync(apxSrc, "utf8").trim()) });
160
173
  if (fs.existsSync(apcSrc))
161
174
  skills.push({ slug: "apc-context", md: buildApcContextSkillMd(fs.readFileSync(apcSrc, "utf8").trim()) });
175
+ skills.push(...readRuntimeSkillFiles());
162
176
 
163
177
  for (const base of GLOBAL_SKILL_DIRS) {
164
178
  for (const { slug, md } of skills) {
package/src/daemon/api.js CHANGED
@@ -3,6 +3,12 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { execFile } from "node:child_process";
5
5
  import express from "express";
6
+ import { buildBrowserRouter } from "./tools/browser.js";
7
+ import { buildFetchRouter } from "./tools/fetch.js";
8
+ import { buildSearchRouter } from "./tools/search.js";
9
+ import { buildRegistryRouter } from "./tools/registry.js";
10
+ import { buildGlobRouter } from "./tools/glob.js";
11
+ import { buildGrepRouter } from "./tools/grep.js";
6
12
  import { readApfMcps, writeApfMcps, SOURCES } from "./mcp-sources.js";
7
13
  import { callEngine, ENGINE_IDS } from "./engines/index.js";
8
14
  import { getRuntime, RUNTIME_IDS } from "./runtimes/index.js";
@@ -58,6 +64,18 @@ export function buildApi({ projects, registries, plugins, scheduler, version, st
58
64
  const app = express();
59
65
  app.use(express.json({ limit: "2mb" }));
60
66
 
67
+ // ---- Tool routers (fetch / browser / search / glob / grep / registry) ----
68
+ // fetch = native HTTP, no Chromium → fast, cheap, default for REST/HTML
69
+ // browser = Puppeteer-backed → heavy, lazy-launched, for JS-rendered pages
70
+ app.use("/tools/fetch", buildFetchRouter(express));
71
+ app.use("/tools/browser", buildBrowserRouter(express));
72
+ app.use("/tools/search", buildSearchRouter(express));
73
+ app.use("/tools/glob", buildGlobRouter(express));
74
+ app.use("/tools/grep", buildGrepRouter(express));
75
+ // Registry MUST be mounted after specific routers so /:name wildcard
76
+ // doesn't shadow /tools/browser, /tools/fetch, /tools/search, etc.
77
+ app.use("/tools", buildRegistryRouter(express, { projects, registries }));
78
+
61
79
  // ---- Health -------------------------------------------------------
62
80
  app.get("/health", (_req, res) => {
63
81
  res.json({
@@ -399,6 +417,60 @@ export function buildApi({ projects, registries, plugins, scheduler, version, st
399
417
  }
400
418
  });
401
419
 
420
+ // POST /telegram/send_photo { chat_id?, photo (path|url), caption?, channel? }
421
+ app.post("/telegram/send_photo", async (req, res) => {
422
+ const { chat_id, photo, caption, parse_mode, channel } = req.body || {};
423
+ if (!photo) return res.status(400).json({ error: "photo required (path or url)" });
424
+ if (!telegram) return res.status(503).json({ error: "telegram plugin not loaded" });
425
+ try {
426
+ const r = await telegram.sendPhoto({ chat_id, photo, caption, parse_mode, channel });
427
+ res.status(202).json({ ok: true, message_id: r?.message_id });
428
+ } catch (e) {
429
+ res.status(502).json({ error: e.message });
430
+ }
431
+ });
432
+
433
+ // POST /telegram/send_voice { chat_id?, audio (path), caption?, duration?, channel? }
434
+ app.post("/telegram/send_voice", async (req, res) => {
435
+ const { chat_id, audio, caption, duration, channel } = req.body || {};
436
+ if (!audio) return res.status(400).json({ error: "audio required (path)" });
437
+ if (!telegram) return res.status(503).json({ error: "telegram plugin not loaded" });
438
+ try {
439
+ const r = await telegram.sendVoice({ chat_id, audio, caption, duration, channel });
440
+ res.status(202).json({ ok: true, message_id: r?.message_id });
441
+ } catch (e) {
442
+ res.status(502).json({ error: e.message });
443
+ }
444
+ });
445
+
446
+ // POST /telegram/send_audio { chat_id?, audio (path), caption?, title?, performer?, channel? }
447
+ app.post("/telegram/send_audio", async (req, res) => {
448
+ const { chat_id, audio, caption, title, performer, channel } = req.body || {};
449
+ if (!audio) return res.status(400).json({ error: "audio required (path)" });
450
+ if (!telegram) return res.status(503).json({ error: "telegram plugin not loaded" });
451
+ try {
452
+ const r = await telegram.sendAudio({ chat_id, audio, caption, title, performer, channel });
453
+ res.status(202).json({ ok: true, message_id: r?.message_id });
454
+ } catch (e) {
455
+ res.status(502).json({ error: e.message });
456
+ }
457
+ });
458
+
459
+ // POST /telegram/notify — alias for /telegram/send for proactive daemon notifications
460
+ // Any internal daemon code (routines, error handlers, MCP failure hooks) can POST here
461
+ // to push a message to the user without waiting for a user-initiated request.
462
+ app.post("/telegram/notify", async (req, res) => {
463
+ const { chat_id, text, channel } = req.body || {};
464
+ if (!text) return res.status(400).json({ error: "text required" });
465
+ if (!telegram) return res.status(503).json({ error: "telegram plugin not loaded" });
466
+ try {
467
+ const r = await telegram.send({ chat_id, text, channel });
468
+ res.status(202).json({ ok: true, message_id: r.message_id, via: "notify" });
469
+ } catch (e) {
470
+ res.status(502).json({ error: e.message });
471
+ }
472
+ });
473
+
402
474
  // ---- Plugins -----------------------------------------------------
403
475
  app.get("/plugins", (_req, res) => {
404
476
  if (!plugins) return res.json({});
@@ -1174,6 +1246,163 @@ export function buildApi({ projects, registries, plugins, scheduler, version, st
1174
1246
  }
1175
1247
  });
1176
1248
 
1249
+ // ---- Session search (cross-agent, cross-conversation) ------------
1250
+ // GET /sessions/search?q=...&project=...&limit=20
1251
+ // Searches session files (.apc/agents/{slug}/sessions/*.md) and
1252
+ // conversation files (~/.apx/.../conversations/*.md) by text content.
1253
+ app.get("/sessions/search", (req, res) => {
1254
+ const { q, project: projectRef, limit = "20" } = req.query;
1255
+ if (!q) return res.status(400).json({ error: "q required" });
1256
+ const lim = Math.min(parseInt(limit, 10) || 20, 200);
1257
+ const needle = q.toLowerCase();
1258
+
1259
+ // Resolve project (or search all)
1260
+ const allProjects = projects.list();
1261
+ const targetProjects = (() => {
1262
+ if (projectRef != null) {
1263
+ const ref = String(projectRef);
1264
+ const found = allProjects.find((p) => String(p.id) === ref || p.path === path.resolve(ref));
1265
+ return found ? [projects.get(found.id)] : [];
1266
+ }
1267
+ return allProjects.map((p) => projects.get(p.id)).filter(Boolean);
1268
+ })();
1269
+
1270
+ const matches = [];
1271
+
1272
+ for (const p of targetProjects) {
1273
+ if (!p) continue;
1274
+
1275
+ // 1. Search session files in project (.apc/agents/{slug}/sessions/)
1276
+ const sessionAgentsDir = path.join(p.path, ".apc", "agents");
1277
+ if (fs.existsSync(sessionAgentsDir)) {
1278
+ for (const slug of fs.readdirSync(sessionAgentsDir)) {
1279
+ const sessionsDir = path.join(sessionAgentsDir, slug, "sessions");
1280
+ if (!fs.existsSync(sessionsDir)) continue;
1281
+ for (const f of fs.readdirSync(sessionsDir).filter((x) => x.endsWith(".md"))) {
1282
+ const filePath = path.join(sessionsDir, f);
1283
+ try {
1284
+ const text = fs.readFileSync(filePath, "utf8");
1285
+ if (text.toLowerCase().includes(needle)) {
1286
+ // Find matching excerpt
1287
+ const lines = text.split("\n");
1288
+ const matchLine = lines.findIndex((l) => l.toLowerCase().includes(needle));
1289
+ const excerpt = lines.slice(Math.max(0, matchLine - 1), matchLine + 3).join("\n");
1290
+ matches.push({
1291
+ type: "session",
1292
+ project: p.id,
1293
+ agent: slug,
1294
+ filename: f,
1295
+ path: filePath,
1296
+ excerpt: excerpt.slice(0, 300),
1297
+ });
1298
+ if (matches.length >= lim) break;
1299
+ }
1300
+ } catch {}
1301
+ }
1302
+ if (matches.length >= lim) break;
1303
+ }
1304
+ }
1305
+
1306
+ if (matches.length >= lim) break;
1307
+
1308
+ // 2. Search conversation files in daemon storage (~/.apx/.../conversations/)
1309
+ const convAgentsDir = path.join(p.storagePath, "agents");
1310
+ if (fs.existsSync(convAgentsDir)) {
1311
+ for (const slug of fs.readdirSync(convAgentsDir)) {
1312
+ const convDir = path.join(convAgentsDir, slug, "conversations");
1313
+ if (!fs.existsSync(convDir)) continue;
1314
+ for (const f of fs.readdirSync(convDir).filter((x) => x.endsWith(".md"))) {
1315
+ const filePath = path.join(convDir, f);
1316
+ try {
1317
+ const text = fs.readFileSync(filePath, "utf8");
1318
+ if (text.toLowerCase().includes(needle)) {
1319
+ const lines = text.split("\n");
1320
+ const matchLine = lines.findIndex((l) => l.toLowerCase().includes(needle));
1321
+ const excerpt = lines.slice(Math.max(0, matchLine - 1), matchLine + 3).join("\n");
1322
+ matches.push({
1323
+ type: "conversation",
1324
+ project: p.id,
1325
+ agent: slug,
1326
+ filename: f,
1327
+ path: filePath,
1328
+ excerpt: excerpt.slice(0, 300),
1329
+ });
1330
+ if (matches.length >= lim) break;
1331
+ }
1332
+ } catch {}
1333
+ }
1334
+ if (matches.length >= lim) break;
1335
+ }
1336
+ }
1337
+
1338
+ if (matches.length >= lim) break;
1339
+ }
1340
+
1341
+ res.json({ q, count: matches.length, results: matches });
1342
+ });
1343
+
1344
+ // POST /sessions/:id/compact
1345
+ // Shortcut: resolves which project/agent owns the session file,
1346
+ // then delegates to the existing compactConversation logic.
1347
+ // Body: { project?, model? }
1348
+ app.post("/sessions/:id/compact", async (req, res) => {
1349
+ const { id } = req.params;
1350
+ const { model: modelOverride, project: projectRef } = req.body || {};
1351
+
1352
+ // Find which project/agent owns this session ID
1353
+ const allProjects = projectRef != null
1354
+ ? (() => {
1355
+ const ref = String(projectRef);
1356
+ const found = projects.list().find((p) => String(p.id) === ref || p.path === path.resolve(ref));
1357
+ return found ? [projects.get(found.id)] : [];
1358
+ })()
1359
+ : projects.list().map((p) => projects.get(p.id)).filter(Boolean);
1360
+
1361
+ let found = null;
1362
+ const filename = id.endsWith(".md") ? id : `${id}.md`;
1363
+
1364
+ for (const p of allProjects) {
1365
+ if (!p) continue;
1366
+ // Search in daemon conversation storage
1367
+ const agentsDir = path.join(p.storagePath, "agents");
1368
+ if (fs.existsSync(agentsDir)) {
1369
+ for (const slug of fs.readdirSync(agentsDir)) {
1370
+ const f = path.join(agentsDir, slug, "conversations", filename);
1371
+ if (fs.existsSync(f)) {
1372
+ found = { p, slug };
1373
+ break;
1374
+ }
1375
+ }
1376
+ }
1377
+ if (found) break;
1378
+ }
1379
+
1380
+ if (!found) {
1381
+ return res.status(404).json({ error: `session/conversation "${id}" not found` });
1382
+ }
1383
+
1384
+ const { p, slug } = found;
1385
+ const { readAgents: _readAgents } = await import("../core/parser.js");
1386
+ const agents = _readAgents(p.path);
1387
+ const agent = agents.find((a) => a.slug === slug);
1388
+ const modelId = modelOverride || agent?.fields?.Model;
1389
+ if (!modelId) return res.status(400).json({ error: "agent has no model; pass model in body" });
1390
+
1391
+ try {
1392
+ const { compactConversation } = await import("./compact.js");
1393
+ const result = await compactConversation({
1394
+ storagePath: p.storagePath,
1395
+ agentSlug: slug,
1396
+ filename,
1397
+ modelId,
1398
+ config: p.config || config,
1399
+ });
1400
+ res.json(result);
1401
+ } catch (e) {
1402
+ res.status(500).json({ error: e.message });
1403
+ }
1404
+ });
1405
+
1177
1406
  // ---- Admin --------------------------------------------------------
1178
1407
  app.post("/admin/shutdown", (_req, res) => {
1179
1408
  res.json({ ok: true });
@@ -11,7 +11,7 @@ function getKey(config) {
11
11
  export default {
12
12
  id: "anthropic",
13
13
 
14
- async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {} }) {
14
+ async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {}, tools, toolChoice }) {
15
15
  const key = getKey(config);
16
16
  if (!key) throw new Error("anthropic: no api_key (set ANTHROPIC_API_KEY or engines.anthropic.api_key)");
17
17
  if (!model) throw new Error("anthropic: model required");
@@ -27,6 +27,18 @@ export default {
27
27
  };
28
28
  if (system) body.system = system;
29
29
 
30
+ // Tool use support — pass tools array and optional tool_choice.
31
+ // toolChoice="required" → { type: "any" } forces at least one tool call per turn,
32
+ // preventing the model from giving an empty acknowledgment instead of acting.
33
+ if (tools && tools.length > 0) {
34
+ body.tools = tools;
35
+ if (toolChoice === "required" || toolChoice === "any") {
36
+ body.tool_choice = { type: "any" };
37
+ } else if (toolChoice && typeof toolChoice === "object") {
38
+ body.tool_choice = toolChoice;
39
+ }
40
+ }
41
+
30
42
  const res = await fetch(API_BASE, {
31
43
  method: "POST",
32
44
  headers: {
@@ -42,12 +54,18 @@ export default {
42
54
  `anthropic ${res.status}: ${json?.error?.message || JSON.stringify(json)}`
43
55
  );
44
56
  }
57
+
58
+ // Extract text blocks; also capture tool_use blocks for callers that need them
45
59
  const text = (json.content || [])
46
60
  .filter((b) => b.type === "text")
47
61
  .map((b) => b.text)
48
62
  .join("");
63
+ const toolUses = (json.content || []).filter((b) => b.type === "tool_use");
64
+
49
65
  return {
50
66
  text,
67
+ tool_uses: toolUses.length > 0 ? toolUses : undefined,
68
+ stop_reason: json.stop_reason,
51
69
  usage: {
52
70
  input_tokens: json.usage?.input_tokens || 0,
53
71
  output_tokens: json.usage?.output_tokens || 0,
@@ -46,7 +46,7 @@ export function getAdapter(provider) {
46
46
  return a;
47
47
  }
48
48
 
49
- export async function callEngine({ modelId, system, messages, config, temperature, maxTokens, tools }) {
49
+ export async function callEngine({ modelId, system, messages, config, temperature, maxTokens, tools, toolChoice }) {
50
50
  const { provider, model } = resolveProvider(modelId);
51
51
  const adapter = getAdapter(provider);
52
52
  const providerCfg =
@@ -58,6 +58,7 @@ export async function callEngine({ modelId, system, messages, config, temperatur
58
58
  temperature,
59
59
  maxTokens,
60
60
  tools,
61
+ toolChoice,
61
62
  config: providerCfg,
62
63
  });
63
64
  }
@@ -10,7 +10,7 @@ function getKey(config) {
10
10
  export default {
11
11
  id: "openai",
12
12
 
13
- async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {} }) {
13
+ async chat({ system, messages, model, temperature = 1.0, maxTokens = 1024, config = {}, tools, toolChoice }) {
14
14
  const key = getKey(config);
15
15
  if (!key) throw new Error("openai: no api_key (set OPENAI_API_KEY or engines.openai.api_key)");
16
16
  if (!model) throw new Error("openai: model required");
@@ -31,6 +31,20 @@ export default {
31
31
  max_tokens: maxTokens,
32
32
  };
33
33
 
34
+ // Tool use support
35
+ if (tools && tools.length > 0) {
36
+ body.tools = tools;
37
+ // toolChoice="required" forces the model to call at least one tool,
38
+ // preventing empty acknowledgment responses.
39
+ if (toolChoice === "required") {
40
+ body.tool_choice = "required";
41
+ } else if (toolChoice === "any") {
42
+ body.tool_choice = "required"; // OpenAI uses "required" for "any"
43
+ } else if (toolChoice && typeof toolChoice === "object") {
44
+ body.tool_choice = toolChoice;
45
+ }
46
+ }
47
+
34
48
  const res = await fetch(API_BASE, {
35
49
  method: "POST",
36
50
  headers: {
@@ -45,9 +59,15 @@ export default {
45
59
  `openai ${res.status}: ${json?.error?.message || JSON.stringify(json)}`
46
60
  );
47
61
  }
48
- const text = json.choices?.[0]?.message?.content || "";
62
+
63
+ const choice = json.choices?.[0];
64
+ const text = choice?.message?.content || "";
65
+ const toolCalls = choice?.message?.tool_calls;
66
+
49
67
  return {
50
68
  text,
69
+ tool_calls: toolCalls?.length > 0 ? toolCalls : undefined,
70
+ finish_reason: choice?.finish_reason,
51
71
  usage: {
52
72
  input_tokens: json.usage?.prompt_tokens || 0,
53
73
  output_tokens: json.usage?.completion_tokens || 0,