@vellumai/assistant 0.12.4 → 0.12.5-dev.202609251017.e467a6c

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 (281) hide show
  1. package/docs/architecture/memory.md +9 -1
  2. package/docs/architecture/scheduling.md +13 -5
  3. package/docs/desktop-browser-cli.md +3 -2
  4. package/docs/guardian-request-flow.md +1 -1
  5. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  6. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  11. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +2 -4
  12. package/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  13. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  14. package/openapi.yaml +152 -3
  15. package/package.json +1 -1
  16. package/src/__tests__/agent-loop.test.ts +76 -0
  17. package/src/__tests__/anthropic-error-formatting.test.ts +9 -0
  18. package/src/__tests__/background-shell-bash.test.ts +34 -0
  19. package/src/__tests__/background-shell-host-bash.test.ts +98 -1
  20. package/src/__tests__/background-tool-registry.test.ts +39 -0
  21. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  22. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  23. package/src/__tests__/client-os-metadata-persistence.test.ts +4 -20
  24. package/src/__tests__/conversation-agent-loop.test.ts +529 -1
  25. package/src/__tests__/conversation-error.test.ts +18 -0
  26. package/src/__tests__/conversation-pairing.test.ts +69 -0
  27. package/src/__tests__/conversation-queue.test.ts +257 -4
  28. package/src/__tests__/conversation-runtime-assembly.test.ts +27 -0
  29. package/src/__tests__/emit-signal-routing-intent.test.ts +62 -0
  30. package/src/__tests__/evict-conversations-for-reload.test.ts +21 -0
  31. package/src/__tests__/gemini-provider.test.ts +9 -0
  32. package/src/__tests__/host-cu-proxy.test.ts +79 -0
  33. package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
  34. package/src/__tests__/introduction-card-resolver.test.ts +53 -8
  35. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  36. package/src/__tests__/non-member-access-request.test.ts +48 -0
  37. package/src/__tests__/notification-events-source-context-probe.test.ts +48 -1
  38. package/src/__tests__/notification-platform-adapter.test.ts +97 -0
  39. package/src/__tests__/notification-vellum-adapter.test.ts +103 -3
  40. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  41. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  42. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  43. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -0
  44. package/src/__tests__/scheduler-result-notification.test.ts +521 -7
  45. package/src/__tests__/scheduler-wake.test.ts +1 -0
  46. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  47. package/src/__tests__/subagent-cron-run-attribution.test.ts +41 -2
  48. package/src/__tests__/subagent-manager-notify.test.ts +48 -0
  49. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -6
  50. package/src/__tests__/usage-attribution.test.ts +31 -0
  51. package/src/__tests__/wake-schedule-escalation.test.ts +1 -0
  52. package/src/agent/loop.ts +23 -0
  53. package/src/api/ask-question-tool.ts +50 -0
  54. package/src/api/events/generation-handoff.ts +4 -0
  55. package/src/api/events/message-complete.ts +4 -0
  56. package/src/api/events/tool-result.ts +11 -0
  57. package/src/api/index.ts +11 -0
  58. package/src/api/requests/guardian-actions.test.ts +45 -0
  59. package/src/api/requests/guardian-actions.ts +20 -0
  60. package/src/api/responses/conversation-message.ts +4 -0
  61. package/src/api/responses/home.ts +27 -0
  62. package/src/approvals/guardian-decision-primitive.ts +6 -4
  63. package/src/approvals/guardian-request-resolvers.ts +35 -30
  64. package/src/avatar/notification-avatar.test.ts +42 -34
  65. package/src/avatar/notification-avatar.ts +1 -0
  66. package/src/calls/__tests__/call-controller.test.ts +18 -0
  67. package/src/calls/__tests__/voice-control-protocol.test.ts +6 -1
  68. package/src/calls/__tests__/voice-leg-coordinator.test.ts +70 -0
  69. package/src/calls/__tests__/voice-session-bridge.test.ts +116 -4
  70. package/src/calls/__tests__/voice-triage-escalate.test.ts +77 -11
  71. package/src/calls/call-controller.ts +3 -1
  72. package/src/calls/voice-control-protocol.ts +12 -4
  73. package/src/calls/voice-escalation-judge.ts +2 -2
  74. package/src/calls/voice-leg-coordinator.ts +23 -5
  75. package/src/calls/voice-screen-annotation.ts +44 -0
  76. package/src/calls/voice-session-bridge.ts +63 -20
  77. package/src/calls/voice-triage-escalate.ts +82 -27
  78. package/src/cli/commands/__tests__/conversations-clear.test.ts +156 -0
  79. package/src/cli/commands/conversations.help.ts +7 -2
  80. package/src/cli/commands/conversations.ts +36 -30
  81. package/src/cli/commands/oauth/index.help.ts +8 -2
  82. package/src/cli/commands/oauth/request.test.ts +32 -0
  83. package/src/cli/commands/platform/index.help.ts +2 -1
  84. package/src/cli/commands/platform/index.ts +8 -2
  85. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +60 -0
  86. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  87. package/src/cli/lib/bundled-marketplace.json +35 -0
  88. package/src/cli/lib/bundled-plugin-packages.json +21 -0
  89. package/src/cli/lib/plugin-catalog-cache.ts +17 -2
  90. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  91. package/src/config/__tests__/default-profile-catalog.test.ts +28 -0
  92. package/src/config/__tests__/platform-identity.test.ts +59 -0
  93. package/src/config/__tests__/profile-order.test.ts +19 -0
  94. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  95. package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
  96. package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
  97. package/src/config/bundled-skills/screen-annotation/SKILL.md +41 -29
  98. package/src/config/bundled-skills/screen-annotation/TOOLS.json +5 -5
  99. package/src/config/call-site-defaults.ts +6 -0
  100. package/src/config/default-profile-catalog.ts +30 -1
  101. package/src/config/default-profile-names.ts +36 -2
  102. package/src/config/feature-flag-registry.json +9 -1
  103. package/src/config/platform-identity.ts +22 -5
  104. package/src/config/profile-order.ts +7 -1
  105. package/src/config/schemas/call-site-catalog.ts +7 -0
  106. package/src/config/schemas/llm.ts +21 -5
  107. package/src/config/sync-gated-profiles.ts +96 -44
  108. package/src/daemon/__tests__/abort-null-controller.test.ts +135 -1
  109. package/src/daemon/__tests__/auto-profile-router.test.ts +380 -0
  110. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +16 -0
  111. package/src/daemon/activity-trail.test.ts +206 -0
  112. package/src/daemon/activity-trail.ts +190 -0
  113. package/src/daemon/auto-profile-router.ts +431 -0
  114. package/src/daemon/computer-use-observation.test.ts +81 -0
  115. package/src/daemon/computer-use-observation.ts +23 -0
  116. package/src/daemon/conversation-agent-loop-handlers.ts +11 -0
  117. package/src/daemon/conversation-agent-loop.ts +157 -32
  118. package/src/daemon/conversation-error.ts +19 -0
  119. package/src/daemon/conversation-lifecycle.ts +70 -7
  120. package/src/daemon/conversation-media-retry.ts +1 -2
  121. package/src/daemon/conversation-messaging.ts +31 -2
  122. package/src/daemon/conversation-process.ts +107 -16
  123. package/src/daemon/conversation-queue-manager.ts +7 -0
  124. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  125. package/src/daemon/conversation-turn-finalize.ts +14 -0
  126. package/src/daemon/conversation.ts +25 -6
  127. package/src/daemon/event-loop-watchdog.test.ts +201 -2
  128. package/src/daemon/event-loop-watchdog.ts +182 -6
  129. package/src/daemon/host-cu-proxy.ts +34 -3
  130. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  131. package/src/daemon/pkb-reminder-builder.ts +1 -1
  132. package/src/daemon/wake-conversation-ops.ts +4 -2
  133. package/src/desktop/desktop-computer-use.test.ts +14 -4
  134. package/src/home/feed-types.ts +2 -0
  135. package/src/inbound/platform-callback-registration.ts +19 -6
  136. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  137. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
  138. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +48 -1
  139. package/src/live-voice/__tests__/live-voice-vad.test.ts +34 -2
  140. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  141. package/src/live-voice/continuation-judge.ts +1 -1
  142. package/src/live-voice/live-voice-session.ts +25 -19
  143. package/src/live-voice/protocol.ts +16 -5
  144. package/src/messaging/providers/discord/withdraw.ts +4 -5
  145. package/src/messaging/providers/gmail/client.ts +4 -0
  146. package/src/messaging/providers/slack/withdraw.ts +5 -5
  147. package/src/messaging/providers/telegram-bot/withdraw.ts +4 -5
  148. package/src/monitoring/__tests__/proc-fixtures.ts +28 -0
  149. package/src/monitoring/__tests__/proc-wait-state.test.ts +232 -0
  150. package/src/monitoring/__tests__/process-usage.test.ts +243 -0
  151. package/src/monitoring/proc-wait-state.ts +366 -0
  152. package/src/monitoring/process-memory.ts +4 -3
  153. package/src/monitoring/process-usage.ts +215 -0
  154. package/src/monitoring/resource-sample-types.ts +8 -0
  155. package/src/monitoring/resource-sampler.ts +17 -2
  156. package/src/monitoring/stall-capture.ts +12 -0
  157. package/src/notifications/README.md +88 -9
  158. package/src/notifications/__tests__/assistant-reply-producer.test.ts +390 -119
  159. package/src/notifications/__tests__/background-result-producer.test.ts +1272 -0
  160. package/src/notifications/__tests__/broadcaster.test.ts +292 -1
  161. package/src/notifications/__tests__/completion-policy.test.ts +243 -0
  162. package/src/notifications/__tests__/decision-engine.test.ts +51 -4
  163. package/src/notifications/__tests__/has-pending-background-work.test.ts +281 -0
  164. package/src/notifications/__tests__/home-feed-side-effect.test.ts +161 -3
  165. package/src/notifications/__tests__/resolve-visible-in-source.test.ts +110 -1
  166. package/src/notifications/__tests__/schedule-result-producer.test.ts +11 -4
  167. package/src/notifications/adapters/macos.ts +24 -12
  168. package/src/notifications/adapters/platform.ts +16 -5
  169. package/src/notifications/assistant-reply-producer.ts +96 -71
  170. package/src/notifications/background-result-producer.ts +369 -0
  171. package/src/notifications/broadcaster.ts +28 -8
  172. package/src/notifications/completion-policy.ts +115 -0
  173. package/src/notifications/completion-work.ts +46 -0
  174. package/src/notifications/conversation-pairing.ts +32 -13
  175. package/src/notifications/decision-engine.ts +18 -24
  176. package/src/notifications/emit-signal.ts +16 -1
  177. package/src/notifications/events-store.ts +4 -0
  178. package/src/notifications/has-pending-background-work.ts +91 -0
  179. package/src/notifications/home-feed-mirror.ts +44 -0
  180. package/src/notifications/home-feed-side-effect.ts +54 -28
  181. package/src/notifications/resolve-visible-in-source.ts +56 -2
  182. package/src/notifications/result-output.ts +154 -0
  183. package/src/notifications/schedule-result-producer.ts +6 -187
  184. package/src/notifications/types.ts +2 -0
  185. package/src/oauth/AGENTS.md +9 -1
  186. package/src/oauth/byo-connection.test.ts +28 -0
  187. package/src/persistence/__tests__/recent-conversation-messages.test.ts +41 -0
  188. package/src/persistence/conversation-crud.ts +26 -0
  189. package/src/persistence/conversation-types.ts +15 -37
  190. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +85 -14
  191. package/src/persistence/jobs-store.ts +229 -0
  192. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  193. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  194. package/src/persistence/steps.ts +12 -0
  195. package/src/platform/sync-avatar.test.ts +29 -0
  196. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  197. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  198. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  199. package/src/plugins/defaults/memory/__tests__/turn-retrieval.test.ts +21 -0
  200. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  201. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  202. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +7 -3
  203. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  204. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  205. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  206. package/src/plugins/defaults/memory/turn-retrieval.ts +8 -0
  207. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +24 -0
  208. package/src/plugins/defaults/memory/v3/injector.ts +4 -4
  209. package/src/plugins/types.ts +2 -0
  210. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  211. package/src/providers/anthropic/client.ts +4 -0
  212. package/src/providers/gemini/client.ts +7 -1
  213. package/src/providers/inference/auth.ts +25 -0
  214. package/src/providers/inference/profile-probe.ts +1 -0
  215. package/src/{calls/__tests__/typesafe-noul.test.ts → providers/jev/ask.test.ts} +4 -4
  216. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +67 -34
  217. package/src/providers/openai/__tests__/api-error-normalization.test.ts +12 -0
  218. package/src/providers/openai/api-error-normalization.ts +4 -0
  219. package/src/providers/retry.ts +2 -0
  220. package/src/providers/search-provider-catalog.ts +10 -0
  221. package/src/providers/types.ts +7 -0
  222. package/src/providers/usage-tracking.ts +5 -3
  223. package/src/runtime/__tests__/agent-wake.test.ts +308 -0
  224. package/src/runtime/access-request-helper.ts +16 -9
  225. package/src/runtime/agent-wake-queue.ts +52 -0
  226. package/src/runtime/agent-wake.ts +111 -18
  227. package/src/runtime/channel-approval-types.ts +6 -38
  228. package/src/runtime/channel-approvals.ts +4 -5
  229. package/src/runtime/http-server.ts +4 -1
  230. package/src/runtime/introduction-policy.test.ts +17 -1
  231. package/src/runtime/introduction-policy.ts +25 -7
  232. package/src/runtime/migrations/vbundle-builder.ts +9 -0
  233. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +21 -1
  234. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  235. package/src/runtime/routes/__tests__/oauth-proxy-routes.test.ts +61 -3
  236. package/src/runtime/routes/auto-profile-routes.ts +83 -0
  237. package/src/runtime/routes/conversation-management-routes.ts +1 -3
  238. package/src/runtime/routes/conversation-routes.ts +9 -0
  239. package/src/runtime/routes/guardian-action-routes.ts +8 -23
  240. package/src/runtime/routes/host-cu-routes.ts +3 -1
  241. package/src/runtime/routes/index.ts +2 -0
  242. package/src/runtime/routes/inference-profiles-routes.ts +5 -2
  243. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  244. package/src/runtime/routes/migration-routes.ts +8 -2
  245. package/src/runtime/routes/oauth-proxy-passthrough.test.ts +32 -0
  246. package/src/runtime/routes/platform-routes.ts +6 -1
  247. package/src/runtime/routes/user-route-dispatcher.ts +1 -2
  248. package/src/runtime/web-presence.ts +3 -4
  249. package/src/schedule/schedule-store.ts +10 -0
  250. package/src/schedule/scheduler.ts +92 -24
  251. package/src/subagent/manager.ts +32 -12
  252. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  253. package/src/telemetry/telemetry-wire-source.json +1 -1
  254. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  255. package/src/tools/background-tool-registry.ts +30 -3
  256. package/src/tools/browser/cdp-client/local-cdp-client.ts +1 -2
  257. package/src/tools/host-terminal/host-shell.ts +7 -0
  258. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  259. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  260. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  261. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  262. package/src/tools/network/web-fetch.ts +30 -9
  263. package/src/tools/network/web-search.ts +235 -4
  264. package/src/tools/skills/load.ts +13 -17
  265. package/src/tools/skills/scaffold-managed.ts +86 -71
  266. package/src/tools/terminal/shell-launch.test.ts +27 -1
  267. package/src/tools/terminal/shell.ts +4 -0
  268. package/src/tools/workspace-tools/loader.ts +1 -2
  269. package/src/usage/attribution.ts +18 -1
  270. package/src/util/abort-reasons.ts +2 -0
  271. package/src/util/errors.ts +1 -0
  272. package/src/util/oauth-request-body.test.ts +5 -1
  273. package/src/util/provider-error-patterns.ts +11 -0
  274. package/src/util/single-flight.test.ts +6 -0
  275. package/src/util/single-flight.ts +2 -0
  276. package/src/util/worker-pipe.test.ts +56 -10
  277. package/src/util/worker-pipe.ts +9 -4
  278. package/src/workspace/git-service.ts +6 -1
  279. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  280. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  281. package/src/workspace/migrations/registry.ts +2 -0
