@johpaz/hive-sdk 0.1.6 → 0.2.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.
Files changed (185) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/README.md +11 -1
  3. package/package.json +18 -2
  4. package/packages/core/src/agent/acceptance-checks.ts +9 -9
  5. package/packages/core/src/agent/agent-catalog.ts +3 -3
  6. package/packages/core/src/agent/agent-loop.ts +115 -26
  7. package/packages/core/src/agent/capability-search.ts +2 -2
  8. package/packages/core/src/agent/catalog-selector.ts +4 -4
  9. package/packages/core/src/agent/compaction.ts +10 -9
  10. package/packages/core/src/agent/context-compiler.ts +58 -34
  11. package/packages/core/src/agent/conversation-store.ts +31 -7
  12. package/packages/core/src/agent/curator.ts +4 -4
  13. package/packages/core/src/agent/delegation-runtime.ts +5 -5
  14. package/packages/core/src/agent/goal-runner.ts +9 -9
  15. package/packages/core/src/agent/index.ts +1 -0
  16. package/packages/core/src/agent/llm-client.ts +98 -37
  17. package/packages/core/src/agent/llm-providers/anthropic.ts +4 -4
  18. package/packages/core/src/agent/llm-providers/deepseek.ts +1 -1
  19. package/packages/core/src/agent/llm-providers/gemini.ts +4 -4
  20. package/packages/core/src/agent/llm-providers/groq.ts +1 -1
  21. package/packages/core/src/agent/llm-providers/hiveagents.ts +3 -3
  22. package/packages/core/src/agent/llm-providers/interface.ts +2 -2
  23. package/packages/core/src/agent/llm-providers/kimi.ts +1 -1
  24. package/packages/core/src/agent/llm-providers/minimax.ts +1 -1
  25. package/packages/core/src/agent/llm-providers/mistral.ts +1 -1
  26. package/packages/core/src/agent/llm-providers/modelscope.ts +1 -1
  27. package/packages/core/src/agent/llm-providers/nvidia.ts +1 -1
  28. package/packages/core/src/agent/llm-providers/ollama.ts +4 -4
  29. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +9 -5
  30. package/packages/core/src/agent/llm-providers/openai.ts +1 -1
  31. package/packages/core/src/agent/llm-providers/opencode-go.ts +1 -1
  32. package/packages/core/src/agent/llm-providers/openrouter.ts +1 -1
  33. package/packages/core/src/agent/llm-providers/qwen.ts +1 -1
  34. package/packages/core/src/agent/llm-providers/z-ai.ts +1 -1
  35. package/packages/core/src/agent/mcp-result-normalizer.ts +192 -0
  36. package/packages/core/src/agent/playbook-selector.ts +4 -4
  37. package/packages/core/src/agent/prompt-builder.ts +5 -5
  38. package/packages/core/src/agent/proof-packet.ts +5 -5
  39. package/packages/core/src/agent/providers/index.ts +4 -4
  40. package/packages/core/src/agent/realtime-providers/gemini-live.ts +238 -0
  41. package/packages/core/src/agent/realtime-providers/index.ts +29 -0
  42. package/packages/core/src/agent/realtime-providers/interface.ts +108 -0
  43. package/packages/core/src/agent/reflector.ts +6 -6
  44. package/packages/core/src/agent/run-store.ts +8 -8
  45. package/packages/core/src/agent/service.ts +10 -10
  46. package/packages/core/src/agent/skill-selector.ts +6 -6
  47. package/packages/core/src/agent/thread-id.ts +71 -0
  48. package/packages/core/src/agent/thread-store.ts +250 -0
  49. package/packages/core/src/agent/tool-selector.ts +7 -5
  50. package/packages/core/src/agent/tracer.ts +5 -5
  51. package/packages/core/src/api/createAgent.ts +1 -1
  52. package/packages/core/src/artifacts/store.ts +84 -3
  53. package/packages/core/src/canvas/emitter.ts +2 -2
  54. package/packages/core/src/channels/telegram.ts +1 -1
  55. package/packages/core/src/channels/webchat.ts +1 -1
  56. package/packages/core/src/config/loader.ts +14 -5
  57. package/packages/core/src/events/agent-bus.ts +3 -3
  58. package/packages/core/src/events/channel-narration.ts +3 -3
  59. package/packages/core/src/events/event-bus.ts +1 -1
  60. package/packages/core/src/events/narration.ts +3 -3
  61. package/packages/core/src/gateway/delegation-groups.ts +4 -4
  62. package/packages/core/src/gateway/durable-queue.ts +5 -5
  63. package/packages/core/src/gateway/job-store.ts +5 -5
  64. package/packages/core/src/gateway/notification-inbox.ts +2 -2
  65. package/packages/core/src/gateway/server.ts +2 -2
  66. package/packages/core/src/mcp/MCPClient.ts +3 -3
  67. package/packages/core/src/mcp/hot-reload.ts +5 -5
  68. package/packages/core/src/mcp/tool-sync.ts +5 -5
  69. package/packages/core/src/mcp/transports/index.ts +2 -2
  70. package/packages/core/src/mcp/transports/sse.ts +1 -1
  71. package/packages/core/src/models/index.ts +36 -0
  72. package/packages/core/src/multimodal/index.ts +2 -2
  73. package/packages/core/src/multimodal/vision-service.ts +6 -6
  74. package/packages/core/src/plugins/loader.ts +4 -1
  75. package/packages/core/src/resilience/circuit-breaker.ts +16 -5
  76. package/packages/core/src/resilience/retry.ts +1 -1
  77. package/packages/core/src/scheduler/CronScheduler.ts +6 -6
  78. package/packages/core/src/scheduler/integration.ts +9 -9
  79. package/packages/core/src/sessions/index.ts +266 -0
  80. package/packages/core/src/storage/bootstrap.ts +34 -8
  81. package/packages/core/src/storage/causal-events.ts +1 -1
  82. package/packages/core/src/storage/collections.ts +32 -1
  83. package/packages/core/src/storage/crypto.ts +16 -2
  84. package/packages/core/src/storage/hive.ts +1 -1
  85. package/packages/core/src/storage/hivedb.ts +10 -1
  86. package/packages/core/src/storage/onboarding.ts +6 -6
  87. package/packages/core/src/storage/reconcile.ts +5 -5
  88. package/packages/core/src/storage/seed.ts +103 -13
  89. package/packages/core/src/storage/usage.ts +3 -3
  90. package/packages/core/src/swarm/AgentExecutor.ts +2 -2
  91. package/packages/core/src/swarm/Coordinator.ts +8 -8
  92. package/packages/core/src/swarm/EventBridge.ts +2 -2
  93. package/packages/core/src/swarm/RoleSwarm.ts +234 -0
  94. package/packages/core/src/swarm/TaskGraph.ts +2 -2
  95. package/packages/core/src/swarm/index.ts +7 -0
  96. package/packages/core/src/swarm/presets/HiveLearnPreset.ts +2 -2
  97. package/packages/core/src/swarm/presets/ResearchPreset.ts +2 -2
  98. package/packages/core/src/swarm/strategies/ParallelStrategy.ts +1 -1
  99. package/packages/core/src/swarm/strategies/PriorityStrategy.ts +3 -3
  100. package/packages/core/src/tools/ToolExecutor.ts +7 -3
  101. package/packages/core/src/tools/core/index.ts +2 -2
  102. package/packages/core/src/tools/cron/index.ts +4 -4
  103. package/packages/core/src/tools/web/artifact-inspect.ts +2 -2
  104. package/packages/core/src/tools/web/artifact-read.ts +162 -0
  105. package/packages/core/src/tools/web/browser-backend.ts +141 -44
  106. package/packages/core/src/tools/web/browser-click.ts +2 -2
  107. package/packages/core/src/tools/web/browser-extract.ts +2 -2
  108. package/packages/core/src/tools/web/browser-navigate.ts +2 -2
  109. package/packages/core/src/tools/web/browser-screenshot.ts +12 -5
  110. package/packages/core/src/tools/web/browser-script.ts +2 -2
  111. package/packages/core/src/tools/web/browser-service.ts +63 -384
  112. package/packages/core/src/tools/web/browser-session.ts +125 -0
  113. package/packages/core/src/tools/web/browser-type.ts +2 -2
  114. package/packages/core/src/tools/web/browser-wait.ts +2 -2
  115. package/packages/core/src/tools/web/computer-use.ts +553 -0
  116. package/packages/core/src/tools/web/index.ts +8 -1
  117. package/packages/core/src/tools/web/webview-backend.ts +460 -21
  118. package/packages/core/src/utils/index.ts +1 -0
  119. package/packages/core/src/utils/logger.ts +12 -4
  120. package/packages/core/src/utils/redact-binary.ts +17 -0
  121. package/packages/core/src/utils/toon.ts +1 -1
  122. package/packages/core/src/voice/index.ts +6 -6
  123. package/bun.lock +0 -859
  124. package/bunfig.toml +0 -9
  125. package/docs/API-AGENTS.md +0 -367
  126. package/docs/API-CONTEXT-COMPILER.md +0 -249
  127. package/docs/API-DAG-SCHEDULER.md +0 -273
  128. package/docs/API-TOOLS-SKILLS-CHANNELS.md +0 -446
  129. package/docs/API-WORKERS-EVENTS.md +0 -299
  130. package/docs/HIVE-HARNESS.md +0 -113
  131. package/docs/INDEX.md +0 -190
  132. package/docs/TEMPLATE-HIVE-APP.md +0 -360
  133. package/packages/cli/package.json +0 -17
  134. package/packages/cli/src/commands/create-app.test.ts +0 -180
  135. package/packages/core/package.json +0 -70
  136. package/packages/core/src/api/createAgent.test.ts +0 -160
  137. package/packages/core/src/canvas/canvas.test.ts +0 -36
  138. package/packages/core/src/channels/channels.test.ts +0 -18
  139. package/packages/core/src/ethics/EthicsGuard.test.ts +0 -108
  140. package/packages/core/src/gateway/gateway.test.ts +0 -38
  141. package/packages/core/src/memory/Scratchpad.test.ts +0 -68
  142. package/packages/core/src/scheduler/scheduler.test.ts +0 -15
  143. package/packages/core/src/skills/skills.test.ts +0 -62
  144. package/packages/core/src/swarm/swarm.test.ts +0 -24
  145. package/packages/core/src/tool-runtime/tool-runtime.test.ts +0 -99
  146. package/packages/core/src/tools/ToolRegistry.test.ts +0 -98
  147. package/packages/core/src/tools/api/api-request.test.ts +0 -164
  148. package/packages/core/src/tools/web/browser-service.test.ts +0 -83
  149. package/packages/core/src/workers/workers.test.ts +0 -41
  150. package/scripts/bump-version.ts +0 -248
  151. package/scripts/generate-skill-bundle.ts +0 -108
  152. package/test/acceptance-checks.test.ts +0 -403
  153. package/test/agent-loop-terminal-synthesis.test.ts +0 -32
  154. package/test/browser-backend.test.ts +0 -308
  155. package/test/catalog-agents-stay-enabled.test.ts +0 -117
  156. package/test/causal-events.test.ts +0 -117
  157. package/test/compaction.test.ts +0 -105
  158. package/test/context-compiler.test.ts +0 -269
  159. package/test/curator.test.ts +0 -130
  160. package/test/durable-queue.test.ts +0 -114
  161. package/test/harness-barrel.test.ts +0 -64
  162. package/test/hive-helpers.test.ts +0 -130
  163. package/test/hivedb-search.test.ts +0 -189
  164. package/test/internal-turns.test.ts +0 -166
  165. package/test/job-idempotency.test.ts +0 -68
  166. package/test/job-retry-backoff.test.ts +0 -184
  167. package/test/job-store.test.ts +0 -381
  168. package/test/llm-retry.test.ts +0 -97
  169. package/test/memory-perf.test.ts +0 -774
  170. package/test/minimal-loadout.test.ts +0 -78
  171. package/test/model-catalog.test.ts +0 -105
  172. package/test/preload.ts +0 -12
  173. package/test/reflector.test.ts +0 -320
  174. package/test/retention-cap.test.ts +0 -91
  175. package/test/retired-capabilities-pruned.test.ts +0 -192
  176. package/test/run-store.test.ts +0 -355
  177. package/test/scratchpad.test.ts +0 -74
  178. package/test/secrets-durability.test.ts +0 -119
  179. package/test/seed-model-reseed.test.ts +0 -155
  180. package/test/setup-agent-seed.test.ts +0 -264
  181. package/test/tool-inventory.test.ts +0 -65
  182. package/test/tool-runtime.test.ts +0 -258
  183. package/test/tool-selector-runtime-tools.test.ts +0 -117
  184. package/test/toon.test.ts +0 -429
  185. package/tsconfig.json +0 -42
