@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.30.2",
3
+ "version": "1.31.1",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -59,7 +59,7 @@ apx agent vault list --all
59
59
  # Create a new template (writes ~/.apx/agents/<slug>.md)
60
60
  apx agent vault add reviewer \
61
61
  --role "Code reviewer" \
62
- --model claude-haiku-4-5 \
62
+ --model ollama:llama3.2:3b \
63
63
  --language es \
64
64
  --skills code-review,git \
65
65
  --description "Reviews PRs and pushes back on hand-wavy diffs."
@@ -5,7 +5,7 @@ description: How to create, configure, and use project agents in APX. Load when
5
5
 
6
6
  # apx-agent
7
7
 
8
- A project agent is a named persona inside an APC project. Canonical definition: `.apc/agents/<slug>.md` (flat file). `AGENTS.md` is auto-regenerated for discovery. Per-agent runtime data: `.apc/agents/<slug>/memory.md` (and optional `sessions/` only when using external runtimes that write APC session stubs APX-native sessions live under `~/.apx/projects/<id>/`).
8
+ A project agent is a named persona inside an APC project. Canonical definition: `.apc/agents/<slug>.md` (flat file). `AGENTS.md` is auto-regenerated for discovery. Per-agent runtime data (memory, conversations, sessions) lives under `~/.apx/projects/<apx_id>/agents/<slug>/` and is never committed. APX still reads legacy `.apc/agents/<slug>/memory.md` as a migration fallback only.
9
9
 
10
10
  ## Concrete CLI calls
11
11
 
@@ -13,10 +13,10 @@ A project agent is a named persona inside an APC project. Canonical definition:
13
13
  # List agents in a project
14
14
  apx agent list --project iacrmar
15
15
 
16
- # Create a new agent (writes .apc/agents/<slug>.md + regenerates AGENTS.md)
16
+ # Create a new agent (writes .apc/agents/<slug>.md, creates runtime dir, regenerates AGENTS.md)
17
17
  apx agent add reviewer \
18
18
  --role "Code reviewer" \
19
- --model claude-haiku-4-5 \
19
+ --model ollama:llama3.2:3b \
20
20
  --language es \
21
21
  --description "Reviews PRs and pushes back on hand-wavy diffs." \
22
22
  --tools read,write,run \
@@ -45,7 +45,7 @@ apx memory <slug> --project iacrmar --replace < file.md # full replace fro
45
45
  2. Description (from AGENTS.md).
46
46
  3. Role + Language fields.
47
47
  4. Invocation context: `engine | telegram | routine | runtime` — the channel calling the agent.
48
- 5. Memory: `.apc/agents/<slug>/memory.md` if it exists.
48
+ 5. Memory: `~/.apx/projects/<apx_id>/agents/<slug>/memory.md` if it exists, with legacy `.apc/agents/<slug>/memory.md` as a migration fallback.
49
49
  6. Skills declared in the agent's `Skills:` field, each loaded from `.apc/skills/<slug>.md` or the bundled set.
50
50
  7. The `apx` meta-skill (so the agent knows how to operate APX).
51
51
  8. ACTION_DISCIPLINE_RULES (fixed footer — anti-ghost, anti-disclaimer, action-first).
@@ -54,13 +54,13 @@ That's the prompt the engine sees on every `apx exec <agent>` or `apx chat <agen
54
54
 
55
55
  ## Models per agent
56
56
 
57
- Each agent can set `Model:` in its `AGENT.md` to override the global super-agent model. Useful when a particular agent should use a cheaper / smaller / specialized model.
57
+ Each agent can set `Model:` in its `AGENT.md` to override the global super-agent model. Leave it empty when the agent should follow the project/global default.
58
58
 
59
59
  ```markdown
60
60
  # .apc/agents/reviewer.md
61
61
  ---
62
62
  Role: Code reviewer
63
- Model: claude-haiku-4-5 ← this agent always uses Haiku, independent of super_agent.model
63
+ Model: ollama:llama3.2:3b ← this agent uses this model, independent of super_agent.model
64
64
  Language: es
65
65
  ---
66
66
  ```
@@ -57,7 +57,6 @@ The CLI calls `resolveProjectId()` which does fuzzy id-or-name-or-path matching.
57
57
  └── .apc/