@@ -12,6 +12,7 @@ let mockKeenableSecureKey: string | undefined;
12
12
  let mockFastcrwSecureKey: string | undefined;
13
13
  let mockSearxngSecureKey: string | undefined;
14
14
  let mockTinyfishSecureKey: string | undefined;
15
+ let mockExaSecureKey: string | undefined;
15
16
  let mockManagedSearchProxyResult: any;
16
17
  let mockManagedSearchAvailable = true;
17
18
  let mockManagedSearchProxyCalls: Array<{
@@ -65,6 +66,9 @@ mock.module("../../../security/secure-keys.js", () => ({
65
66
  if (provider === "tinyfish") {
66
67
  return mockTinyfishSecureKey;
67
68
  }
69
+ if (provider === "exa") {
70
+ return mockExaSecureKey;
71
+ }
68
72
  return undefined;
69
73
  },
70
74
  }));
@@ -111,6 +115,7 @@ describe("web_search tool", () => {
111
115
  mockFastcrwSecureKey = undefined;
112
116
  mockSearxngSecureKey = undefined;
113
117
  mockTinyfishSecureKey = undefined;
118
+ mockExaSecureKey = undefined;
114
119
  mockManagedSearchProxyCalls = [];
115
120
  mockManagedSearchAvailable = true;
116
121
  mockManagedSearchProxyResult = {
@@ -1345,6 +1350,97 @@ describe("web_search tool", () => {
1345
1350
  },
1346
1351
  );
1347
1352
 
1353
+ // ---- Exa provider ------------------------------------------------------
1354
+
1355
+ test("Exa search posts the recommended request and maps freshness", async () => {
1356
+ seedWebSearch("your-own", "exa");
1357
+ mockExaSecureKey = "exa_test";
1358
+ let capturedUrl = "";
1359
+ let capturedInit: RequestInit | undefined;
1360
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
1361
+ capturedUrl = url;
1362
+ capturedInit = init;
1363
+ return new Response(
1364
+ JSON.stringify({
1365
+ requestId: "r1",
1366
+ results: [
1367
+ {
1368
+ title: "Exa Result",
1369
+ url: "https://example.com/exa",
1370
+ publishedDate: "2026-09-18T00:00:00.000Z",
1371
+ highlights: ["Fresh from Exa"],
1372
+ },
1373
+ ],
1374
+ }),
1375
+ { status: 200, headers: { "content-type": "application/json" } },
1376
+ );
1377
+ }) as any;
1378
+
1379
+ const result = await execute({
1380
+ query: "fresh tools",
1381
+ freshness: "pw",
1382
+ count: 5,
1383
+ });
1384
+
1385
+ expect(result.isError).toBe(false);
1386
+ expect(capturedUrl).toBe("https://api.exa.ai/search");
1387
+ expect(capturedInit?.method).toBe("POST");
1388
+ const headers = new Headers(capturedInit?.headers);
1389
+ expect(headers.get("x-api-key")).toBe("exa_test");
1390
+ const body = JSON.parse(String(capturedInit?.body));
1391
+ expect(body.query).toBe("fresh tools");
1392
+ expect(body.type).toBe("auto");
1393
+ expect(body.numResults).toBe(5);
1394
+ expect(body.contents.highlights).toBe(true);
1395
+ expect(typeof body.startPublishedDate).toBe("string");
1396
+ const delta = Date.now() - Date.parse(body.startPublishedDate);
1397
+ expect(delta).toBeGreaterThan(7 * 24 * 60 * 60 * 1000 - 60_000);
1398
+ expect(delta).toBeLessThan(7 * 24 * 60 * 60 * 1000 + 60_000);
1399
+ expect(result.content).toContain("Exa Result");
1400
+ expect(result.content).toContain("Fresh from Exa");
1401
+ expect(result.activityMetadata?.webSearch?.provider).toBe("exa");
1402
+ });
1403
+
1404
+ test("Exa omits the date filter without freshness and trims to count", async () => {
1405
+ seedWebSearch("your-own", "exa");
1406
+ mockExaSecureKey = "exa_test";
1407
+ let capturedInit: RequestInit | undefined;
1408
+ globalThis.fetch = (async (_url: string, init?: RequestInit) => {
1409
+ capturedInit = init;
1410
+ return new Response(
1411
+ JSON.stringify({
1412
+ results: [
1413
+ { title: "A", url: "https://a.example.com", highlights: ["a"] },
1414
+ { title: "B", url: "https://b.example.com", highlights: ["b"] },
1415
+ { title: "C", url: "https://c.example.com", highlights: ["c"] },
1416
+ ],
1417
+ }),
1418
+ { status: 200, headers: { "content-type": "application/json" } },
1419
+ );
1420
+ }) as any;
1421
+
1422
+ const result = await execute({ query: "test", count: 2 });
1423
+
1424
+ const body = JSON.parse(String(capturedInit?.body));
1425
+ expect(body.startPublishedDate).toBeUndefined();
1426
+ expect(result.content).toContain("A");
1427
+ expect(result.content).toContain("B");
1428
+ expect(result.content).not.toContain("https://c.example.com");
1429
+ expect(result.activityMetadata?.webSearch?.resultCount).toBe(2);
1430
+ });
1431
+
1432
+ test.each([401, 402, 403])("Exa handles %d access error", async (status) => {
1433
+ seedWebSearch("your-own", "exa");
1434
+ mockExaSecureKey = "exa_test";
1435
+ globalThis.fetch = (async () =>
1436
+ new Response(JSON.stringify({ error: "denied" }), { status })) as any;
1437
+
1438
+ const result = await execute({ query: "test" });
1439
+
1440
+ expect(result.isError).toBe(true);
1441
+ expect(result.activityMetadata?.webSearch?.provider).toBe("exa");
1442
+ });
1443
+
1348
1444
  // ---- Provider fallback --------------------------------------------------
1349
1445
 
1350
1446
  test("falls back from perplexity to brave when perplexity has no key", async () => {
@@ -58,6 +58,15 @@ const MAX_TIMEOUT_SECONDS = 60;
58
58
  const DEFAULT_MAX_CHARS = 12_000;
59
59
  const MAX_MAX_CHARS = 40_000;
60
60
  const MAX_DOWNLOAD_BYTES = 2_000_000;
61
+
62
+ /**
63
+ * The metadata this assistant writes. `startIndexPastEnd` is optional on the
64
+ * wire because older records lack it; every writer here sets it, which is how
65
+ * a reader tells this metadata carries every reader-facing warning.
66
+ */
67
+ type WrittenWebFetchMetadata = WebFetchMetadata & {
68
+ startIndexPastEnd: boolean;
69
+ };
61
70
  const MAX_REDIRECTS = 10;
62
71
 
63
72
  const TEXT_LIKE_CONTENT_TYPES = [
@@ -672,7 +681,8 @@ export async function executeWebFetch(
672
681
  redirectCount: meta.redirectCount ?? 0,
673
682
  durationMs: Date.now() - startedAt,
674
683
  errorMessage,
675
- },
684
+ startIndexPastEnd: false,
685
+ } satisfies WrittenWebFetchMetadata,
676
686
  },
677
687
  };
678
688
  };