@@ -1,23 +1,28 @@
1
1
  /**
2
2
  * WebViewBackend — `BrowserBackend` sobre `Bun.WebView` (Bun >= 1.3).
3
3
  *
4
- * Corre in-process: no hay subproceso, ni instalación de ~75 MB, ni descarga de
5
- * Chrome. Un `evaluate` cuesta ~0.25 ms contra los ~68 ms de piso que tiene cada
6
- * invocación del CLI de agent-browser. A cambio necesita entorno gráfico, así
7
- * que no reemplaza a agent-browser en Docker ni en un servidor headless.
4
+ * Corre in-process: no hay subproceso, ni instalación, ni descarga de Chrome.
5
+ * Un `evaluate` cuesta ~0,3 ms. Con motor chrome corre headless —Bun lanza el
6
+ * navegador con `--headless`— así que también sirve en un servidor sin display;
7
+ * lo único que necesita es un Chromium instalado.
8
8
  *
9
- * Dos restricciones del motor mandan sobre el diseño de este archivo:
9
+ * Tres restricciones del motor mandan sobre el diseño de este archivo:
10
10
  *
11
- * 1. `Bun.WebView` acepta **una sola operación pendiente por vez**; dos
11
+ * 1. `Bun.WebView` acepta **una sola operación pendiente por vista**; dos
12
12
  * llamadas solapadas fallan con `ERR_INVALID_STATE: a simple operation is
13
- * already pending`. Todo pasa por una cola serializada.
14
- * 2. No expone árbol de accesibilidad. `snapshot()` se sintetiza recorriendo
15
- * el DOM, imitando el formato que emite agent-browser para que el modelo
16
- * vea lo mismo con cualquiera de los dos backends.
13
+ * already pending`. Todo pasa por una cola serializada. (El límite es por
14
+ * vista: varias instancias trabajan en paralelo.)
15
+ * 2. El perfil de Chrome es efímero y no configurable —`userDataDir` y `args`
16
+ * se ignoran—, así que las cookies se guardan y restauran a mano por CDP
17
+ * (`browser-session.ts`). Sin eso, cada reinicio perdería los logins.
18
+ * 3. El motor webkit (macOS) no expone CDP. Todo lo que use `cdp()` tiene que
19
+ * tener camino alternativo: por eso `snapshot()` cae al DOM y `screenshot()`
20
+ * ignora las opciones cuando no hay puente CDP.
17
21
  */