58
58
  ├── project.json ← { apxId, name, ... }
59
59
  ├── agents/<slug>.md
60
- ├── agents/<slug>/memory.md
61
60
  ├── skills/<slug>.md or <slug>/SKILL.md
62
61
  ├── mcps.json ← shared MCPs (committed)
63
62
  ├── commands/ ← custom slash-commands
@@ -65,7 +64,7 @@ The CLI calls `resolveProjectId()` which does fuzzy id-or-name-or-path matching.
65
64
 
66
65
  ~/.apx/projects/<apxId>/ ← runtime state (never committed)
67
66
  ├── messages/YYYY-MM-DD.jsonl
68
- ├── agents/<slug>/{sessions/, conversations/}
67
+ ├── agents/<slug>/{memory.md, sessions/, conversations/}
69
68
  ├── routines.json
70
69
  ├── tasks/YYYY-MM.jsonl
71
70
  ├── artifacts/
@@ -271,6 +271,11 @@ export function buildSuperAgentSystem({
271
271
  // Pre-rendered "# Hilos activos en otros canales" block (recency-based
272
272
  // cross-channel awareness; see core/memory/active-threads.js). "" → omitted.
273
273
  activeThreadsBlock = "",
274
+ // Compact "# Tools adicionales (activación on-demand)" block: instructions +
275
+ // the NAMES (no schemas) of tools that exist but aren't loaded on this
276
+ // channel, so the model knows they're reachable via discover_tools without
277
+ // paying for their schemas. "" → omitted (full channels load everything).
278
+ lazyToolsBlock = "",
274
279
  }) {
275
280
  const sa = globalConfig.super_agent;
276
281
  const projectIndex = projects
@@ -305,6 +310,7 @@ export function buildSuperAgentSystem({
305
310
  projectIndex || "(no projects registered)",
306
311
  buildProjectAgentsBlock(channelMeta?.projectPath),
307
312
  buildSkillsCatalog(listSkills),
313
+ lazyToolsBlock,
308
314
  voiceModeBlock,
309
315
  systemSuffix,
310
316
  ]
@@ -178,6 +178,25 @@ export async function runAgent({
178
178
  })
179
179
  : rawHandlers;
180
180
 
181
+ // Lazy tools: when the super-agent runs a `discover_tools` activation, its
182
+ // handler pushes the newly-revealed schemas onto session.pending. We drain
183
+ // that queue into effectiveSchemas at the top of each iteration, so tools
184
+ // activated on step N are callable from step N+1. No session → no-op.
185
+ const toolSession = toolHandlerCtx?.toolSession || null;
186
+ const drainPendingTools = () => {
187
+ if (!toolSession || toolSession.pending.length === 0) return;
188
+ const seen = new Set(
189
+ effectiveSchemas.map((s) => s?.function?.name || s?.name)
190
+ );
191
+ const additions = [];
192
+ for (const sc of toolSession.pending) {
193
+ const n = sc?.function?.name || sc?.name;
194
+ if (n && !seen.has(n)) { additions.push(sc); seen.add(n); }
195
+ }
196
+ toolSession.pending = [];
197
+ if (additions.length > 0) effectiveSchemas = effectiveSchemas.concat(additions);
198
+ };
199
+
181
200
  const conversation = [...previousMessages, { role: "user", content: prompt }];
182
201
  const trace = [];
183
202
  let totalUsage = { input_tokens: 0, output_tokens: 0 };
@@ -236,6 +255,8 @@ export async function runAgent({
236
255
  };
237
256
 
238
257
  for (let iter = 0; iter < maxIters; iter++) {
258
+ // Merge any tools activated via discover_tools on the previous iteration.
259
+ drainPendingTools();
239
260
  await emitProgress(onEvent, { type: "model_start", iteration: iter + 1, model: activeModel });
240
261
  // Force a tool call on iter 0 ONLY when the user message looks like a real
241
262
  // action request ("listame…", "mandá…", "buscá…"). For chit-chat ("hola",
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // This is distinct from:
4
4
  // - identity.json → who Roby is (name, personality, owner)
5
- // - project agents' .apc/agents/<slug>/memory.md → per-agent, per-project
5
+ // - project agents' ~/.apx/projects/<apx_id>/agents/<slug>/memory.md → per-agent, per-project
6
6
  // - sessions → raw transcripts of past work (search_sessions)
7
7
  //
8
8
  // It is a single free-form markdown file at ~/.apx/memory.md that Roby keeps
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { projectStorageRoot } from "./config.js";
4
+ import { getOrCreateApxId } from "./scaffold.js";
5
+
6
+ const EMPTY_MEMORY = (slug) =>
7
+ `# Memory — ${slug}\n\n` +
8
+ `## Identity\n- \n\n` +
9
+ `## Long-term facts\n- \n\n` +
10
+ `## Recent context\n- \n`;
11
+
12
+ export function agentRuntimeDir(projectOrRoot, slug) {
13
+ const storagePath =
14
+ typeof projectOrRoot === "object" && projectOrRoot?.storagePath
15
+ ? projectOrRoot.storagePath
16
+ : null;
17
+ const root =
18
+ typeof projectOrRoot === "string"
19
+ ? projectOrRoot
20
+ : projectOrRoot?.path;
21
+ const base = storagePath || projectStorageRoot(getOrCreateApxId(root));
22
+ return path.join(base, "agents", slug);
23
+ }
24
+
25
+ export function agentMemoryPath(projectOrRoot, slug) {
26
+ return path.join(agentRuntimeDir(projectOrRoot, slug), "memory.md");
27
+ }
28
+
29
+ export function legacyAgentMemoryPath(projectRoot, slug) {
30
+ return path.join(projectRoot, ".apc", "agents", slug, "memory.md");
31
+ }
32
+
33
+ export function ensureAgentRuntimeDir(projectOrRoot, slug, { createMemory = false } = {}) {
34
+ const dir = agentRuntimeDir(projectOrRoot, slug);
35
+ fs.mkdirSync(dir, { recursive: true });
36
+ if (createMemory) {
37
+ const memory = path.join(dir, "memory.md");
38
+ if (!fs.existsSync(memory)) fs.writeFileSync(memory, EMPTY_MEMORY(slug));
39
+ }
40
+ return dir;
41
+ }
42
+
43
+ export function readAgentMemory(projectOrRoot, slug) {
44
+ const primary = agentMemoryPath(projectOrRoot, slug);
45
+ if (fs.existsSync(primary)) return fs.readFileSync(primary, "utf8");
46
+
47
+ const root =
48
+ typeof projectOrRoot === "string"
49
+ ? projectOrRoot
50
+ : projectOrRoot?.path;
51
+ if (root) {
52
+ const legacy = legacyAgentMemoryPath(root, slug);
53
+ if (fs.existsSync(legacy)) return fs.readFileSync(legacy, "utf8");
54
+ }
55
+
56
+ return "";
57
+ }
58
+
59
+ export function writeAgentMemory(projectOrRoot, slug, body) {
60
+ ensureAgentRuntimeDir(projectOrRoot, slug);
61
+ const memory = agentMemoryPath(projectOrRoot, slug);
62
+ fs.writeFileSync(memory, body);
63
+ return memory;
64
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { readAgentMemory } from "./agent-memory.js";
3
4
 
4
5
  // ---------------------------------------------------------------------------
5
6
  // Anti-ghost-response rules injected into every agent system prompt.
@@ -62,8 +63,8 @@ export function buildAgentSystem(project, agent, {
62
63
 
63
64
  parts.push(buildInvocationContext({ invocation, runtime, channel, caller, routine }));
64
65
 
65
- const memPath = path.join(project.path, ".apc", "agents", agent.slug, "memory.md");
66
- if (fs.existsSync(memPath)) parts.push("## Memory\n" + fs.readFileSync(memPath, "utf8"));
66
+ const memory = readAgentMemory(project, agent.slug);
67
+ if (memory) parts.push("## Memory\n" + memory);
67
68
 
68
69
  const apxSkill = path.join(project.path, ".apc", "skills", "apx.md");
69
70
  if (fs.existsSync(apxSkill)) parts.push("## APX\n" + fs.readFileSync(apxSkill, "utf8"));
@@ -325,24 +325,60 @@ const AGENTS_MD_TEMPLATE = `# AGENTS.md
325
325
  <!-- Hard constraints: what agents must always / never do here. -->
326
326
  `;
327
327
 
328
- const APC_GITIGNORE = `# APC runtime data — never in the repository
329
- # Chat conversations and runtime sessions belong in ~/.apx/projects/<id>/
330
- agents/*/sessions/
331
- agents/*/conversations/
328
+ const APC_GITIGNORE = `# APC repository-safe context only.
329
+ # Runtime state belongs in ~/.apx/projects/<id>/, not in .apc/.
330
+
331
+ # Legacy per-agent runtime dirs (agent definitions are flat: agents/<slug>.md)
332
+ agents/*/
333
+
334
+ # Runtime sessions / conversations / messages
332
335
  sessions/
333
336
  conversations/
334
337
  messages/
335
338
  chats/
339
+ threads/
340
+ transcripts/
341
+ runs/
342
+
343
+ # Runtime memory, indexes, databases, and caches
344
+ memory.local.md
345
+ auto-memory.md
336
346
  cache/
337
347
  tmp/
338
348
  private/
339
349
  secrets/
350
+ *.db
351
+ *.db-*
352
+ *.sqlite
353
+ *.sqlite3
354
+ project.db
355
+ memory.db
356
+ memory-index.jsonl
357
+ memory-cursor.json
358
+
359
+ # Local config and secrets
360
+ .env
340
361
  *.local.json
341
362
  *.secret.json
342
363
  *.env
343
364
  *.env.*
344
- project.db
365
+ *.key
366
+ *.pem
367
+ *.p12
368
+ *.crt
369
+ credentials.json
370
+ service-account*.json
371
+ token*.json
372
+ mcps.local.json
373
+ config.local.json
374
+
375
+ # Scratch planning state
376
+ plans/scratch/
377
+ plans/*.local.md
378
+
379
+ # Migration marker and OS noise
345
380
  migrate.md
381
+ .DS_Store
346
382
  `;
347
383
 
348
384
  function nowIso() {
@@ -448,19 +484,8 @@ export function initApf(directory, { name } = {}) {
448
484
  }
449
485
 
450
486
  export function ensureAgentDir(root, slug) {
451
- const dir = path.join(root, ".apc", "agents", slug);
452
- fs.mkdirSync(dir, { recursive: true });
453
- const memory = path.join(dir, "memory.md");
454
- if (!fs.existsSync(memory)) {
455
- fs.writeFileSync(
456
- memory,
457
- `# Memory — ${slug}\n\n` +
458
- `## Identity\n- \n\n` +
459
- `## Long-term facts\n- \n\n` +
460
- `## Recent context\n- \n`
461
- );
462
- }
463
- return dir;
487
+ fs.mkdirSync(path.join(root, ".apc", "agents"), { recursive: true });
488
+ return path.join(root, ".apc", "agents");
464
489
  }
465
490
 
466
491
  // Write .apc/agents/<slug>.md — the canonical agent definition file.
@@ -181,19 +181,104 @@ async function ensureBrowser({ launch_options, allow_dangerous } = {}) {
181
181
  return _page;
182
182
  }
183
183
 
184
+ // ---------------------------------------------------------------------------
185
+ // Context-destruction resilience
186
+ // ---------------------------------------------------------------------------
187
+
188
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
189
+
190
+ // Puppeteer throws this family of errors when an action (evaluate / get_text /
191
+ // click / …) runs while the page is navigating, redirecting, or reloading —
192
+ // the frame's JS execution context is torn down mid-call. Redirect-heavy sites
193
+ // (ESPN geo/consent hops, login walls) trigger it constantly. These are
194
+ // transient: waiting for the navigation to settle and retrying succeeds.
195
+ const CONTEXT_DESTROYED_RE =
196
+ /Execution context was destroyed|Cannot find context|Execution context is not available|detached frame|frame (?:was|got) detached|Target closed|Session closed|Protocol error.*(?:Runtime|Page)\./i;
197
+
198
+ export function isContextDestroyed(err) {
199
+ return CONTEXT_DESTROYED_RE.test(String(err?.message || err));
200
+ }
201
+
202
+ // Let any in-flight navigation finish so the next action sees a stable context.
203
+ async function settlePage(page, { timeout = 5000 } = {}) {
204
+ if (!page || page.isClosed()) return;
205
+ await page.waitForNetworkIdle({ idleTime: 500, timeout }).catch(() => {});
206
+ }
207
+
208
+ // Run a page action, retrying on a transient "Execution context was destroyed"
209
+ // (and friends): wait `delayMs`, let the page settle, try again — up to
210
+ // `retries` extra attempts. Non-context errors bubble immediately.
211
+ export async function withContextRetry(fn, { retries = 2, delayMs = 1500 } = {}) {
212
+ let lastErr;
213
+ for (let attempt = 0; attempt <= retries; attempt++) {
214
+ try {
215
+ return await fn();
216
+ } catch (e) {
217
+ lastErr = e;
218
+ if (!isContextDestroyed(e) || attempt === retries) throw e;
219
+ await sleep(delayMs);
220
+ await settlePage(_page);
221
+ }
222
+ }
223
+ throw lastErr;
224
+ }
225
+
226
+ // Convenience: ensure the browser/page, then run an action under context-retry.
227
+ async function onPage(fn) {
228
+ const page = await ensureBrowser();
229
+ return withContextRetry(() => fn(page));
230
+ }
231
+
184
232
  // ---------------------------------------------------------------------------
185
233
  // Tool implementations
186
234
  // ---------------------------------------------------------------------------
187
235
 
188
- export async function browser_navigate({ url, launch_options, allow_dangerous } = {}) {
236
+ export async function browser_navigate({ url, launch_options, allow_dangerous, wait_until } = {}) {
189
237
  if (!url) throw new Error("url required");
190
238
  const page = await ensureBrowser({ launch_options, allow_dangerous });
191
- const response = await page.goto(url, { waitUntil: "networkidle2", timeout: 30000 });
239
+
240
+ const go = async (waitUntil) => {
241
+ const response = await page.goto(url, { waitUntil, timeout: 30000 });
242
+ // Some sites fire a client-side redirect/reload right after the initial
243
+ // load. Give it a beat to settle so the execution context is stable for
244
+ // the caller's NEXT tool (get_text/evaluate) instead of being destroyed
245
+ // out from under it.
246
+ await settlePage(page, { timeout: 3000 });
247
+ return response;
248
+ };
249
+
250
+ // Preferred wait strategy: networkidle2 (or caller override). On a
251
+ // context-destroyed / timeout / navigation error, fall back to the much more
252
+ // permissive "domcontentloaded" which resolves as soon as the DOM is parsed,
253
+ // before late redirects/XHR can tear the context down.
254
+ const preferred = wait_until || "networkidle2";
255
+ let response;
256
+ try {
257
+ response = await go(preferred);
258
+ } catch (e) {
259
+ const recoverable =
260
+ isContextDestroyed(e) ||
261
+ /TimeoutError|Navigation timeout|net::ERR_ABORTED|frame was detached/i.test(String(e?.message || e));
262
+ if (!recoverable || preferred === "domcontentloaded") throw e;
263
+ await sleep(1500);
264
+ response = await go("domcontentloaded");
265
+ }
266
+
267
+ // title() evaluates in-page, so it can itself throw if a redirect is still
268
+ // in flight — read it defensively (url() is sync and always safe).
269
+ let title = "";
270
+ try {
271
+ title = await withContextRetry(() => page.title(), { retries: 1, delayMs: 1000 });
272
+ } catch {
273
+ title = "";
274
+ }
275
+
192
276
  return {
193
277
  ok: true,
194
278
  url: page.url(),
195
279
  status: response?.status() ?? null,
196
- title: await page.title(),
280
+ title,
281
+ wait_until: response ? (preferred) : null,
197
282
  };
198
283
  }
199
284
 
@@ -206,12 +291,13 @@ export async function browser_screenshot({ selector, full_page = false, width, h
206
291
  });
207
292
  }
208
293
 
209
- const target = selector ? await page.$(selector) : null;
210
- if (selector && !target) throw new Error(`Element not found: ${selector}`);
211
-
212
- const buf = target
213
- ? await target.screenshot({ type: "png", encoding: "base64" })
214
- : await page.screenshot({ type: "png", encoding: "base64", fullPage: !!full_page });
294
+ const buf = await withContextRetry(async () => {
295
+ const target = selector ? await page.$(selector) : null;
296
+ if (selector && !target) throw new Error(`Element not found: ${selector}`);
297
+ return target
298
+ ? await target.screenshot({ type: "png", encoding: "base64" })
299
+ : await page.screenshot({ type: "png", encoding: "base64", fullPage: !!full_page });
300
+ });
215
301
 
216
302
  const size = Buffer.from(String(buf), "base64").length;
217
303
  if (size > MAX_SCREENSHOT_BYTES) {
@@ -248,50 +334,56 @@ export async function browser_screenshot({ selector, full_page = false, width, h
248
334
 
249
335
  export async function browser_click({ selector } = {}) {
250
336
  if (!selector) throw new Error("selector required");
251
- const page = await ensureBrowser();
252
- await page.waitForSelector(selector, { timeout: 10000 });
253
- await page.click(selector);
254
- await page.waitForNetworkIdle({ timeout: 5000 }).catch(() => {});
255
- return { ok: true, selector, url: page.url() };
337
+ return onPage(async (page) => {
338
+ await page.waitForSelector(selector, { timeout: 10000 });
339
+ await page.click(selector);
340
+ await page.waitForNetworkIdle({ timeout: 5000 }).catch(() => {});
341
+ return { ok: true, selector, url: page.url() };
342
+ });
256
343
  }
257
344
 
258
345
  export async function browser_type({ selector, text, clear = true } = {}) {
259
346
  if (!selector) throw new Error("selector required");
260
347
  if (text === undefined) throw new Error("text required");
261
- const page = await ensureBrowser();
262
- await page.waitForSelector(selector, { timeout: 10000 });
263
- await page.focus(selector);
264
- if (clear) {
265
- await page.keyboard.down("Control");
266
- await page.keyboard.press("KeyA");
267
- await page.keyboard.up("Control");
268
- await page.keyboard.press("Backspace");
269
- }
270
- await page.type(selector, String(text), { delay: 20 });
271
- return { ok: true, selector, typed: String(text).length };
348
+ return onPage(async (page) => {
349
+ await page.waitForSelector(selector, { timeout: 10000 });
350
+ await page.focus(selector);
351
+ if (clear) {
352
+ await page.keyboard.down("Control");
353
+ await page.keyboard.press("KeyA");
354
+ await page.keyboard.up("Control");
355
+ await page.keyboard.press("Backspace");
356
+ }
357
+ await page.type(selector, String(text), { delay: 20 });
358
+ return { ok: true, selector, typed: String(text).length };
359
+ });
272
360
  }
273
361
 
274
362
  export async function browser_select({ selector, value } = {}) {
275
363
  if (!selector) throw new Error("selector required");
276
364
  if (value === undefined) throw new Error("value required");
277
- const page = await ensureBrowser();
278
- await page.waitForSelector(selector, { timeout: 10000 });
279
- await page.select(selector, String(value));
280
- return { ok: true, selector, value };
365
+ return onPage(async (page) => {
366
+ await page.waitForSelector(selector, { timeout: 10000 });
367
+ await page.select(selector, String(value));
368
+ return { ok: true, selector, value };
369
+ });
281
370
  }
282
371
 
283
372
  export async function browser_hover({ selector } = {}) {
284
373
  if (!selector) throw new Error("selector required");
285
- const page = await ensureBrowser();
286
- await page.waitForSelector(selector, { timeout: 10000 });
287
- await page.hover(selector);
288
- return { ok: true, selector };
374
+ return onPage(async (page) => {
375
+ await page.waitForSelector(selector, { timeout: 10000 });
376
+ await page.hover(selector);
377
+ return { ok: true, selector };
378
+ });
289
379
  }
290
380
 
291
381
  export async function browser_evaluate({ code } = {}) {
292
382
  if (!code) throw new Error("code required");
293
- const page = await ensureBrowser();
383
+ return onPage((page) => evaluateOnPage(page, code));
384
+ }
294
385
 
386
+ async function evaluateOnPage(page, code) {
295
387
  // Install in-page console capture so evaluated code's logs come back.
296
388
  await page.evaluate(() => {
297
389
  window.__apxHelper = { logs: [], orig: { ...console } };
@@ -325,54 +417,56 @@ export async function browser_evaluate({ code } = {}) {
325
417
  }
326
418
 
327
419
  export async function browser_get_text({ selector } = {}) {
328
- const page = await ensureBrowser();
329
- const text = await page.evaluate((sel) => {
330
- const root = sel ? document.querySelector(sel) : document.body;
331
- if (!root) return null;
332
- const clone = root.cloneNode(true);
333
- for (const tag of ["script", "style", "nav", "header", "footer", "noscript"]) {
334
- for (const el of clone.querySelectorAll(tag)) el.remove();
335
- }
336
- return clone.innerText || clone.textContent || "";
337
- }, selector ?? null);
338
- if (text === null) throw new Error(`Element not found: ${selector}`);
339
- const cleaned = text.replace(/\n{3,}/g, "\n\n").trim();
340
- return {
341
- ok: true,
342
- url: page.url(),
343
- title: await page.title(),
344
- text: cleaned,
345
- chars: cleaned.length,
346
- };
420
+ return onPage(async (page) => {
421
+ const text = await page.evaluate((sel) => {
422
+ const root = sel ? document.querySelector(sel) : document.body;
423
+ if (!root) return null;
424
+ const clone = root.cloneNode(true);
425
+ for (const tag of ["script", "style", "nav", "header", "footer", "noscript"]) {
426
+ for (const el of clone.querySelectorAll(tag)) el.remove();
427
+ }
428
+ return clone.innerText || clone.textContent || "";
429
+ }, selector ?? null);
430
+ if (text === null) throw new Error(`Element not found: ${selector}`);
431
+ const cleaned = text.replace(/\n{3,}/g, "\n\n").trim();
432
+ let title = "";
433
+ try { title = await page.title(); } catch { title = ""; }
434
+ return {
435
+ ok: true,
436
+ url: page.url(),
437
+ title,
438
+ text: cleaned,
439
+ chars: cleaned.length,
440
+ };
441
+ });
347
442
  }
348
443
 
349
444
  export async function browser_get_content({ selector } = {}) {
350
- const page = await ensureBrowser();
351
- let content = selector
352
- ? await page.$eval(selector, el => el.innerHTML).catch(() => null)
353
- : await page.content();
354
- if (content === null) throw new Error(`Element not found: ${selector}`);
355
-
356
- let truncated = false;
357
- if (content.length > MAX_CONTENT_CHARS) {
358
- content = content.slice(0, MAX_CONTENT_CHARS) + "\n[TRUNCATED]";
359
- truncated = true;
360
- }
361
- return {
362
- ok: true,
363
- url: page.url(),
364
- selector: selector ?? null,
365
- chars: content.length,
366
- truncated,
367
- html: content,
368
- };
445
+ return onPage(async (page) => {
446
+ let content = selector
447
+ ? await page.$eval(selector, el => el.innerHTML).catch(() => null)
448
+ : await page.content();
449
+ if (content === null) throw new Error(`Element not found: ${selector}`);
450
+
451
+ let truncated = false;
452
+ if (content.length > MAX_CONTENT_CHARS) {
453
+ content = content.slice(0, MAX_CONTENT_CHARS) + "\n[TRUNCATED]";
454
+ truncated = true;
455
+ }
456
+ return {
457
+ ok: true,
458
+ url: page.url(),
459
+ selector: selector ?? null,
460
+ chars: content.length,
461
+ truncated,
462
+ html: content,
463
+ };
464
+ });
369
465
  }
370
466
 
371
467
  export async function browser_wait_for_selector({ selector, timeout = 30000 } = {}) {
372
468
  if (!selector) throw new Error("selector required");
373
- const page = await ensureBrowser();
374
- await page.waitForSelector(selector, { timeout });
375
- return { ok: true, selector };
469
+ return onPage((page) => page.waitForSelector(selector, { timeout }).then(() => ({ ok: true, selector })));
376
470
  }
377
471
 
378
472
  export async function browser_close() {