@@ -1023,7 +1033,7 @@ export async function executeWebFetch(
1023
1033
  const truncated = body.truncated || safeEnd < processed.length;
1024
1034
  const parsedTitle = html ? parseHtmlTitle(body.text) : undefined;
1025
1035
  const finalDomain = extractDomain(currentUrl.href);
1026
- const meta: WebFetchMetadata = {
1036
+ const meta: WrittenWebFetchMetadata = {
1027
1037
  url: safeRequestedUrl,
1028
1038
  finalUrl: sanitizeUrlForOutput(currentUrl),
1029
1039
  provider: "default",
@@ -1038,6 +1048,9 @@ export async function executeWebFetch(
1038
1048
  redirectCount,
1039
1049
  durationMs: Date.now() - startedAt,
1040
1050
  mayRequireJavaScript: mayRequireJavaScript || undefined,
1051
+ // Past the end of the page only when the whole page was downloaded: past
1052
+ // a capped prefix, the page may still have content at that offset.
1053
+ startIndexPastEnd: !body.truncated && startIndex > processed.length,
1041
1054
  };
1042
1055
 
1043
1056
  if (!response.ok) {
@@ -1223,7 +1236,8 @@ function hostedScrapeErrorResult(
1223
1236
  redirectCount: 0,
1224
1237
  durationMs: Date.now() - startedAt,
1225
1238
  errorMessage,
1226
- },
1239
+ startIndexPastEnd: false,
1240
+ } satisfies WrittenWebFetchMetadata,
1227
1241
  },
1228
1242
  };
1229
1243
  }