18
22
 
19
23
  import { logger } from "../../utils/logger.ts";
20
24
  import { resolveWebViewEngine, type BrowserBackend, type ScreenshotOptions, type SnapshotOptions, type WebViewEngine } from "./browser-backend.ts";
25
+ import { loadStoredCookies, sessionPersistenceEnabled, storeCookies } from "./browser-session.ts";
21
26
 
22
27
  const log = logger.child("webview-backend");
23
28
 
@@ -25,7 +30,47 @@ const log = logger.child("webview-backend");
25
30
  const SNAPSHOT_CHAR_LIMIT = 20_000;
26
31
 
27
32
  /**
28
- * Forma real de `Bun.WebView` en 1.3.14, verificada contra el prototipo.
33
+ * Espera antes de volcar las cookies al almacén. Un login son varias
34
+ * navegaciones seguidas (formulario, redirect, destino) y no tiene sentido
35
+ * guardar en cada una; con esta ventana se guarda una vez, al final.
36
+ */
37
+ const SESSION_SAVE_DEBOUNCE_MS = 3_000;
38
+
39
+ /**
40
+ * Tope para las operaciones que el motor puede dejar pendientes para siempre.
41
+ *
42
+ * `WebView.click()` sobre un selector que no existe nunca resuelve, y como la
43
+ * cola es de una sola vía, esa promesa cuelga **todo** el navegador: la
44
+ * siguiente tool espera detrás y el agente se queda mudo. Con el tope, la
45
+ * operación falla, la vista se descarta y la próxima abre una limpia.
46
+ */
47
+ const OPERACION_TIMEOUT_MS = 15_000;
48
+
49
+ /** Nombres de tecla de uso común → los que entiende `Bun.WebView`. */
50
+ const ALIAS_DE_TECLA: Record<string, string> = {
51
+ return: "Enter",
52
+ enter: "Enter",
53
+ esc: "Escape",
54
+ escape: "Escape",
55
+ del: "Delete",
56
+ delete: "Delete",
57
+ back: "Backspace",
58
+ backspace: "Backspace",
59
+ space: " ",
60
+ spacebar: " ",
61
+ tab: "Tab",
62
+ up: "ArrowUp",
63
+ down: "ArrowDown",
64
+ left: "ArrowLeft",
65
+ right: "ArrowRight",
66
+ pageup: "PageUp",
67
+ pagedown: "PageDown",
68
+ home: "Home",
69
+ end: "End",
70
+ };
71
+
72
+ /**
73
+ * Forma real de `Bun.WebView`, verificada contra el prototipo en 1.4.0.
29
74
  *
30
75
  * No se usan los tipos de `bun-types` a propósito: declaran `back()`/`forward()`
31
76
  * y el runtime expone `goBack()`/`goForward()`. Contra los tipos, la navegación
@@ -193,11 +238,151 @@ function buildSnapshotScript(options: Required<SnapshotOptions>): string {
193
238
  })()`;
194
239
  }
195
240
 
241
+ // ─── snapshot por árbol de accesibilidad (CDP) ────────────────────────────────
242
+
243
+ /**
244
+ * Un nodo tal como lo devuelve `Accessibility.getFullAXTree`.
245
+ *
246
+ * Se tipa a mano y flojo: es JSON crudo del protocolo, y Chrome agrega campos
247
+ * entre versiones. Sólo se declara lo que este archivo lee.
248
+ */
249
+ interface AxNode {
250
+ nodeId: string;
251
+ ignored?: boolean;
252
+ role?: { value?: string };
253
+ name?: { value?: string };
254
+ properties?: Array<{ name?: string; value?: { value?: unknown } }>;
255
+ childIds?: string[];
256
+ }
257
+
258
+ /** Roles que no aportan una línea: contenedores y nodos de texto interno. */
259
+ const AX_SKIP_ROLES = new Set([
260
+ "generic", "none", "presentation", "GenericContainer", "InlineTextBox",
261
+ "StaticText", "LineBreak", "RootWebArea", "WebArea", "Ignored",
262
+ ]);
263
+
264
+ /** Roles accionables: se emiten aunque no tengan nombre accesible. */
265
+ const AX_INTERACTIVE = new Set([
266
+ "link", "button", "textbox", "checkbox", "radio", "combobox", "option",
267
+ "searchbox", "menuitem", "menuitemcheckbox", "menuitemradio", "tab",
268
+ "switch", "slider", "spinbutton",
269
+ ]);
270
+
271
+ /** Propiedades del nodo que sí vale la pena mostrarle al modelo. */
272
+ const AX_SHOWN_PROPERTIES = ["level", "disabled", "checked", "expanded", "required", "selected"];
273
+
274
+ function axProperty(node: AxNode, name: string): unknown {
275
+ return node.properties?.find((p) => p.name === name)?.value?.value;
276
+ }
277
+
278
+ /**
279
+ * Convierte el árbol de accesibilidad en el mismo texto que produce el
280
+ * recorrido del DOM: `- rol "nombre" [attrs, ref=eN]`, con sangría por cada
281
+ * nivel realmente emitido.
282
+ *
283
+ * Es la fuente preferida porque es la que Chrome le da a un lector de pantalla:
284
+ * resuelve nombres accesibles, roles implícitos y contenido oculto sin que este
285
+ * archivo tenga que reimplementar esas reglas.
286
+ */
287
+ function formatAxTree(nodes: AxNode[], options: Required<SnapshotOptions>): string {
288
+ const byId = new Map<string, AxNode>();
289
+ const hijos = new Set<string>();
290
+ for (const node of nodes) {
291
+ byId.set(node.nodeId, node);
292
+ for (const child of node.childIds ?? []) hijos.add(child);
293
+ }
294
+ const raices = nodes.filter((n) => !hijos.has(n.nodeId));
295
+
296
+ const lines: string[] = [];
297
+ let refSeq = 0;
298
+ let largo = 0;
299
+ let truncado = false;
300
+
301
+ /** El nombre de un nodo de texto sale de sus hijos StaticText, como en el DOM. */
302
+ function nombreDe(node: AxNode): string {
303
+ const propio = (node.name?.value ?? "").toString().replace(/\s+/g, " ").trim();
304
+ if (propio) return propio;
305
+
306
+ let texto = "";
307
+ for (const id of node.childIds ?? []) {
308
+ const child = byId.get(id);
309
+ if (child?.role?.value === "StaticText") texto += " " + (child.name?.value ?? "");
310
+ }
311
+ return texto.replace(/\s+/g, " ").trim();
312
+ }
313
+
314
+ function atributos(node: AxNode, role: string): string[] {
315
+ const attrs: string[] = [];
316
+ for (const name of AX_SHOWN_PROPERTIES) {
317
+ const value = axProperty(node, name);
318
+ if (value === undefined || value === false || value === "false") continue;
319
+ // `heading` ya dice que es un encabezado; lo informativo es el nivel.
320
+ if (name === "level" && role !== "heading") continue;
321
+ attrs.push(value === true || value === "true" ? name : `${name}=${value}`);
322
+ }
323
+ return attrs;
324
+ }
325
+
326
+ function walk(node: AxNode, depth: number): void {
327
+ if (truncado) return;
328
+
329
+ const role = node.role?.value ?? "";
330
+ const saltear = node.ignored === true || AX_SKIP_ROLES.has(role) || !role;
331
+ const interactivo = AX_INTERACTIVE.has(role);
332
+ const name = saltear ? "" : nombreDe(node);
333
+
334
+ let emitir = !saltear && (Boolean(name) || interactivo);
335
+ if (options.interactiveOnly && !interactivo) emitir = false;
336
+ if (options.compact && !name && !interactivo) emitir = false;
337
+
338
+ if (emitir && depth < options.depth) {
339
+ const attrs = atributos(node, role);
340
+ if (name || interactivo) attrs.push("ref=e" + ++refSeq);
341
+ let label = "- " + role;
342
+ if (name) {
343
+ const shown = options.compact && name.length > 120 ? name.slice(0, 120) + "…" : name;
344
+ label += ' "' + shown.replace(/"/g, "'") + '"';
345
+ }
346
+ if (attrs.length) label += " [" + attrs.join(", ") + "]";
347
+
348
+ const line = " ".repeat(depth) + label;
349
+ if (largo + line.length > SNAPSHOT_CHAR_LIMIT) {
350
+ truncado = true;
351
+ return;
352
+ }
353
+ lines.push(line);
354
+ largo += line.length + 1;
355
+ } else {
356
+ emitir = false;
357
+ }
358
+
359
+ // Igual que en el DOM: si el nodo no emitió línea, sus hijos no bajan de
360
+ // nivel, así el árbol no se hunde por cada contenedor de maquetado.
361
+ const siguiente = emitir && depth < options.depth ? depth + 1 : depth;
362
+ for (const id of node.childIds ?? []) {
363
+ const child = byId.get(id);
364
+ if (child) walk(child, siguiente);
365
+ if (truncado) return;
366
+ }
367
+ }
368
+
369
+ for (const raiz of raices) walk(raiz, 0);
370
+ if (truncado) lines.push("… (snapshot truncado)");
371
+ return lines.join("\n");
372
+ }
373
+
196
374
  export class WebViewBackend implements BrowserBackend {
197
375
  private view: BunWebView | null = null;
198
376
  private _url = "";
199
377
  /** Cola de una sola vía: WebView rechaza operaciones solapadas. */
200
378
  private queue: Promise<unknown> = Promise.resolve();
379
+ /** Motor con el que se abrió la vista; sólo `chrome` tiene puente CDP. */
380
+ private engine: WebViewEngine | null = null;
381
+ /** `Accessibility.enable` se manda una vez por vista, no en cada snapshot. */
382
+ private axEnabled = false;
383
+ /** La sesión guardada se restaura una sola vez, antes de la primera página. */
384
+ private sessionRestored = false;
385
+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
201
386
 
202
387
  constructor(
203
388
  private readonly options: {
@@ -205,9 +390,16 @@ export class WebViewBackend implements BrowserBackend {
205
390
  height?: number;
206
391
  show?: boolean;
207
392
  engine?: WebViewEngine;
393
+ /** Guardar y restaurar cookies entre procesos. Default: sí. */
394
+ persistSession?: boolean;
208
395
  } = {},
209
396
  ) {}
210
397
 
398
+ /** ¿Hay puente CDP? El motor webkit de macOS no lo tiene. */
399
+ private get hasCdp(): boolean {
400
+ return (this.engine ?? this.options.engine ?? resolveWebViewEngine()) === "chrome";
401
+ }
402
+
211
403
  private ensureView(): BunWebView {
212
404
  if (this.view) return this.view;
213
405
 
@@ -220,7 +412,7 @@ export class WebViewBackend implements BrowserBackend {
220
412
  if (!engine) {
221
413
  throw new Error(
222
414
  "Bun.WebView no tiene motor utilizable: WebKit sólo existe en macOS y no se encontró Chrome. " +
223
- "Instalá Chrome o definí BUN_CHROME_PATH.",
415
+ "Instala Chrome o define BUN_CHROME_PATH.",
224
416
  );
225
417
  }
226
418
 
@@ -242,6 +434,7 @@ export class WebViewBackend implements BrowserBackend {
242
434
  width: this.options.width ?? 1280,
243
435
  height: this.options.height ?? 800,
244
436
  });
437
+ this.engine = engine;
245
438
  log.info(`✅ WebView abierto (motor: ${engine}, in-process)`);
246
439
  return this.view;
247
440
  }
@@ -257,6 +450,35 @@ export class WebViewBackend implements BrowserBackend {
257
450
  return next;
258
451
  }
259
452
 
453
+ /**
454
+ * Como `run`, pero con tope. Si se cumple, la vista queda descartada: su cola
455
+ * sigue esperando a una operación que no va a volver, así que lo único
456
+ * recuperable es abrir otra.
457
+ */
458
+ private async runVigilado<T>(
459
+ operation: (view: BunWebView) => Promise<T>,
460
+ quehacia: string,
461
+ ms = OPERACION_TIMEOUT_MS,
462
+ ): Promise<T> {
463
+ let timer: ReturnType<typeof setTimeout> | undefined;
464
+ const limite = new Promise<never>((_, reject) => {
465
+ timer = setTimeout(() => reject(new Error(`${quehacia} no respondió en ${ms}ms`)), ms);
466
+ (timer as { unref?: () => void }).unref?.();
467
+ });
468
+
469
+ try {
470
+ return await Promise.race([this.run(operation), limite]);
471
+ } catch (error) {
472
+ if ((error as Error).message.includes("no respondió")) {
473
+ log.warn(`${quehacia} colgó el WebView; se descarta la vista`);
474
+ this.close();
475
+ }
476
+ throw error;
477
+ } finally {
478
+ clearTimeout(timer);
479
+ }
480
+ }
481
+
260
482
  get url(): string {
261
483
  return this.view?.url || this._url;
262
484
  }
@@ -271,8 +493,101 @@ export class WebViewBackend implements BrowserBackend {
271
493
 
272
494
  async navigate(url: string): Promise<void> {
273
495
  const target = /^[a-z]+:/i.test(url) ? url : `https://${url}`;
496
+ await this.restoreSession();
274
497
  await this.run((view) => view.navigate(target));
275
498
  this._url = this.view?.url || target;
499
+ this.scheduleSessionSave();
500
+ }
501
+
502
+ // ─── sesión persistente ─────────────────────────────────────────────────────
503
+
504
+ /**
505
+ * Devuelve las cookies guardadas al navegador, una sola vez y antes de la
506
+ * primera página real.
507
+ *
508
+ * Hace falta un `about:blank` previo porque `cdp()` no tiene sesión hasta que
509
+ * la vista navegó alguna vez, y `Network.setCookies` tiene que correr antes
510
+ * de la página de destino para que el request ya salga autenticado.
511
+ */
512
+ private async restoreSession(): Promise<void> {
513
+ if (this.sessionRestored) return;
514
+ this.sessionRestored = true;
515
+ if (!sessionPersistenceEnabled(this.options.persistSession) || !this.hasCdp) return;
516
+
517
+ try {
518
+ const cookies = await loadStoredCookies();
519
+ if (!cookies.length) return;
520
+
521
+ await this.run(async (view) => {
522
+ await view.navigate("about:blank");
523
+ await view.cdp("Network.setCookies", { cookies });
524
+ });
525
+ log.info(`sesión del navegador restaurada (${cookies.length} cookies)`);
526
+ } catch (err) {
527
+ // Una sesión que no se pudo restaurar es un login perdido, no un fallo de
528
+ // la navegación: el agente sigue, sólo que deslogueado.
529
+ log.warn(`no se pudo restaurar la sesión: ${(err as Error).message}`);
530
+ }
531
+ }
532
+
533
+ /** Agenda un volcado de cookies; las llamadas seguidas se funden en una. */
534
+ private scheduleSessionSave(): void {
535
+ if (!sessionPersistenceEnabled(this.options.persistSession) || !this.hasCdp) return;
536
+ if (this.saveTimer) clearTimeout(this.saveTimer);
537
+
538
+ this.saveTimer = setTimeout(() => {
539
+ this.saveTimer = null;
540
+ void this.saveSession();
541
+ }, SESSION_SAVE_DEBOUNCE_MS);
542
+ // Un guardado pendiente no puede ser motivo para que el proceso no termine.
543
+ (this.saveTimer as { unref?: () => void }).unref?.();
544
+ }
545
+
546
+ /**
547
+ * Vuelca la sesión ya, sin esperar la ventana del debounce. Lo usan el
548
+ * apagado ordenado y los tests; el camino normal es `scheduleSessionSave()`.
549
+ */
550
+ /**
551
+ * Guarda la sesión ahora y falla si no puede.
552
+ *
553
+ * A diferencia del guardado periódico, aquí alguien pidió expresamente que la
554
+ * sesión quede en disco, así que callarse un fallo convierte el problema en
555
+ * "la cookie no aparece" mucho más tarde y en otro sitio — que es justo cómo
556
+ * se vivió desde una integración continua, sin forma de saber la causa.
557
+ */
558
+ async flushSession(): Promise<void> {
559
+ if (this.saveTimer) {
560
+ clearTimeout(this.saveTimer);
561
+ this.saveTimer = null;
562
+ }
563
+ await this.saveSession(true);
564
+ }
565
+
566
+ private async saveSession(estricto = false): Promise<void> {
567
+ const rendirse = (motivo: string) => {
568
+ if (estricto) throw new Error(`no se pudo guardar la sesión: ${motivo}`);
569
+ };
570
+ if (!this.view) return rendirse("no hay ninguna vista abierta");
571
+ // Apagada por configuración: no hay nada que guardar y tampoco un problema
572
+ // que contar. Sólo se protesta cuando debía guardar y no pudo.
573
+ if (!sessionPersistenceEnabled(this.options.persistSession)) return;
574
+ if (!this.hasCdp) {
575
+ return rendirse(`el motor «${this.engine ?? this.options.engine ?? "?"}» no expone CDP`);
576
+ }
577
+ try {
578
+ const res = (await this.run((view) => view.cdp("Network.getAllCookies"))) as {
579
+ cookies?: unknown[];
580
+ };
581
+ const cookies = res?.cookies ?? [];
582
+ const guardadas = await storeCookies(cookies);
583
+ if (guardadas) log.debug(`sesión del navegador guardada (${guardadas} cookies)`);
584
+ else if (estricto && cookies.length === 0) {
585
+ throw new Error("el navegador no reportó ninguna cookie");
586
+ }
587
+ } catch (err) {
588
+ if (estricto) throw err;
589
+ log.warn(`no se pudo guardar la sesión: ${(err as Error).message}`);
590
+ }
276
591
  }
277
592
 
278
593
  async evaluate<T = unknown>(script: string): Promise<T> {
@@ -286,7 +601,26 @@ export class WebViewBackend implements BrowserBackend {
286
601
  return (await this.run((view) => view.evaluate(wrapped))) as T;
287
602
  }
288
603
 
289
- async screenshot(_options?: ScreenshotOptions): Promise<string> {
604
+ async screenshot(options?: ScreenshotOptions): Promise<string> {
605
+ const format = options?.format ?? "png";
606
+ const clip = options?.clip;
607
+
608
+ // `view.screenshot()` sólo sabe hacer un PNG del viewport entero. Todo lo
609
+ // demás —jpeg, calidad, recorte— sale por CDP, que además evita mandarle al
610
+ // modelo un PNG de 1 MB cuando pidió un jpeg al 70%.
611
+ const necesitaCdp = format !== "png" || Boolean(clip) || options?.quality !== undefined;
612
+ if (necesitaCdp && this.hasCdp) {
613
+ const params: Record<string, unknown> = { format };
614
+ if (format !== "png" && options?.quality !== undefined) params.quality = options.quality;
615
+ if (clip) params.clip = clip;
616
+
617
+ const shot = (await this.run((view) => view.cdp("Page.captureScreenshot", params))) as {
618
+ data?: string;
619
+ };
620
+ if (shot?.data) return shot.data;
621
+ log.warn("CDP no devolvió imagen; se usa la captura nativa del WebView");
622
+ }
623
+
290
624
  const blob = await this.run((view) => view.screenshot());
291
625
  return Buffer.from(await blob.arrayBuffer()).toString("base64");
292
626
  }
@@ -318,16 +652,61 @@ export class WebViewBackend implements BrowserBackend {
318
652
  }
319
653
 
320
654
  async snapshot(options?: SnapshotOptions): Promise<string> {
321
- const script = buildSnapshotScript({
655
+ const opciones: Required<SnapshotOptions> = {
322
656
  compact: options?.compact !== false,
323
657
  depth: options?.depth ?? 12,
324
658
  interactiveOnly: options?.interactiveOnly ?? false,
325
- });
326
- return (await this.evaluate<string>(script)) || "";
659
+ };
660
+
661
+ // Primero el árbol de accesibilidad de verdad: es el que Chrome le entrega
662
+ // a un lector de pantalla, así que resuelve nombres accesibles, roles
663
+ // implícitos y contenido oculto mejor que cualquier recorrido del DOM.
664
+ if (this.hasCdp) {
665
+ try {
666
+ const tree = (await this.run(async (view) => {
667
+ if (!this.axEnabled) {
668
+ await view.cdp("Accessibility.enable");
669
+ this.axEnabled = true;
670
+ }
671
+ return view.cdp("Accessibility.getFullAXTree");
672
+ })) as { nodes?: AxNode[] };
673
+
674
+ // Un árbol vacío es una página vacía, no un fallo: se devuelve tal cual.
675
+ return formatAxTree(tree?.nodes ?? [], opciones);
676
+ } catch (err) {
677
+ log.warn(`árbol de accesibilidad no disponible, se recorre el DOM: ${(err as Error).message}`);
678
+ }
679
+ }
680
+
681
+ return (await this.evaluate<string>(buildSnapshotScript(opciones))) || "";
327
682
  }
328
683
 
329
- async click(selector: string, _options?: Record<string, unknown>): Promise<void> {
330
- await this.run((view) => view.click(selector));
684
+ /**
685
+ * Puente CDP crudo, por la misma cola que el resto. Sólo con motor chrome:
686
+ * el WebKit de macOS no lo tiene, y por eso todo lo que lo use necesita un
687
+ * camino alternativo.
688
+ */
689
+ async cdp<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T> {
690
+ if (!this.hasCdp) {
691
+ throw new Error("Bun.WebView con motor webkit no expone CDP");
692
+ }
693
+ return (await this.run((view) => view.cdp(method, params))) as T;
694
+ }
695
+
696
+ async click(selector: string, options?: Record<string, unknown>): Promise<void> {
697
+ // El motor no falla cuando el selector no existe: se queda esperando a que
698
+ // aparezca, para siempre. Comprobarlo acá convierte ese cuelgue en el error
699
+ // que la tool sabe reportar.
700
+ const existe = await this.evaluate<boolean>(
701
+ `!!document.querySelector(${JSON.stringify(selector)})`,
702
+ );
703
+ if (!existe) throw new Error(`click failed: elemento no encontrado: ${selector}`);
704
+
705
+ // `browser_click` acepta un timeout y hasta ahora nadie lo miraba.
706
+ const tope = typeof options?.timeout === "number" ? options.timeout : undefined;
707
+ await this.runVigilado((view) => view.click(selector), `click(${selector})`, tope);
708
+ // Un submit de login deja la cookie sin que haya un navigate() explícito.
709
+ this.scheduleSessionSave();
331
710
  }
332
711
 
333
712
  async type(text: string): Promise<void> {
@@ -366,6 +745,11 @@ export class WebViewBackend implements BrowserBackend {
366
745
  }
367
746
 
368
747
  async press(key: string, options?: { modifiers?: string[] }): Promise<void> {
748
+ // El motor sólo acepta su propia lista ("Enter", "Escape", "ArrowUp"...) y
749
+ // lanza con cualquier otro nombre. Los modelos y el código viejo escriben
750
+ // "Return", "Esc" o "Del", que son los nombres de toda la vida.
751
+ const tecla = ALIAS_DE_TECLA[key.toLowerCase()] ?? key;
752
+
369
753
  const modifiers: Record<string, boolean> = {};
370
754
  for (const modifier of options?.modifiers ?? []) {
371
755
  const normalized = modifier.toLowerCase();
@@ -374,7 +758,7 @@ export class WebViewBackend implements BrowserBackend {
374
758
  else if (normalized === "alt") modifiers.alt = true;
375
759
  else if (normalized === "meta" || normalized === "cmd") modifiers.meta = true;
376
760
  }
377
- await this.run((view) => view.press(key, modifiers));
761
+ await this.run((view) => view.press(tecla, modifiers));
378
762
  }
379
763
 
380
764
  async scroll(dx: number, dy: number): Promise<void> {
@@ -385,12 +769,59 @@ export class WebViewBackend implements BrowserBackend {
385
769
  await this.run((view) => view.scrollTo(selector));
386
770
  }
387
771
 
772
+ /**
773
+ * Ir y volver en el historial.
774
+ *
775
+ * `goBack()`/`goForward()` del motor **no resuelven nunca** con páginas HTTP
776
+ * reales —con `data:` URLs sí, que es por lo que pasaba desapercibido— y como
777
+ * la cola es de una sola vía, dejaban colgado todo el navegador. Con CDP se
778
+ * pide el historial y se salta a la entrada que toca, que además permite
779
+ * decir "no hay página anterior" en vez de esperar a que algo pase.
780
+ */
781
+ private async irEnHistorial(delta: -1 | 1): Promise<void> {
782
+ if (!this.hasCdp) {
783
+ await this.runVigilado(
784
+ (view) => (delta < 0 ? view.goBack() : view.goForward()),
785
+ delta < 0 ? "back()" : "forward()",
786
+ );
787
+ return;
788
+ }
789
+
790
+ const historial = await this.cdp<{
791
+ currentIndex: number;
792
+ entries: Array<{ id: number; url: string }>;
793
+ }>("Page.getNavigationHistory");
794
+
795
+ const destino = historial.entries[historial.currentIndex + delta];
796
+ if (!destino) {
797
+ throw new Error(delta < 0 ? "no hay página anterior" : "no hay página siguiente");
798
+ }
799
+
800
+ await this.cdp("Page.navigateToHistoryEntry", { entryId: destino.id });
801
+ this._url = destino.url;
802
+ await this.esperarCarga();
803
+ }
804
+
805
+ /**
806
+ * Espera a que la página termine de cargar. `navigateToHistoryEntry` vuelve
807
+ * apenas dispara la navegación, así que sin esto el `evaluate` siguiente lee
808
+ * la página vieja.
809
+ */
810
+ private async esperarCarga(ms = 5_000): Promise<void> {
811
+ const limite = Date.now() + ms;
812
+ while (Date.now() < limite) {
813
+ const listo = await this.evaluate<boolean>('document.readyState === "complete"').catch(() => false);
814
+ if (listo) return;
815
+ await Bun.sleep(25);
816
+ }
817
+ }
818
+
388
819
  async back(): Promise<void> {
389
- await this.run((view) => view.goBack());
820
+ await this.irEnHistorial(-1);
390
821
  }
391
822
 
392
823
  async forward(): Promise<void> {
393
- await this.run((view) => view.goForward());
824
+ await this.irEnHistorial(1);
394
825
  }
395
826
 
396
827
  async reload(): Promise<void> {
@@ -402,11 +833,19 @@ export class WebViewBackend implements BrowserBackend {
402
833
  }
403
834
 
404
835
  close(): void {
836
+ if (this.saveTimer) {
837
+ clearTimeout(this.saveTimer);
838
+ this.saveTimer = null;
839
+ }
405
840
  try {
406
841
  this.view?.close();
407
842
  } catch {
408
843
  /* ya cerrado */
409
844
  }
410
845
  this.view = null;
846
+ this.axEnabled = false;
847
+ // La cola encadenaba sobre la vista que se fue: si quedó una operación
848
+ // pendiente, todo lo que viniera detrás esperaría a un fantasma.
849
+ this.queue = Promise.resolve();
411
850
  }
412
851
  }
@@ -8,3 +8,4 @@ export { Benchmark } from "./benchmark.ts";
8
8
  export { getUserDate, getUserTime } from "./date.ts";
9
9
  export type { ToonStringifyResult } from "./toon.ts";
10
10
  export { estimateTokens, stringify, formatToolResult, formatMCPResponse, formatSkillOutput, formatContext, withToonFormat, reportCompression, getCompressionAnalysis } from "./toon.ts";
11
+ export { redactBinaryStrings } from "./redact-binary.ts";
@@ -313,11 +313,19 @@ export class Logger {
313
313
  }
314
314
 
315
315
  export class ChildLogger {
316
+ private parent: Logger;
317
+ private context: string;
318
+ private correlationContext: LogMeta;
319
+
316
320
  constructor(
317
- private parent: Logger,
318
- private context: string,
319
- private correlationContext: LogMeta = {}
320
- ) { }
321
+ parent: Logger,
322
+ context: string,
323
+ correlationContext: LogMeta = {}
324
+ ) {
325
+ this.parent = parent;
326
+ this.context = context;
327
+ this.correlationContext = correlationContext;
328
+ }
321
329
 
322
330
  private prefix(message: string): string {
323
331
  return `[${this.context}] ${message}`;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * redact-binary — last-line-of-defense redaction for stray base64 blobs.
3
+ *
4
+ * mcp-result-normalizer.ts (agent/mcp-result-normalizer.ts) is the real fix:
5
+ * it intercepts MCP image/audio/resource content blocks before they ever
6
+ * reach a string. This utility is the safety net for whatever slips past
7
+ * that — a producer that isn't MCP, a content shape normalizeMcpResult
8
+ * doesn't recognize, etc. Never rely on this alone to keep binaries out of
9
+ * the LLM context.
10
+ */
11
+
12
+ const DATA_URI_RE = /data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+/gi;
13
+ const LONG_BASE64_RE = /[A-Za-z0-9+/]{1000,}={0,2}/g;
14
+
15
+ export function redactBinaryStrings(text: string): string {
16
+ return text.replace(DATA_URI_RE, "[REDACTED_BINARY]").replace(LONG_BASE64_RE, "[REDACTED_BINARY]");
17
+ }
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { encode, decode, analyzeCompression } from 'toon-format-parser'
9
- import { logger } from './logger'
9
+ import { logger } from './logger.ts'
10
10
  import { recordToonSavings } from '../storage/usage.ts'
11
11
 
12
12
  const log = logger.child('toon')