@agentprojectcontext/apx 1.30.2 → 1.31.1

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 (31) hide show
  1. package/package.json +1 -1
  2. package/skills/apx-agency-agents/SKILL.md +1 -1
  3. package/skills/apx-agent/SKILL.md +6 -6
  4. package/skills/apx-project/SKILL.md +1 -2
  5. package/src/core/agent/prompt-builder.js +6 -0
  6. package/src/core/agent/run-agent.js +21 -0
  7. package/src/core/agent/self-memory.js +1 -1
  8. package/src/core/agent-memory.js +64 -0
  9. package/src/core/agent-system.js +3 -2
  10. package/src/core/scaffold.js +43 -18
  11. package/src/core/tools/browser.js +169 -75
  12. package/src/core/tools/registry.js +13 -8
  13. package/src/core/tools/search.js +35 -7
  14. package/src/host/daemon/api/agents.js +19 -21
  15. package/src/host/daemon/api/sessions-search.js +1 -1
  16. package/src/host/daemon/api/shared.js +5 -8
  17. package/src/host/daemon/super-agent-tools/index.js +232 -43
  18. package/src/host/daemon/super-agent-tools/registry-bridge.js +30 -1
  19. package/src/host/daemon/super-agent-tools/tools/discover-tools.js +67 -0
  20. package/src/host/daemon/super-agent-tools/tools/import-agent.js +2 -0
  21. package/src/host/daemon/super-agent-tools/tools/read-agent-memory.js +5 -4
  22. package/src/host/daemon/super-agent.js +15 -17
  23. package/src/interfaces/cli/commands/agent.js +4 -1
  24. package/src/interfaces/cli/commands/memory.js +9 -10
  25. package/src/interfaces/web/dist/assets/{index-CfWyjPBa.js → index-BV615I9p.js} +5 -5
  26. package/src/interfaces/web/dist/assets/{index-CfWyjPBa.js.map → index-BV615I9p.js.map} +1 -1
  27. package/src/interfaces/web/dist/index.html +1 -1
  28. package/src/interfaces/web/package-lock.json +100 -211
  29. package/src/interfaces/web/src/i18n/en.ts +6 -6
  30. package/src/interfaces/web/src/i18n/es.ts +6 -6
  31. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +1 -1