@@ -1394,6 +1408,8 @@ export async function executeFirecrawlCompatScrape(
1394
1408
  );
1395
1409
  }
1396
1410
 
1411
+ // A provider can pass an empty upstream title through; empty is no title.
1412
+ const title = scrapeMeta.title?.trim() || undefined;
1397
1413
  const content = formatWebFetchOutput({
1398
1414
  requestedUrl: safeRequestedUrl,
1399
1415
  finalUrl,
@@ -1405,7 +1421,7 @@ export async function executeFirecrawlCompatScrape(
1405
1421
  startIndex: safeStart,
1406
1422
  endIndex: safeEnd,
1407
1423
  content: sliced,
1408
- title: scrapeMeta.title,
1424
+ title,
1409
1425
  description: scrapeMeta.description,
1410
1426
  notices,
1411
1427
  raw: false,
@@ -1413,7 +1429,7 @@ export async function executeFirecrawlCompatScrape(
1413
1429
  });
1414
1430
 
1415
1431
  const finalDomain = extractDomain(finalUrl);
1416
- const meta: WebFetchMetadata = {
1432
+ const meta: WrittenWebFetchMetadata = {
1417
1433
  url: safeRequestedUrl,
1418
1434
  finalUrl,
1419
1435
  provider: options.provider,
@@ -1422,11 +1438,13 @@ export async function executeFirecrawlCompatScrape(
1422
1438
  byteCount: bytesRead,
1423
1439
  charCount: sliced.length,
1424
1440
  truncated: safeEnd < processed.length,
1425
- title: scrapeMeta.title,
1441
+ title,
1426
1442
  domain: finalDomain,
1427
1443
  faviconUrl: faviconUrlForDomain(finalDomain),
1428
1444
  redirectCount: 0,
1429
1445
  durationMs: Date.now() - startedAt,
1446
+ startIndexPastEnd: startIndex > processed.length,
1447
+ providerWarning: warning || undefined,
1430
1448
  };
1431
1449
 
1432
1450
  return {
@@ -1670,6 +1688,8 @@ export async function executeTinyfishFetch(
1670
1688
  );
1671
1689
  }
1672
1690
 
1691
+ // A provider can pass an empty upstream title through; empty is no title.
1692
+ const title = page.title?.trim() || undefined;
1673
1693
  const content = formatWebFetchOutput({
1674
1694
  requestedUrl: safeRequestedUrl,
1675
1695
  finalUrl,
@@ -1681,14 +1701,14 @@ export async function executeTinyfishFetch(
1681
1701
  startIndex: safeStart,
1682
1702
  endIndex: safeEnd,
1683
1703
  content: sliced,
1684
- title: page.title ?? undefined,
1704
+ title,
1685
1705
  description: page.description ?? undefined,
1686
1706
  notices,
1687
1707
  raw: false,
1688
1708
  markdown: true,
1689
1709
  });
1690
1710
  const finalDomain = extractDomain(finalUrl);
1691
- const metadata: WebFetchMetadata = {
1711
+ const metadata: WrittenWebFetchMetadata = {
1692
1712
  url: safeRequestedUrl,
1693
1713
  finalUrl,
1694
1714
  provider: "tinyfish",
@@ -1697,11 +1717,12 @@ export async function executeTinyfishFetch(
1697
1717
  byteCount: bytesRead,
1698
1718
  charCount: sliced.length,
1699
1719
  truncated: safeEnd < processed.length,
1700
- title: page.title ?? undefined,
1720
+ title,
1701
1721
  domain: finalDomain,
1702
1722
  faviconUrl: faviconUrlForDomain(finalDomain),
1703
1723
  redirectCount: finalUrl === safeRequestedUrl ? 0 : 1,
1704
1724
  durationMs: Date.now() - startedAt,
1725
+ startIndexPastEnd: startIndex > processed.length,
1705
1726
  };
1706
1727
  return {
1707
1728
  content,
@@ -45,6 +45,7 @@ const TAVILY_API_URL = "https://api.tavily.com/search";
45
45
  const FIRECRAWL_API_URL = "https://api.firecrawl.dev/v2/search";
46
46
  const FASTCRW_SEARCH_PATH = "/v1/search";
47
47
  const TINYFISH_DEFAULT_SEARCH_API_BASE = "https://api.search.tinyfish.ai";
48
+ const EXA_API_URL = "https://api.exa.ai/search";
48
49
  // Keenable is keyless by default: the public path needs no key (rate-limited);
49
50
  // a key switches to the authenticated path and lifts the cap.
50
51
  const KEENABLE_API_BASE_URL = "https://api.keenable.ai";
@@ -59,7 +60,8 @@ type WebSearchProvider =
59
60
  | "keenable"
60
61
  | "fastcrw"
61
62
  | "searxng"
62
- | "tinyfish";
63
+ | "tinyfish"
64
+ | "exa";
63
65
 
64
66
  /**
65
67
  * Arguments passed to every {@link WebSearchAdapter}. The full superset is
@@ -197,6 +199,20 @@ interface TinyfishSearchResponse {
197
199
  page?: number;
198
200
  }
199
201
 
202
+ interface ExaSearchResult {
203
+ id?: string;
204
+ title?: string | null;
205
+ url?: string;
206
+ publishedDate?: string | null;
207
+ author?: string | null;
208
+ highlights?: string[];
209
+ }
210
+
211
+ interface ExaSearchResponse {
212
+ requestId?: string;
213
+ results?: ExaSearchResult[];
214
+ }
215
+
200
216
  const SEARXNG_MISSING_INSTANCE_MESSAGE =
201
217
  "SearXNG needs an instance URL. Set API Base in Settings under Web Search.";
202
218
  const SEARXNG_JSON_DISABLED_MESSAGE =
@@ -716,6 +732,84 @@ function buildTinyfishMetadata(
716
732
  };
717
733
  }
718
734
 
735
+ function formatExaResults(data: ExaSearchResponse, query: string): string {
736
+ const results = data.results ?? [];
737
+ if (results.length === 0) {
738
+ return `No results found for "${query}".`;
739
+ }
740
+
741
+ const lines: string[] = [`Web search results for "${query}":\n`];
742
+ for (let i = 0; i < results.length; i++) {
743
+ const result = results[i];
744
+ const title =
745
+ result.title?.trim() || result.url?.trim() || "Untitled result";
746
+ lines.push(`${i + 1}. ${title}`);
747
+ if (result.url) {
748
+ lines.push(` URL: ${result.url}`);
749
+ }
750
+ const snippet = result.highlights?.join(" ").trim();
751
+ if (snippet) {
752
+ lines.push(` ${snippet}`);
753
+ }
754
+ if (result.publishedDate) {
755
+ lines.push(` Published: ${result.publishedDate}`);
756
+ }
757
+ lines.push("");
758
+ }
759
+ return lines.join("\n");
760
+ }
761
+
762
+ function buildExaMetadata(
763
+ data: ExaSearchResponse,
764
+ query: string,
765
+ durationMs: number,
766
+ ): WebSearchMetadata {
767
+ const results = data.results ?? [];
768
+ const items: WebSearchResultItem[] = results.map((result, index) => {
769
+ const url = result.url ?? "";
770
+ const domain = extractDomain(url);
771
+ return {
772
+ rank: index + 1,
773
+ title: result.title?.trim() || url.trim() || "Untitled result",
774
+ url,
775
+ domain,
776
+ faviconUrl: faviconUrlForDomain(domain),
777
+ snippet: result.highlights?.join(" ").trim() || undefined,
778
+ };
779
+ });
780
+ return {
781
+ query,
782
+ provider: "exa",
783
+ resultCount: items.length,
784
+ durationMs,
785
+ results: items,
786
+ };
787
+ }
788
+
789
+ function exaStartPublishedDateForFreshness(
790
+ freshness: string | undefined,
791
+ now: number = Date.now(),
792
+ ): string | undefined {
793
+ let days: number;
794
+ switch (freshness) {
795
+ case "pd":
796
+ days = 1;
797
+ break;
798
+ case "pw":
799
+ days = 7;
800
+ break;
801
+ case "pm":
802
+ days = 30;
803
+ break;
804
+ case "py":
805
+ days = 365;
806
+ break;
807
+ default:
808
+ return undefined;
809
+ }
810
+ return new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
811
+ }
812
+
719
813
  function tinyfishRecencyMinutesForFreshness(
720
814
  freshness: string | undefined,
721
815
  ): number | undefined {
@@ -1801,6 +1895,134 @@ async function executeTinyfishSearch(
1801
1895
  );
1802
1896
  }
1803
1897
 
1898
+ async function executeExaSearch(
1899
+ query: string,
1900
+ count: number,
1901
+ freshness: string | undefined,
1902
+ apiKey: string,
1903
+ signal?: AbortSignal,
1904
+ ): Promise<ToolExecutionResult> {
1905
+ const startedAt = Date.now();
1906
+ const requestBody: Record<string, unknown> = {
1907
+ query,
1908
+ type: "auto",
1909
+ numResults: count,
1910
+ contents: { highlights: true },
1911
+ };
1912
+ const startPublishedDate = exaStartPublishedDateForFreshness(freshness);
1913
+ if (startPublishedDate !== undefined) {
1914
+ requestBody.startPublishedDate = startPublishedDate;
1915
+ }
1916
+
1917
+ const headers = {
1918
+ "Content-Type": "application/json",
1919
+ Accept: "application/json",
1920
+ "x-api-key": apiKey.trim(),
1921
+ };
1922
+
1923
+ for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) {
1924
+ let response: Response;
1925
+ try {
1926
+ response = await fetch(EXA_API_URL, {
1927
+ method: "POST",
1928
+ headers,
1929
+ body: JSON.stringify(requestBody),
1930
+ signal,
1931
+ });
1932
+ } catch (err) {
1933
+ return networkFailureResult(query, "exa", startedAt, err, signal);
1934
+ }
1935
+
1936
+ const bodyText = await response.text();
1937
+ if (response.ok) {
1938
+ let data: ExaSearchResponse;
1939
+ try {
1940
+ data = JSON.parse(bodyText) as ExaSearchResponse;
1941
+ } catch {
1942
+ return errorResult(
1943
+ query,
1944
+ "exa",
1945
+ startedAt,
1946
+ "Exa Search returned an invalid JSON payload.",
1947
+ );
1948
+ }
1949
+ if (data.results && data.results.length > count) {
1950
+ data.results = data.results.slice(0, count);
1951
+ }
1952
+ const durationMs = Date.now() - startedAt;
1953
+ return {
1954
+ content:
1955
+ wrapUntrustedContent(formatExaResults(data, query), {
1956
+ source: "search",
1957
+ sourceDetail: "exa",
1958
+ }) + CITATION_INSTRUCTION,
1959
+ isError: false,
1960
+ activityMetadata: {
1961
+ webSearch: buildExaMetadata(data, query, durationMs),
1962
+ },
1963
+ };
1964
+ }
1965
+
1966
+ if (response.status === 401) {
1967
+ return errorResult(
1968
+ query,
1969
+ "exa",
1970
+ startedAt,
1971
+ "Invalid or expired Exa API key",
1972
+ );
1973
+ }
1974
+ if (response.status === 402) {
1975
+ return errorResult(
1976
+ query,
1977
+ "exa",
1978
+ startedAt,
1979
+ "Exa account has no remaining credits.",
1980
+ );
1981
+ }
1982
+ if (response.status === 403) {
1983
+ return errorResult(
1984
+ query,
1985
+ "exa",
1986
+ startedAt,
1987
+ "Exa Search request was forbidden by the upstream service.",
1988
+ );
1989
+ }
1990
+
1991
+ if (response.status === 429 && attempt < DEFAULT_MAX_RETRIES) {
1992
+ const delayMs = getHttpRetryDelay(
1993
+ response,
1994
+ attempt,
1995
+ DEFAULT_BASE_DELAY_MS,
1996
+ );
1997
+ log.warn(
1998
+ { attempt: attempt + 1, delayMs },
1999
+ "Exa Search rate limited, retrying",
2000
+ );
2001
+ await sleep(delayMs);
2002
+ continue;
2003
+ }
2004
+
2005
+ log.warn({ status: response.status }, "Exa Search API error");
2006
+ return backendFailureResult(
2007
+ query,
2008
+ "exa",
2009
+ startedAt,
2010
+ { statusCode: response.status, error: rawBodyDetail(bodyText) },
2011
+ response.status === 429
2012
+ ? "Exa Search rate limit exceeded after retries. Try again shortly."
2013
+ : `Exa Search API returned status ${response.status}`,
2014
+ );
2015
+ }
2016
+
2017
+ return backendFailureResult(
2018
+ query,
2019
+ "exa",
2020
+ startedAt,
2021
+ { statusCode: 429 },
2022
+ "Exa Search rate limit exceeded after retries. Try again shortly.",
2023
+ );
2024
+ }
2025
+
1804
2026
  // ----------------------------------------------------------------------------
1805
2027
  // Adapter registry
1806
2028
  //
@@ -1884,6 +2106,14 @@ const tinyfishSearchAdapter: WebSearchAdapter = {
1884
2106
  executeTinyfishSearch(query, count, freshness, apiKey, signal),
1885
2107
  };
1886
2108
 
2109
+ const exaSearchAdapter: WebSearchAdapter = {
2110
+ id: "exa",
2111
+ providerKeyName: "exa",
2112
+ fallbackOrder: 9,
2113
+ execute: ({ query, count, freshness, apiKey, signal }) =>
2114
+ executeExaSearch(query, count, freshness, apiKey, signal),
2115
+ };
2116
+
1887
2117
  /**
1888
2118
  * All built-in web-search adapters keyed by provider id. The
1889
2119
  * `Record<WebSearchProvider, ...>` shape forces TypeScript to flag any
@@ -1898,6 +2128,7 @@ const WEB_SEARCH_ADAPTERS: Record<WebSearchProvider, WebSearchAdapter> = {
1898
2128
  fastcrw: fastcrwSearchAdapter,
1899
2129
  searxng: searxngSearchAdapter,
1900
2130
  tinyfish: tinyfishSearchAdapter,
2131
+ exa: exaSearchAdapter,
1901
2132
  };
1902
2133
 
1903
2134
  /**
@@ -1929,7 +2160,7 @@ export const webSearchTool = {
1929
2160
  count: {
1930
2161
  type: "number",
1931
2162
  description:
1932
- "Number of results to return (1-20, default 10). Used with Brave, Tavily, Firecrawl, Keenable, fastCRW, SearXNG, and TinyFish providers.",
2163
+ "Number of results to return (1-20, default 10). Used with Brave, Tavily, Firecrawl, Keenable, fastCRW, SearXNG, TinyFish, and Exa providers.",
1933
2164
  },
1934
2165
  offset: {
1935
2166
  type: "number",
@@ -1939,7 +2170,7 @@ export const webSearchTool = {
1939
2170
  freshness: {
1940
2171
  type: "string",
1941
2172
  description:
1942
- 'Filter by recency: "pd" (past day), "pw" (past week), "pm" (past month), "py" (past year). Used with Brave, Tavily, Firecrawl, Keenable, fastCRW, SearXNG, and TinyFish providers. SearXNG maps day/month/year and omits week.',
2173
+ 'Filter by recency: "pd" (past day), "pw" (past week), "pm" (past month), "py" (past year). Used with Brave, Tavily, Firecrawl, Keenable, fastCRW, SearXNG, TinyFish, and Exa providers. SearXNG maps day/month/year and omits week.',
1943
2174
  },
1944
2175
  },
1945
2176
  required: ["query"],
@@ -2055,7 +2286,7 @@ export const webSearchTool = {
2055
2286
  query,
2056
2287
  provider,
2057
2288
  startedAt,
2058
- "No web search API key configured. Set it via `keys set perplexity <key>`, `keys set brave <key>`, `keys set tavily <key>`, `keys set firecrawl <key>`, or `keys set fastcrw <key>`, or `keys set tinyfish <key>`, or configure it from the Settings page under API Keys. Or switch the web-search provider to Keenable, which works without a key, or SearXNG with your instance URL.",
2289
+ "No web search API key configured. Set it via `keys set perplexity <key>`, `keys set brave <key>`, `keys set tavily <key>`, `keys set firecrawl <key>`, or `keys set fastcrw <key>`, or `keys set tinyfish <key>`, or `keys set exa <key>`, or configure it from the Settings page under API Keys. Or switch the web-search provider to Keenable, which works without a key, or SearXNG with your instance URL.",
2059
2290
  );
2060
2291
  }
2061
2292
  }
@@ -157,6 +157,7 @@ export const skillLoadTool = {
157
157
  async execute(
158
158
  input: Record<string, unknown>,
159
159
  context: ToolContext,
160
+ options?: { readOnly?: boolean },
160
161
  ): Promise<ToolExecutionResult> {
161
162
  const selector = input.skill;
162
163
  if (typeof selector !== "string" || selector.trim().length === 0) {
@@ -171,13 +172,17 @@ export const skillLoadTool = {
171
172
  // command execution. Instructions are still returned so the assistant can
172
173
  // load the system-storage-cleanup skill (and any already-installed skill).
173
174
  const cleanupMode = context.diskPressureCleanupModeActive === true;
175
+ const readOnly = cleanupMode || options?.readOnly === true;
176
+ const inlineCommandStub = cleanupMode
177
+ ? INLINE_COMMAND_CLEANUP_STUB
178
+ : "[inline command not executed]";
174
179
 
175
180
  let loaded = loadSkillBySelector(selector);
176
181
 
177
182
  // Auto-install from catalog if the skill isn't found locally
178
183
  if (
179
184
  !loaded.skill &&
180
- !cleanupMode &&
185
+ !readOnly &&
181
186
  (loaded.errorCode === "not_found" || loaded.errorCode === "empty_catalog")
182
187
  ) {
183
188
  try {
@@ -273,9 +278,8 @@ export const skillLoadTool = {
273
278
  break;
274
279
  }
275
280
 
276
- // Under the disk-pressure lock, never auto-install missing includes
277
- // (that writes to the workspace). Leave them advisory ("not loaded").
278
- if (cleanupMode) {
281
+ // Read-only loads leave missing includes advisory ("not loaded").
282
+ if (readOnly) {
279
283
  break;
280
284
  }
281
285
 
@@ -349,17 +353,11 @@ export const skillLoadTool = {
349
353
  const hasInlineCommands =
350
354
  skill.inlineCommandExpansions && skill.inlineCommandExpansions.length > 0;
351
355
 
352
- if (hasInlineCommands && cleanupMode) {
353
- // Under the disk-pressure lock, loading a skill must not execute shell.
354
- // Strip inline command tokens instead of rendering them; the rest of the
355
- // instructions are still returned.
356
- body = body.replace(
357
- INLINE_COMMAND_TOKEN_PATTERN,
358
- INLINE_COMMAND_CLEANUP_STUB,
359
- );
356
+ if (hasInlineCommands && readOnly) {
357
+ body = body.replace(INLINE_COMMAND_TOKEN_PATTERN, inlineCommandStub);
360
358
  log.info(
361
359
  { skillId: skill.id },
362
- "Skipped inline command expansion during disk pressure cleanup mode",
360
+ "Skipped inline command expansion during read-only skill load",
363
361
  );
364
362
  } else if (hasInlineCommands) {
365
363
  if (skill.source === "extra" || skill.source === "plugin") {
@@ -451,12 +449,10 @@ export const skillLoadTool = {
451
449
  childLoaded.skill.inlineCommandExpansions &&
452
450
  childLoaded.skill.inlineCommandExpansions.length > 0;
453
451
 
454
- if (childHasInlineCommands && cleanupMode) {
455
- // No shell execution under the disk-pressure lock — strip the
456
- // child's inline command tokens rather than rendering them.
452
+ if (childHasInlineCommands && readOnly) {
457
453
  childBody = childBody.replace(
458
454
  INLINE_COMMAND_TOKEN_PATTERN,
459
- INLINE_COMMAND_CLEANUP_STUB,
455
+ inlineCommandStub,
460
456
  );
461
457
  } else if (childHasInlineCommands) {
462
458
  if (