@@ -350,12 +350,17 @@ const TOOL_DEFINITIONS = [
350
350
  {
351
351
  name: "browser_navigate",
352
352
  category: "browser",
353
- description: "Navigate the headless browser to a URL. Launches Chromium lazily on first call.",
353
+ description: "Navigate the headless browser to a URL. Launches Chromium lazily on first call. Auto-retries and falls back to a more permissive wait strategy on redirect-heavy sites.",
354
354
  endpoint: { method: "POST", path: "/tools/browser/navigate" },
355
355
  parameters: {
356
356
  type: "object",
357
357
  properties: {
358
358
  url: { type: "string" },
359
+ wait_until: {
360
+ type: "string",
361
+ enum: ["load", "domcontentloaded", "networkidle0", "networkidle2"],
362
+ description: "Puppeteer wait strategy (default networkidle2). Use 'domcontentloaded' for slow/redirect-heavy sites; navigate auto-falls back to it on failure anyway.",
363
+ },
359
364
  launch_options: { type: "object", description: "Puppeteer launch overrides (headless, args, defaultViewport, etc.)." },
360
365
  allow_dangerous: { type: "boolean", description: "Allow dangerous launch args (--no-sandbox, --single-process, etc.)." },
361
366
  },
@@ -614,6 +619,8 @@ function makeInlineHandlers({ projects, registries }) {
614
619
  memory_list: async (body) => {
615
620
  const { default: fs } = await import("node:fs");
616
621
  const { default: path } = await import("node:path");
622
+ const { readAgents } = await import("../parser.js");
623
+ const { agentMemoryPath } = await import("../agent-memory.js");
617
624
  // Find the project
618
625
  const all = projects.list();
619
626
  let p = null;
@@ -624,15 +631,13 @@ function makeInlineHandlers({ projects, registries }) {
624
631
  }
625
632
  if (!p) p = projects.get(all.filter((x) => x.id !== 0)[0]?.id) || projects.get(0);
626
633
  if (!p) throw new Error("no project registered");
627
- const agentsDir = path.join(p.path, ".apc", "agents");
628
- if (!fs.existsSync(agentsDir)) return { agents_with_memory: [] };
629
- const result = fs.readdirSync(agentsDir).filter((slug) => {
630
- return fs.existsSync(path.join(agentsDir, slug, "memory.md"));
631
- }).map((slug) => {
632
- const memPath = path.join(agentsDir, slug, "memory.md");
634
+ const result = readAgents(p.path).map((agent) => {
635
+ const slug = agent.slug;
636
+ const memPath = agentMemoryPath(p, slug);
637
+ if (!fs.existsSync(memPath)) return null;
633
638
  const stat = fs.statSync(memPath);
634
639
  return { agent: slug, path: memPath, size: stat.size, mtime: stat.mtime };
635
- });
640
+ }).filter(Boolean);
636
641
  return { project: p.path, agents_with_memory: result };
637
642
  },
638
643
  };
@@ -45,26 +45,54 @@ function extractText(html) {
45
45
  .replace(/&lt;/g, "<")
46
46
  .replace(/&gt;/g, ">")
47
47
  .replace(/&quot;/g, '"')
48
- .replace(/&#039;/g, "'")
48
+ .replace(/&#0?39;/g, "'")
49
49
  .replace(/&nbsp;/g, " ")
50
+ // Generic numeric entities (decimal &#92; and hex &#x27;) DDG sprinkles into
51
+ // titles/snippets — decode so results read cleanly.
52
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
53
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
50
54
  .replace(/\s{2,}/g, " ")
51
55
  .trim();
52
56
  }
53
57
 
58
+ /**
59
+ * Unwrap DuckDuckGo's result redirect. DDG no longer exposes the target URL
60
+ * directly: every result href is `//duckduckgo.com/l/?uddg=<urlencoded real
61
+ * url>&rut=...`. We pull the `uddg` param out and decode it back to the real
62
+ * destination. Plain/protocol-relative URLs are normalized to https.
63
+ */
64
+ export function unwrapDdgUrl(href) {
65
+ if (!href) return href;
66
+ const m = href.match(/[?&]uddg=([^&]+)/);
67
+ if (m) {
68
+ try {
69
+ return decodeURIComponent(m[1].replace(/&amp;/g, "&"));
70
+ } catch {
71
+ /* fall through to raw href */
72
+ }
73
+ }
74
+ if (href.startsWith("//")) return "https:" + href;
75
+ return href;
76
+ }
77
+
54
78
  /** Parse DuckDuckGo HTML results */
55
- function parseDdgResults(html, limit) {
79
+ export function parseDdgResults(html, limit) {
56
80
  const results = [];
57
- // Match result blocks: each has a link (.result__a) and snippet (.result__snippet)
58
- const blockRe = /<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
81
+ // Match result blocks: each has a link (.result__a) and snippet (.result__snippet).
82
+ // Attribute order varies (rel/class/href), so don't assume class precedes href.
83
+ const blockRe = /<a[^>]+class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
59
84
  const snippetRe = /<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
60
85
 
61
86
  const links = [];
62
87
  let m;
63
88
  while ((m = blockRe.exec(html)) !== null && links.length < limit * 2) {
64
- const href = m[1];
89
+ // DDG wraps every external link in a //duckduckgo.com/l/?uddg= redirect —
90
+ // decode it to the real target instead of discarding it (the old code
91
+ // dropped everything containing "duckduckgo.com", yielding zero results).
92
+ const url = unwrapDdgUrl(m[1]);
65
93
  const title = extractText(m[2]).trim();
66
- if (href && title && !href.startsWith("//duckduckgo") && !href.includes("duckduckgo.com")) {
67
- links.push({ url: href, title });
94
+ if (url && title && !/^https?:\/\/(?:[a-z]+\.)?duckduckgo\.com\//i.test(url) && !url.startsWith("//duckduckgo")) {
95
+ links.push({ url, title });
68
96
  }
69
97
  }
70
98
 
@@ -14,6 +14,13 @@ import {
14
14
  restoreVaultAgent,
15
15
  ensureAgentDir,
16
16
  } from "../../../core/scaffold.js";
17
+ import {
18
+ ensureAgentRuntimeDir,
19
+ readAgentMemory,
20
+ writeAgentMemory,
21
+ agentMemoryPath,
22
+ legacyAgentMemoryPath,
23
+ } from "../../../core/agent-memory.js";
17
24
  import { agentToResponse } from "./shared.js";
18
25
 
19
26
  // Lowercase the patch keys we accept on the vault and turn skills/tools into
@@ -114,6 +121,7 @@ export function register(app, { projects, project }) {
114
121
  try {
115
122
  writeAgentFile(p.path, slug, vault.fields || {}, vault.body || "");
116
123
  ensureAgentDir(p.path, slug);
124
+ ensureAgentRuntimeDir(p, slug);
117
125
  projects.rebuild(p.id);
118
126
  res.status(201).json(agentToResponse(readAgents(p.path).find((a) => a.slug === slug)));
119
127
  } catch (e) {
@@ -133,10 +141,7 @@ export function register(app, { projects, project }) {
133
141
  const agents = readAgents(p.path);
134
142
  const a = agents.find((x) => x.slug === req.params.slug);
135
143
  if (!a) return res.status(404).json({ error: "agent not found" });
136
- const memPath = path.join(p.path, ".apc", "agents", a.slug, "memory.md");
137
- const memory = fs.existsSync(memPath)
138
- ? fs.readFileSync(memPath, "utf8")
139
- : "";
144
+ const memory = readAgentMemory(p, a.slug);
140
145
  res.json({ ...agentToResponse(a), memory, system: a.body || "" });
141
146
  });
142
147
 
@@ -163,6 +168,7 @@ export function register(app, { projects, project }) {
163
168
  Parent: parent || null,
164
169
  });
165
170
  ensureAgentDir(p.path, slug);
171
+ ensureAgentRuntimeDir(p, slug);
166
172
  projects.rebuild(p.id);
167
173
  const created = readAgents(p.path).find((a) => a.slug === slug);
168
174
  res.status(201).json(agentToResponse(created));
@@ -203,6 +209,7 @@ export function register(app, { projects, project }) {
203
209
  try {
204
210
  writeAgentFile(p.path, slug, fields, body);
205
211
  ensureAgentDir(p.path, slug);
212
+ ensureAgentRuntimeDir(p, slug);
206
213
  projects.rebuild(p.id);
207
214
  const updated = readAgents(p.path).find((a) => a.slug === slug);
208
215
  res.json(agentToResponse(updated));
@@ -211,18 +218,20 @@ export function register(app, { projects, project }) {
211
218
  }
212
219
  });
213
220
 
214
- // Delete an agent: removes .apc/agents/<slug>.md and its data dir.
221
+ // Delete an agent: removes .apc/agents/<slug>.md and runtime data dir.
215
222
  app.delete("/projects/:pid/agents/:slug", (req, res) => {
216
223
  const p = project(req, res);
217
224
  if (!p) return;
218
225
  const slug = req.params.slug;
219
226
  const file = path.join(p.path, ".apc", "agents", `${slug}.md`);
220
- const dir = path.join(p.path, ".apc", "agents", slug);
221
- if (!fs.existsSync(file) && !fs.existsSync(dir))
227
+ const runtimeDir = path.dirname(agentMemoryPath(p, slug));
228
+ const legacyDir = path.dirname(legacyAgentMemoryPath(p.path, slug));
229
+ if (!fs.existsSync(file) && !fs.existsSync(runtimeDir) && !fs.existsSync(legacyDir))
222
230
  return res.status(404).json({ error: "agent not found" });
223
231
  try {
224
232
  if (fs.existsSync(file)) fs.rmSync(file);
225
- if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
233
+ if (fs.existsSync(runtimeDir)) fs.rmSync(runtimeDir, { recursive: true, force: true });
234
+ if (fs.existsSync(legacyDir)) fs.rmSync(legacyDir, { recursive: true, force: true });
226
235
  projects.rebuild(p.id);
227
236
  res.json({ ok: true });
228
237
  } catch (e) {
@@ -257,15 +266,7 @@ export function register(app, { projects, project }) {
257
266
  app.get("/projects/:pid/agents/:slug/memory", (req, res) => {
258
267
  const p = project(req, res);
259
268
  if (!p) return;
260
- const memPath = path.join(
261
- p.path,
262
- ".apc",
263
- "agents",
264
- req.params.slug,
265
- "memory.md"
266
- );
267
- if (!fs.existsSync(memPath)) return res.json({ body: "" });
268
- res.json({ body: fs.readFileSync(memPath, "utf8") });
269
+ res.json({ body: readAgentMemory(p, req.params.slug) });
269
270
  });
270
271
 
271
272
  app.put("/projects/:pid/agents/:slug/memory", (req, res) => {
@@ -274,10 +275,7 @@ export function register(app, { projects, project }) {
274
275
  const { body } = req.body || {};
275
276
  if (typeof body !== "string")
276
277
  return res.status(400).json({ error: "body must be string" });
277
- const dir = path.join(p.path, ".apc", "agents", req.params.slug);
278
- fs.mkdirSync(path.join(dir, "sessions"), { recursive: true });
279
- const memPath = path.join(dir, "memory.md");
280
- fs.writeFileSync(memPath, body);
278
+ writeAgentMemory(p, req.params.slug, body);
281
279
  projects.rebuild(p.id);
282
280
  res.json({ ok: true, bytes: Buffer.byteLength(body, "utf8") });
283
281
  });
@@ -31,7 +31,7 @@ export function register(app, { projects, config }) {
31
31
  for (const p of targetProjects) {
32
32
  if (!p) continue;
33
33
 
34
- // 1) Session files in the repo (.apc/agents/<slug>/sessions/)
34
+ // 1) Legacy session files in the repo (.apc/agents/<slug>/sessions/)
35
35
  const sessionAgentsDir = path.join(p.path, ".apc", "agents");
36
36
  if (fs.existsSync(sessionAgentsDir)) {
37
37
  for (const slug of fs.readdirSync(sessionAgentsDir)) {
@@ -6,6 +6,8 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { appendErrorTrace, previewText } from "../../../core/logging.js";
9
+ import { readAgents } from "../../../core/parser.js";
10
+ import { agentMemoryPath } from "../../../core/agent-memory.js";
9
11
 
10
12
  export const nowIso = () =>
11
13
  new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -110,15 +112,10 @@ export function makeTopProjectResolver(projects) {
110
112
  }
111
113
 
112
114
  // Pick the memory.md to use when /memory is called without an agent ref.
113
- // Prefer the first .apc/agents/<slug>/memory.md; else .apc/memory.md.
115
+ // Prefer the first agent's runtime-local memory; else project-level .apc/memory.md.
114
116
  export function resolveMemoryPath(p) {
115
- const agentsDir = path.join(p.path, ".apc", "agents");
116
- if (fs.existsSync(agentsDir)) {
117
- const slugs = fs.readdirSync(agentsDir).filter((s) =>
118
- fs.statSync(path.join(agentsDir, s)).isDirectory()
119
- );
120
- if (slugs.length) return path.join(agentsDir, slugs[0], "memory.md");
121
- }
117
+ const firstAgent = readAgents(p.path)[0];
118
+ if (firstAgent) return agentMemoryPath(p, firstAgent.slug);
122
119
  return path.join(p.path, ".apc", "memory.md");
123
120
  }
124
121
 
@@ -28,6 +28,7 @@ import transcribeAudio from "./tools/transcribe-audio.js";
28
28
  import askQuestions from "./tools/ask-questions.js";
29
29
  import createTask from "./tools/create-task.js";
30
30
  import listTasks from "./tools/list-tasks.js";
31
+ import discoverTools from "./tools/discover-tools.js";
31
32
  import { createPermissionGuard } from "./helpers.js";
32
33
  import { buildBridgedTools, DEFAULT_CATEGORIES } from "./registry-bridge.js";
33
34
 
@@ -62,6 +63,7 @@ const NATIVE_TOOLS = [
62
63
  askQuestions,
63
64
  createTask,
64
65
  listTasks,
66
+ discoverTools,
65
67
  ];
66
68
 
67
69
  // Registry-backed bridges. Categories can be overridden per-process via env
@@ -78,67 +80,254 @@ const TOOLS = [...NATIVE_TOOLS, ...BRIDGED_TOOLS];
78
80
 
79
81
  export const TOOL_SCHEMAS = TOOLS.map((tool) => tool.schema);
80
82
 
81
- // "Core" tools always sent to the model. The rest are pulled in on-demand via
82
- // load_skill or by switching to a heavier channel. Picked to fit cheap cloud
83
- // tiers: full TOOL_SCHEMAS is ~22 KB / ~5.5 K tokens — too much when Groq
84
- // free tier caps you at 6-12 K TPM. CORE_TOOL_NAMES is ~3 KB / ~700 tokens.
85
- // See spec/done/backlog item 12 for the underlying motivation.
86
- const CORE_TOOL_NAMES = new Set([
87
- // Inventory the model NEEDS to call these to know what's there.
83
+ // ---------------------------------------------------------------------------
84
+ // Lazy tools: base set (always loaded) + on-demand set (revealed via
85
+ // discover_tools). Motivation: full TOOL_SCHEMAS is ~25 KB / ~6.3 K tokens —
86
+ // too much when Groq's free tier caps you at 6-12 K TPM. The base set is
87
+ // ~24 tools (the ones a Telegram chat actually reaches for); everything else
88
+ // (browser/Puppeteer, fetch, web_search, runtime delegation, voice, …) stays
89
+ // off the wire until the model asks for it with discover_tools().
90
+ // ---------------------------------------------------------------------------
91
+
92
+ // Always loaded on lightweight channels. Covers messages, files, memory,
93
+ // sessions, projects/inventory, basic shell, tasks, skills, and discovery.
94
+ export const BASE_TOOL_NAMES = new Set([
95
+ // Discovery — the entry point to everything not loaded here.
96
+ "discover_tools",
97
+ // Inventory — the model needs these to know what exists.
88
98
  "list_projects",
89
99
  "list_agents",
90
100
  "list_mcps",
91
101
  "list_skills",
92
- // Memory + identity — used during identity / config conversations.
102
+ "load_skill",
103
+ // Memory + identity.
93
104
  "read_agent_memory",
94
- "set_identity",
95
- // Self-memory: jot durable facts so they survive across sessions.
105
+ "read_self_memory",
96
106
  "remember",
97
- // Self-recall: "what did we do / last session" must work on every channel.
107
+ "set_identity",
108
+ // Sessions + messages (self-recall + channel history).
98
109
  "search_sessions",
99
- // Conversation control.
100
- "ask_questions",
101
- // On-demand expansion: this is how the model loads the rest of the surface.
102
- "load_skill",
103
- // Channels the user expects out of any super-agent turn.
110
+ "search_messages",
111
+ "tail_messages",
112
+ // Channels + conversation control + lightweight delegation.
104
113
  "send_telegram",
105
- // Lightweight delegation (no spawn).
114
+ "ask_questions",
106
115
  "call_agent",
107
- // Routine creation (very common ask via chat).
116
+ // Tasks (very common ask via chat).
108
117
  "create_task",
109
118
  "list_tasks",
119
+ // Files + basic shell — frequent enough on chat to keep hot.
120
+ "read_file",
121
+ "write_file",
122
+ "edit_file",
123
+ "list_files",
124
+ "search_files",
125
+ "run_shell",
110
126
  ]);
111
127
 
112
- export const CORE_TOOL_SCHEMAS = TOOLS
113
- .filter((t) => CORE_TOOL_NAMES.has(t.name))
128
+ // Channels that get the FULL registry up front (deliberate, user-picked model,
129
+ // no cheap-tier TPM cap). Everything else is a "lightweight" channel and starts
130
+ // on BASE_TOOL_NAMES with discover_tools to expand.
131
+ const FULL_CHANNELS = new Set(["routine", "api", "web", "code", "terminal"]);
132
+
133
+ // Category labels for grouping the discover_tools catalog. Native tools have no
134
+ // registry category, so we assign one here; bridged tools carry their own
135
+ // (browser/fetch/search/file) from registry-bridge.js.
136
+ const NATIVE_CATEGORY = {
137
+ discover_tools: "system",
138
+ set_permission_mode: "system",
139
+ list_projects: "inventory",
140
+ list_agents: "inventory",
141
+ list_vault_agents: "inventory",
142
+ list_mcps: "inventory",
143
+ list_skills: "inventory",
144
+ load_skill: "skills",
145
+ import_agent: "agents",
146
+ add_project: "projects",
147
+ call_agent: "agents",
148
+ call_runtime: "runtime",
149
+ call_mcp: "mcp",
150
+ read_agent_memory: "memory",
151
+ read_self_memory: "memory",
152
+ remember: "memory",
153
+ set_identity: "identity",
154
+ search_sessions: "sessions",
155
+ search_messages: "messages",
156
+ tail_messages: "messages",
157
+ send_telegram: "messages",
158
+ ask_questions: "conversation",
159
+ create_task: "tasks",
160
+ list_tasks: "tasks",
161
+ transcribe_audio: "voice",
162
+ read_file: "files",
163
+ write_file: "files",
164
+ edit_file: "files",
165
+ list_files: "files",
166
+ search_files: "files",
167
+ run_shell: "shell",
168
+ };
169
+
170
+ function categoryOf(tool) {
171
+ return tool.category || NATIVE_CATEGORY[tool.name] || "other";
172
+ }
173
+
174
+ function oneLine(desc = "") {
175
+ const flat = String(desc).replace(/\s+/g, " ").trim();
176
+ if (flat.length <= 120) return flat;
177
+ return flat.slice(0, 117).trimEnd() + "…";
178
+ }
179
+
180
+ // Static metadata index for every tool — name, schema, category, short blurb.
181
+ // Used by the per-turn tool session for the catalog and activation lookups.
182
+ const TOOL_META = TOOLS.map((t) => ({
183
+ name: t.name,
184
+ schema: t.schema,
185
+ category: categoryOf(t),
186
+ description: oneLine(t.schema?.function?.description),
187
+ }));
188
+ const META_BY_NAME = new Map(TOOL_META.map((m) => [m.name, m]));
189
+
190
+ export const BASE_TOOL_SCHEMAS = TOOLS
191
+ .filter((t) => BASE_TOOL_NAMES.has(t.name))
114
192
  .map((t) => t.schema);
115
193
 
194
+ // Back-compat alias: a few callers/tests historically referenced the "core"
195
+ // subset. The base set supersedes it.
196
+ export const CORE_TOOL_SCHEMAS = BASE_TOOL_SCHEMAS;
197
+
198
+ const schemaName = (s) => s?.function?.name || s?.name;
199
+
116
200
  /**
117
- * Choose the tool schema list for a given channel. Telegram / desktop / api
118
- * (chit-chat) get the "core" subset to stay under cheap-tier TPM limits;
119
- * routines get the full list because they're deliberate, scheduled, and the
120
- * user has chosen the model. Override with the explicit `full: true` opt.
201
+ * Choose the INITIAL tool schema list for a channel. Full channels get the
202
+ * whole registry; lightweight channels (telegram/desktop/deck/web_sidebar) get
203
+ * the base set and expand on demand via discover_tools. `full: true` forces the
204
+ * complete registry regardless of channel.
121
205
  */
122
206
  export function schemasForChannel(channel, { full = false } = {}) {
123
- if (full) return TOOL_SCHEMAS;
124
- // Full registry for deliberate, local surfaces running on a user-picked model
125
- // (not subject to the cheap-tier TPM caps that motivate the "core" subset):
126
- // routine — scheduled/autonomous · api — generic HTTP / `apx exec`
127
- // web — the big web chat (long-form workspace)
128
- // code — the web Code module (needs read/write/edit/run_shell/grep/glob)
129
- // terminal the `apx code`/`apx sys` TUI: same coding surface as web Code,
130
- // so it needs the full read/write/edit/run_shell registry too.
131
- if (
132
- channel === "routine" ||
133
- channel === "api" ||
134
- channel === "web" ||
135
- channel === "code" ||
136
- channel === "terminal"
137
- )
138
- return TOOL_SCHEMAS;
139
- // Lightweight surfaces stay on the small subset to fit cheap cloud TPM limits
140
- // and keep replies snappy: telegram, web_sidebar, deck, desktop.
141
- return CORE_TOOL_SCHEMAS;
207
+ if (full || FULL_CHANNELS.has(channel)) return TOOL_SCHEMAS;
208
+ return BASE_TOOL_SCHEMAS;
209
+ }
210
+
211
+ /**
212
+ * Per-turn tool session: tracks which tools are live, exposes the catalog of
213
+ * not-yet-loaded tools, and activates more on demand. The agent loop reads
214
+ * `pending` after each iteration and merges the new schemas into the live set,
215
+ * so activated tools become callable on the model's next step.
216
+ *
217
+ * `allowedTools` mirrors the role gate: "*" = unrestricted, [] = nothing, an
218
+ * array = allowlist. Both the initial set AND any activation respect it, so a
219
+ * limited sender can't discover its way past the gate.
220
+ */
221
+ export function createToolSession(channel, { full = false, allowedTools = "*" } = {}) {
222
+ const allowAll = allowedTools === "*";
223
+ const allow = allowAll || !Array.isArray(allowedTools) ? null : new Set(allowedTools);
224
+ const permits = (name) => allowAll || (allow ? allow.has(name) : false);
225
+
226
+ // If the role gate is "[]" (no tools), start empty and stay empty.
227
+ const gateEmpty = Array.isArray(allowedTools) && allowedTools.length === 0;
228
+
229
+ const initial = (gateEmpty ? [] : schemasForChannel(channel, { full }))
230
+ .filter((s) => permits(schemaName(s)));
231
+ const activeNames = new Set(initial.map(schemaName));
232
+
233
+ const session = {
234
+ channel,
235
+ initialSchemas: initial,
236
+ pending: [],
237
+ activeNames,
238
+
239
+ // Tools that exist but aren't loaded yet (and are permitted by the gate).
240
+ notLoaded() {
241
+ return TOOL_META.filter((m) => !activeNames.has(m.name) && permits(m.name));
242
+ },
243
+
244
+ // Catalog response for discover_tools() with no args: grouped by category.
245
+ catalogResponse() {
246
+ const pool = session.notLoaded();
247
+ const byCategory = {};
248
+ for (const m of pool) {
249
+ (byCategory[m.category] ||= []).push({ name: m.name, description: m.description });
250
+ }
251
+ return {
252
+ ok: true,
253
+ loaded_count: activeNames.size,
254
+ available_count: pool.length,
255
+ categories: byCategory,
256
+ hint:
257
+ "Activá lo que necesites con discover_tools({ category: \"<cat>\" }) o " +
258
+ "discover_tools({ names: [\"tool_a\", \"tool_b\"] }). Quedan disponibles desde tu próximo paso.",
259
+ };
260
+ },
261
+
262
+ // Activate by exact names and/or whole category. Pushes new schemas to
263
+ // `pending` for the agent loop to merge.
264
+ activate({ names, category } = {}) {
265
+ const targets = new Set();
266
+ if (Array.isArray(names)) for (const n of names) targets.add(n);
267
+ if (typeof category === "string" && category.trim()) {
268
+ const cat = category.trim();
269
+ for (const m of TOOL_META) if (m.category === cat) targets.add(m.name);
270
+ }
271
+
272
+ const activated = [];
273
+ const alreadyLoaded = [];
274
+ const unknown = [];
275
+ const denied = [];
276
+ for (const name of targets) {
277
+ const meta = META_BY_NAME.get(name);
278
+ if (!meta) { unknown.push(name); continue; }
279
+ if (!permits(name)) { denied.push(name); continue; }
280
+ if (activeNames.has(name)) { alreadyLoaded.push(name); continue; }
281
+ activeNames.add(name);
282
+ session.pending.push(meta.schema);
283
+ activated.push(name);
284
+ }
285
+
286
+ return {
287
+ ok: activated.length > 0 || (unknown.length === 0 && denied.length === 0),
288
+ activated,
289
+ already_loaded: alreadyLoaded,
290
+ ...(unknown.length ? { unknown } : {}),
291
+ ...(denied.length ? { denied } : {}),
292
+ note: activated.length
293
+ ? `Activé ${activated.length} tool(s): ${activated.join(", ")}. Ya las podés usar desde tu próximo paso.`
294
+ : "No se activó ninguna tool nueva.",
295
+ };
296
+ },
297
+ };
298
+
299
+ return session;
300
+ }
301
+
302
+ /**
303
+ * Compact "tools you can activate" block for the system prompt: instructions +
304
+ * just the NAMES (no schemas) of not-loaded tools, grouped by category. Returns
305
+ * "" when nothing is pending (full channels), so it's omitted from the prompt.
306
+ */
307
+ export function buildLazyToolsBlock(session) {
308
+ if (!session) return "";
309
+ const pool = session.notLoaded();
310
+ if (pool.length === 0) return "";
311
+
312
+ const byCategory = {};
313
+ for (const m of pool) (byCategory[m.category] ||= []).push(m.name);
314
+ const lines = Object.keys(byCategory)
315
+ .sort()
316
+ .map((cat) => `- ${cat}: ${byCategory[cat].join(", ")}`);
317
+
318
+ return [
319
+ "# Tools adicionales (activación on-demand)",
320
+ "Tenés las tools base siempre cargadas. Estas otras EXISTEN pero no están",
321
+ "cargadas (para ahorrar tokens). Activalas cuando las necesites con",
322
+ "discover_tools — quedan disponibles desde tu próximo paso:",
323
+ ' • discover_tools() → catálogo completo (nombre + descripción)',
324
+ ' • discover_tools({ category: "browser" }) → activa toda una categoría',
325
+ ' • discover_tools({ names: ["browser_navigate"] })→ activa tools puntuales',
326
+ "Si no encontrás la tool que buscás, llamá discover_tools() sin argumentos.",
327
+ "",
328
+ `Tools no cargadas (solo nombres, ${pool.length} en total):`,
329
+ ...lines,
330
+ ].join("\n");
142
331
  }
143
332
 
144
333
  export function makeToolHandlers(ctx) {
@@ -18,7 +18,29 @@
18
18
  // Net result: adding a tool = adding one entry to registry.js. No file in
19
19
  // super-agent-tools/tools/, no import in index.js.
20
20
 
21
+ import fs from "node:fs";
21
22
  import { TOOL_DEFINITIONS } from "../../../core/tools/registry.js";
23
+ import { TOKEN_PATH } from "../../../core/config.js";
24
+
25
+ // The bridge POSTs to the daemon's OWN HTTP server, which is behind the bearer
26
+ // auth middleware (see api/shared.js). Without a token every bridged tool call
27
+ // (web_search, browser_*, http_*, glob, grep) comes back 401 "unauthorized" —
28
+ // which is exactly what Roby hit. We read the daemon's master token from
29
+ // ~/.apx/daemon.token (the same file the CLI authenticates with) and cache it.
30
+ let cachedToken = null;
31
+ function daemonToken() {
32
+ if (cachedToken !== null) return cachedToken;
33
+ cachedToken =
34
+ process.env.APX_TOKEN ||
35
+ (() => {
36
+ try {
37
+ return fs.readFileSync(TOKEN_PATH, "utf8").trim();
38
+ } catch {
39
+ return "";
40
+ }
41
+ })();
42
+ return cachedToken;
43
+ }
22
44
 
23
45
  // Native handlers in super-agent-tools/tools/ that own these names. The bridge
24
46
  // MUST skip them or the registry version (HTTP roundtrip) would shadow the
@@ -56,9 +78,13 @@ function buildHandler(entry) {
56
78
  const method = String(entry.endpoint?.method || "POST").toUpperCase();
57
79
  let url = `http://127.0.0.1:${port}${entry.endpoint?.path || ""}`;
58
80
 
81
+ const token = daemonToken();
59
82
  const opts = {
60
83
  method,
61
- headers: { "content-type": "application/json" },
84
+ headers: {
85
+ "content-type": "application/json",
86
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
87
+ },
62
88
  };
63
89
 
64
90
  if (method === "GET" || method === "HEAD") {
@@ -114,6 +140,9 @@ export function buildBridgedTools(opts = {}) {
114
140
  .filter(e => e.endpoint?.path)
115
141
  .map(entry => ({
116
142
  name: entry.name,
143
+ // Carried through so the lazy-tools catalog can group on-demand tools by
144
+ // their registry category (browser/fetch/search/file) for discover_tools.
145
+ category: entry.category,
117
146
  schema: buildSchema(entry),
118
147
  makeHandler: buildHandler(entry),
119
148
  }));