ada-agent 0.1.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 (339) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -0
  3. package/bench/README.md +88 -0
  4. package/bench/swebench.mjs +242 -0
  5. package/bin/ada-server.mjs +6 -0
  6. package/bin/ada.mjs +7 -0
  7. package/docs/agent-loop.svg +66 -0
  8. package/docs/architecture.md +139 -0
  9. package/docs/architecture.svg +73 -0
  10. package/docs/connectors.md +48 -0
  11. package/docs/integrations.md +59 -0
  12. package/docs/login-flow.svg +56 -0
  13. package/docs/orchestration.md +45 -0
  14. package/package.json +64 -0
  15. package/skills/accessibility/SKILL.md +23 -0
  16. package/skills/add-logging/SKILL.md +23 -0
  17. package/skills/add-metrics/SKILL.md +23 -0
  18. package/skills/adr/SKILL.md +24 -0
  19. package/skills/aesthetic-direction/SKILL.md +24 -0
  20. package/skills/agent-loop/SKILL.md +23 -0
  21. package/skills/alerting/SKILL.md +23 -0
  22. package/skills/alpha-compositing/SKILL.md +23 -0
  23. package/skills/android-compose/SKILL.md +23 -0
  24. package/skills/angular-module/SKILL.md +23 -0
  25. package/skills/ansible-playbook/SKILL.md +24 -0
  26. package/skills/api-docs/SKILL.md +24 -0
  27. package/skills/app-store-prep/SKILL.md +23 -0
  28. package/skills/architecture-diagram/SKILL.md +21 -0
  29. package/skills/architecture-doc/SKILL.md +24 -0
  30. package/skills/audit-log/SKILL.md +23 -0
  31. package/skills/authz-review/SKILL.md +23 -0
  32. package/skills/aws-lambda/SKILL.md +24 -0
  33. package/skills/bash-script/SKILL.md +23 -0
  34. package/skills/batch/SKILL.md +23 -0
  35. package/skills/bisect/SKILL.md +23 -0
  36. package/skills/bounding-box/SKILL.md +24 -0
  37. package/skills/branch-cleanup/SKILL.md +23 -0
  38. package/skills/bundle-analyze/SKILL.md +23 -0
  39. package/skills/cache/SKILL.md +23 -0
  40. package/skills/call-graph/SKILL.md +23 -0
  41. package/skills/canvas-debug/SKILL.md +23 -0
  42. package/skills/cdn-setup/SKILL.md +23 -0
  43. package/skills/changelog/SKILL.md +24 -0
  44. package/skills/cherry-pick/SKILL.md +23 -0
  45. package/skills/ci-setup/SKILL.md +23 -0
  46. package/skills/cleanup/SKILL.md +23 -0
  47. package/skills/cli-tool/SKILL.md +23 -0
  48. package/skills/cloudformation/SKILL.md +23 -0
  49. package/skills/code-examples/SKILL.md +24 -0
  50. package/skills/code-review/SKILL.md +23 -0
  51. package/skills/color-palette/SKILL.md +24 -0
  52. package/skills/color-space/SKILL.md +24 -0
  53. package/skills/comment-why/SKILL.md +23 -0
  54. package/skills/commit/SKILL.md +26 -0
  55. package/skills/complexity-audit/SKILL.md +23 -0
  56. package/skills/component/SKILL.md +23 -0
  57. package/skills/component-library/SKILL.md +23 -0
  58. package/skills/connect-github/SKILL.md +20 -0
  59. package/skills/connect-mcp/SKILL.md +21 -0
  60. package/skills/connect-postgres/SKILL.md +20 -0
  61. package/skills/connect-remote/SKILL.md +23 -0
  62. package/skills/connect-slack/SKILL.md +20 -0
  63. package/skills/contract-audit/SKILL.md +25 -0
  64. package/skills/contributing/SKILL.md +23 -0
  65. package/skills/cpp-raii/SKILL.md +23 -0
  66. package/skills/cron-job/SKILL.md +23 -0
  67. package/skills/cv-preprocess/SKILL.md +24 -0
  68. package/skills/dark-mode/SKILL.md +24 -0
  69. package/skills/dashboard/SKILL.md +23 -0
  70. package/skills/dashboard-ui/SKILL.md +23 -0
  71. package/skills/data-export/SKILL.md +23 -0
  72. package/skills/data-validation/SKILL.md +23 -0
  73. package/skills/dataframe/SKILL.md +23 -0
  74. package/skills/db-index/SKILL.md +24 -0
  75. package/skills/dead-code/SKILL.md +23 -0
  76. package/skills/debug/SKILL.md +24 -0
  77. package/skills/deck-review/SKILL.md +24 -0
  78. package/skills/dedupe/SKILL.md +23 -0
  79. package/skills/dedupe-deps/SKILL.md +23 -0
  80. package/skills/dependency-audit/SKILL.md +23 -0
  81. package/skills/dependency-update/SKILL.md +23 -0
  82. package/skills/deploy/SKILL.md +23 -0
  83. package/skills/design-system/SKILL.md +24 -0
  84. package/skills/design-tokens/SKILL.md +24 -0
  85. package/skills/diagram-as-code/SKILL.md +24 -0
  86. package/skills/diff-explain/SKILL.md +23 -0
  87. package/skills/django-view/SKILL.md +23 -0
  88. package/skills/doc-lint/SKILL.md +24 -0
  89. package/skills/docker-compose/SKILL.md +23 -0
  90. package/skills/dockerize/SKILL.md +23 -0
  91. package/skills/docstrings/SKILL.md +23 -0
  92. package/skills/dotfiles/SKILL.md +23 -0
  93. package/skills/dpi-scaling/SKILL.md +23 -0
  94. package/skills/e2e-test/SKILL.md +23 -0
  95. package/skills/embeddings/SKILL.md +23 -0
  96. package/skills/empty-states/SKILL.md +23 -0
  97. package/skills/env-setup/SKILL.md +23 -0
  98. package/skills/erc20/SKILL.md +24 -0
  99. package/skills/error-tracking/SKILL.md +23 -0
  100. package/skills/estimate/SKILL.md +23 -0
  101. package/skills/etl-pipeline/SKILL.md +24 -0
  102. package/skills/eval-harness/SKILL.md +23 -0
  103. package/skills/exif-orientation/SKILL.md +23 -0
  104. package/skills/explain-code/SKILL.md +23 -0
  105. package/skills/express-middleware/SKILL.md +23 -0
  106. package/skills/extract-function/SKILL.md +23 -0
  107. package/skills/faq/SKILL.md +24 -0
  108. package/skills/fastapi-endpoint/SKILL.md +23 -0
  109. package/skills/favicon/SKILL.md +23 -0
  110. package/skills/feature-engineering/SKILL.md +23 -0
  111. package/skills/few-shot/SKILL.md +23 -0
  112. package/skills/find-owner/SKILL.md +23 -0
  113. package/skills/firmware-driver/SKILL.md +23 -0
  114. package/skills/fix-flaky-tests/SKILL.md +23 -0
  115. package/skills/flutter-widget/SKILL.md +23 -0
  116. package/skills/font-rendering/SKILL.md +23 -0
  117. package/skills/form-validation/SKILL.md +23 -0
  118. package/skills/format/SKILL.md +23 -0
  119. package/skills/game-loop/SKILL.md +23 -0
  120. package/skills/gas-optimize/SKILL.md +25 -0
  121. package/skills/gdpr-review/SKILL.md +24 -0
  122. package/skills/github-actions/SKILL.md +23 -0
  123. package/skills/glossary/SKILL.md +24 -0
  124. package/skills/go-idioms/SKILL.md +23 -0
  125. package/skills/gpu-profile/SKILL.md +23 -0
  126. package/skills/graphify/SKILL.md +21 -0
  127. package/skills/graphql-resolver/SKILL.md +23 -0
  128. package/skills/grpc-service/SKILL.md +23 -0
  129. package/skills/guardrails/SKILL.md +23 -0
  130. package/skills/healthcheck/SKILL.md +23 -0
  131. package/skills/heisenbug/SKILL.md +23 -0
  132. package/skills/helm-chart/SKILL.md +24 -0
  133. package/skills/hero-section/SKILL.md +23 -0
  134. package/skills/html-email/SKILL.md +24 -0
  135. package/skills/html-form/SKILL.md +23 -0
  136. package/skills/html-sanitize/SKILL.md +23 -0
  137. package/skills/html-table/SKILL.md +23 -0
  138. package/skills/html-to-pdf/SKILL.md +23 -0
  139. package/skills/http-client/SKILL.md +23 -0
  140. package/skills/i18n/SKILL.md +23 -0
  141. package/skills/i2c-spi/SKILL.md +23 -0
  142. package/skills/image-decode/SKILL.md +24 -0
  143. package/skills/image-memory/SKILL.md +24 -0
  144. package/skills/image-perf/SKILL.md +24 -0
  145. package/skills/image-pipeline/SKILL.md +24 -0
  146. package/skills/image-upload/SKILL.md +24 -0
  147. package/skills/infra-cost/SKILL.md +24 -0
  148. package/skills/input-validation/SKILL.md +23 -0
  149. package/skills/issue-template/SKILL.md +23 -0
  150. package/skills/java-streams/SKILL.md +23 -0
  151. package/skills/k8s-manifest/SKILL.md +23 -0
  152. package/skills/kotlin-coroutines/SKILL.md +23 -0
  153. package/skills/landing-page/SKILL.md +24 -0
  154. package/skills/laravel-controller/SKILL.md +23 -0
  155. package/skills/lazy-load/SKILL.md +23 -0
  156. package/skills/license-check/SKILL.md +23 -0
  157. package/skills/license-header/SKILL.md +23 -0
  158. package/skills/lint-fix/SKILL.md +23 -0
  159. package/skills/llm-cost/SKILL.md +23 -0
  160. package/skills/lockfile-fix/SKILL.md +23 -0
  161. package/skills/low-power/SKILL.md +23 -0
  162. package/skills/makefile/SKILL.md +23 -0
  163. package/skills/man-page/SKILL.md +24 -0
  164. package/skills/mcp-server/SKILL.md +23 -0
  165. package/skills/memory-leak/SKILL.md +23 -0
  166. package/skills/mermaid-diagram/SKILL.md +23 -0
  167. package/skills/meta-tags/SKILL.md +23 -0
  168. package/skills/micro-interactions/SKILL.md +23 -0
  169. package/skills/migration/SKILL.md +23 -0
  170. package/skills/migration-guide/SKILL.md +24 -0
  171. package/skills/mkdocs-setup/SKILL.md +24 -0
  172. package/skills/mobile-permissions/SKILL.md +23 -0
  173. package/skills/mock-api/SKILL.md +23 -0
  174. package/skills/modernize/SKILL.md +23 -0
  175. package/skills/monorepo-setup/SKILL.md +23 -0
  176. package/skills/motion-design/SKILL.md +23 -0
  177. package/skills/n-plus-one/SKILL.md +23 -0
  178. package/skills/naming-review/SKILL.md +23 -0
  179. package/skills/nextjs-route/SKILL.md +23 -0
  180. package/skills/nginx-config/SKILL.md +23 -0
  181. package/skills/ocr-debug/SKILL.md +24 -0
  182. package/skills/onboard/SKILL.md +23 -0
  183. package/skills/onboarding-map/SKILL.md +23 -0
  184. package/skills/open-pr/SKILL.md +24 -0
  185. package/skills/openapi/SKILL.md +23 -0
  186. package/skills/opencv-debug/SKILL.md +24 -0
  187. package/skills/orm-model/SKILL.md +23 -0
  188. package/skills/owasp-check/SKILL.md +24 -0
  189. package/skills/page-transitions/SKILL.md +23 -0
  190. package/skills/pagination/SKILL.md +23 -0
  191. package/skills/perf-optimize/SKILL.md +23 -0
  192. package/skills/perf-profile/SKILL.md +23 -0
  193. package/skills/physics/SKILL.md +23 -0
  194. package/skills/pitch-deck/SKILL.md +24 -0
  195. package/skills/pixel-diff/SKILL.md +23 -0
  196. package/skills/ponytail/SKILL.md +46 -0
  197. package/skills/postmortem/SKILL.md +24 -0
  198. package/skills/pptx-deck/SKILL.md +23 -0
  199. package/skills/pptx-export/SKILL.md +23 -0
  200. package/skills/pptx-from-markdown/SKILL.md +23 -0
  201. package/skills/pptx-template/SKILL.md +24 -0
  202. package/skills/pr-review/SKILL.md +24 -0
  203. package/skills/precommit/SKILL.md +23 -0
  204. package/skills/pricing-page/SKILL.md +23 -0
  205. package/skills/project-overview/SKILL.md +22 -0
  206. package/skills/prompt-template/SKILL.md +23 -0
  207. package/skills/property-test/SKILL.md +23 -0
  208. package/skills/protobuf/SKILL.md +23 -0
  209. package/skills/py-async/SKILL.md +23 -0
  210. package/skills/py-typing/SKILL.md +23 -0
  211. package/skills/query-optimize/SKILL.md +23 -0
  212. package/skills/rag-pipeline/SKILL.md +23 -0
  213. package/skills/rails-resource/SKILL.md +23 -0
  214. package/skills/rate-limit/SKILL.md +23 -0
  215. package/skills/react-hooks/SKILL.md +23 -0
  216. package/skills/react-native-screen/SKILL.md +23 -0
  217. package/skills/react-perf/SKILL.md +23 -0
  218. package/skills/readme/SKILL.md +24 -0
  219. package/skills/rebase/SKILL.md +24 -0
  220. package/skills/refactor/SKILL.md +23 -0
  221. package/skills/regression-test/SKILL.md +23 -0
  222. package/skills/release-notes/SKILL.md +24 -0
  223. package/skills/rename-symbol/SKILL.md +23 -0
  224. package/skills/repro/SKILL.md +23 -0
  225. package/skills/resolve-conflicts/SKILL.md +23 -0
  226. package/skills/responsive/SKILL.md +23 -0
  227. package/skills/rest-endpoint/SKILL.md +23 -0
  228. package/skills/retro/SKILL.md +23 -0
  229. package/skills/rtos-task/SKILL.md +23 -0
  230. package/skills/runbook/SKILL.md +25 -0
  231. package/skills/rust-borrow/SKILL.md +23 -0
  232. package/skills/rust-unsafe-audit/SKILL.md +23 -0
  233. package/skills/sanitize/SKILL.md +23 -0
  234. package/skills/schema-design/SKILL.md +23 -0
  235. package/skills/screenshot-debug/SKILL.md +22 -0
  236. package/skills/scroll-animation/SKILL.md +23 -0
  237. package/skills/secret-scan/SKILL.md +23 -0
  238. package/skills/security-audit/SKILL.md +23 -0
  239. package/skills/security-review/SKILL.md +23 -0
  240. package/skills/seed-data/SKILL.md +23 -0
  241. package/skills/self-review/SKILL.md +23 -0
  242. package/skills/semantic-html/SKILL.md +23 -0
  243. package/skills/semver-bump/SKILL.md +24 -0
  244. package/skills/shader/SKILL.md +23 -0
  245. package/skills/shader-debug/SKILL.md +23 -0
  246. package/skills/simplify-conditionals/SKILL.md +23 -0
  247. package/skills/sitemap/SKILL.md +23 -0
  248. package/skills/skeleton-loader/SKILL.md +23 -0
  249. package/skills/slide-charts/SKILL.md +24 -0
  250. package/skills/slide-outline/SKILL.md +23 -0
  251. package/skills/snapshot-update/SKILL.md +23 -0
  252. package/skills/solidity-contract/SKILL.md +25 -0
  253. package/skills/speaker-notes/SKILL.md +23 -0
  254. package/skills/spike/SKILL.md +23 -0
  255. package/skills/split-file/SKILL.md +23 -0
  256. package/skills/spring-controller/SKILL.md +23 -0
  257. package/skills/sprite-anim/SKILL.md +23 -0
  258. package/skills/sql-report/SKILL.md +23 -0
  259. package/skills/squash/SKILL.md +24 -0
  260. package/skills/ssl-setup/SKILL.md +23 -0
  261. package/skills/stacktrace/SKILL.md +23 -0
  262. package/skills/static-site/SKILL.md +24 -0
  263. package/skills/structured-logging/SKILL.md +23 -0
  264. package/skills/svelte-store/SKILL.md +23 -0
  265. package/skills/swiftui-view/SKILL.md +23 -0
  266. package/skills/tailwind-theme/SKILL.md +24 -0
  267. package/skills/tcp-server/SKILL.md +23 -0
  268. package/skills/tdd/SKILL.md +23 -0
  269. package/skills/terraform-module/SKILL.md +24 -0
  270. package/skills/test-coverage/SKILL.md +23 -0
  271. package/skills/texture-debug/SKILL.md +23 -0
  272. package/skills/threat-model/SKILL.md +23 -0
  273. package/skills/thumbnail/SKILL.md +24 -0
  274. package/skills/todo-scan/SKILL.md +23 -0
  275. package/skills/tool-definition/SKILL.md +23 -0
  276. package/skills/trace-flow/SKILL.md +23 -0
  277. package/skills/tracing/SKILL.md +23 -0
  278. package/skills/train-model/SKILL.md +24 -0
  279. package/skills/tree-shake/SKILL.md +23 -0
  280. package/skills/ts-generics/SKILL.md +23 -0
  281. package/skills/ts-strict/SKILL.md +23 -0
  282. package/skills/tui-app/SKILL.md +23 -0
  283. package/skills/tutorial/SKILL.md +24 -0
  284. package/skills/type-tighten/SKILL.md +23 -0
  285. package/skills/typography/SKILL.md +24 -0
  286. package/skills/ui-bug-repro/SKILL.md +23 -0
  287. package/skills/ui-polish/SKILL.md +24 -0
  288. package/skills/ui-review/SKILL.md +24 -0
  289. package/skills/vendor/SKILL.md +23 -0
  290. package/skills/visual-diff-ci/SKILL.md +24 -0
  291. package/skills/visual-regression/SKILL.md +23 -0
  292. package/skills/vue-composition/SKILL.md +23 -0
  293. package/skills/web-component/SKILL.md +23 -0
  294. package/skills/web-fonts/SKILL.md +24 -0
  295. package/skills/web3-frontend/SKILL.md +25 -0
  296. package/skills/webgl-debug/SKILL.md +23 -0
  297. package/skills/webhook/SKILL.md +23 -0
  298. package/skills/websocket/SKILL.md +23 -0
  299. package/skills/write-tests/SKILL.md +19 -0
  300. package/src/client/agent.ts +803 -0
  301. package/src/client/background.ts +39 -0
  302. package/src/client/checkpoint.ts +48 -0
  303. package/src/client/cli.ts +1253 -0
  304. package/src/client/compaction.ts +86 -0
  305. package/src/client/extensions.ts +83 -0
  306. package/src/client/hooks.ts +40 -0
  307. package/src/client/image.ts +26 -0
  308. package/src/client/lsp.ts +0 -0
  309. package/src/client/mcp.ts +276 -0
  310. package/src/client/models-dev.ts +52 -0
  311. package/src/client/pkg.ts +41 -0
  312. package/src/client/platform.ts +94 -0
  313. package/src/client/prompts.ts +47 -0
  314. package/src/client/render.ts +138 -0
  315. package/src/client/session.ts +107 -0
  316. package/src/client/settings.ts +86 -0
  317. package/src/client/skill-router.ts +79 -0
  318. package/src/client/skills.ts +199 -0
  319. package/src/client/snapshot.ts +56 -0
  320. package/src/client/telemetry.ts +24 -0
  321. package/src/client/todos.ts +23 -0
  322. package/src/client/tools.ts +756 -0
  323. package/src/client/tui-mode.ts +41 -0
  324. package/src/client/tui.ts +224 -0
  325. package/src/sdk/index.ts +36 -0
  326. package/src/selfcheck.ts +364 -0
  327. package/src/server/config.ts +58 -0
  328. package/src/server/credentials.ts +89 -0
  329. package/src/server/identity.ts +58 -0
  330. package/src/server/index.ts +113 -0
  331. package/src/server/oauth.ts +93 -0
  332. package/src/server/providers/adapter.ts +25 -0
  333. package/src/server/providers/anthropic.ts +189 -0
  334. package/src/server/providers/openai-compat.ts +76 -0
  335. package/src/server/providers/registry.ts +31 -0
  336. package/src/server/router.ts +29 -0
  337. package/src/server/sse.ts +20 -0
  338. package/src/shared/types.ts +20 -0
  339. package/tsconfig.json +15 -0
@@ -0,0 +1,803 @@
1
+ // The agentic loop. Talks ONLY to the ada backend via the OpenAI SDK; the backend
2
+ // routes to the real provider. Streams text, runs tool calls, persists every message.
3
+
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import type OpenAI from "openai";
7
+ import { compact, estimateTokens, isContextOverflowError } from "./compaction.ts";
8
+ import { MarkdownStreamer } from "./render.ts";
9
+ import { type Tool, type ToolResult, isDestructive, toolByName, tools } from "./tools.ts";
10
+ import { afterTool, beforeTool, transformInput } from "./hooks.ts";
11
+ import { configuredServers } from "./mcp.ts";
12
+ import { priceOf } from "./models-dev.ts";
13
+ import { permissionFor } from "./settings.ts";
14
+ import { routeConfident, routeSkills } from "./skills.ts";
15
+ import { Session } from "./session.ts";
16
+
17
+ type Msg = OpenAI.Chat.Completions.ChatCompletionMessageParam;
18
+ type SendCtrl = { signal?: AbortSignal; steer?: string[]; quiet?: boolean; images?: string[]; onReplyStart?: () => void };
19
+ type ToolCall = { id: string; name: string; args: string };
20
+ type StepResult = { content: string; toolCalls: ToolCall[] };
21
+ type ToolDef = OpenAI.Chat.Completions.ChatCompletionTool;
22
+
23
+ export type ApprovalDecision = "yes" | "all" | "no";
24
+ export type OnApprove = (toolName: string, summary: string) => Promise<ApprovalDecision>;
25
+
26
+ function projectContext(): string {
27
+ for (const f of ["AGENTS.md", "CLAUDE.md"]) {
28
+ const p = resolve(process.cwd(), f);
29
+ if (existsSync(p)) {
30
+ try {
31
+ return `\n\nProject guide (${f}):\n${readFileSync(p, "utf8").slice(0, 8000)}`;
32
+ } catch {
33
+ /* ignore unreadable */
34
+ }
35
+ }
36
+ }
37
+ return "";
38
+ }
39
+
40
+ function systemPrompt(includeProject: boolean): string {
41
+ return (
42
+ [
43
+ "You are ada, a minimal coding agent running in a terminal, in the spirit of pi, Codex, and Cursor.",
44
+ `Working directory: ${process.cwd()}`,
45
+ `Platform: ${process.platform}`,
46
+ "Tools: read_file, write_file, edit_file, bash, ls, grep, glob, web_fetch, web_search, lsp_diagnostics. Use grep/glob/ls to explore the codebase; read a file before editing it; prefer edit_file for changes to existing files; web_fetch to read a URL, web_search to find one; lsp_diagnostics to check a file for errors after editing; apply_patch for multi-file changes; ask_user only when genuinely blocked.",
47
+ "Specialized skills are available: call list_skills to browse them (by category or filter), then use_skill to load one before a specialized task.",
48
+ "Be concise. Don't narrate routine actions or pad with preamble. When you have enough information to act, act. Ask only when genuinely blocked or before destructive, irreversible actions.",
49
+ ].join("\n") + (includeProject ? projectContext() : "")
50
+ );
51
+ }
52
+
53
+ function buildApiTools(): ToolDef[] {
54
+ return tools.map((t) => ({
55
+ type: "function",
56
+ function: { name: t.name, description: t.description, parameters: t.parameters },
57
+ }));
58
+ }
59
+
60
+ // Pull every top-level JSON object out of a string (brace-matched, string-aware).
61
+ function extractJsonObjects(s: string): Array<Record<string, unknown>> {
62
+ const t = s.trim();
63
+ try {
64
+ const v = JSON.parse(t);
65
+ if (Array.isArray(v)) return v.filter((x): x is Record<string, unknown> => !!x && typeof x === "object");
66
+ if (v && typeof v === "object") return [v as Record<string, unknown>];
67
+ } catch {
68
+ /* not one clean value — scan for embedded objects below */
69
+ }
70
+ const out: Array<Record<string, unknown>> = [];
71
+ let depth = 0;
72
+ let start = -1;
73
+ let inStr = false;
74
+ let esc = false;
75
+ for (let i = 0; i < t.length; i++) {
76
+ const c = t[i];
77
+ if (inStr) {
78
+ if (esc) esc = false;
79
+ else if (c === "\\") esc = true;
80
+ else if (c === '"') inStr = false;
81
+ } else if (c === '"') inStr = true;
82
+ else if (c === "{") {
83
+ if (depth === 0) start = i;
84
+ depth++;
85
+ } else if (c === "}") {
86
+ if (--depth === 0 && start >= 0) {
87
+ try {
88
+ const o = JSON.parse(t.slice(start, i + 1));
89
+ if (o && typeof o === "object") out.push(o);
90
+ } catch {
91
+ /* unbalanced — skip */
92
+ }
93
+ start = -1;
94
+ }
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // Some providers (notably Ollama over a streaming connection) fail to parse a model's tool
101
+ // call into the structured tool_calls field and leak it as raw JSON in the text content.
102
+ // Recover it: if the reply IS a JSON tool call for a real tool, hand it back as a call.
103
+ export function parseTextToolCalls(content: string): Array<{ name: string; args: string }> | null {
104
+ let s = content.trim();
105
+ if (!s) return null;
106
+ const fence = s.match(/^```(?:json|tool(?:_call)?)?\s*([\s\S]*?)\s*```$/i);
107
+ if (fence) s = fence[1]!.trim();
108
+ const blocks: string[] = [];
109
+ const tagRe = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/gi; // Qwen/Hermes wrap calls in tags
110
+ let m: RegExpExecArray | null;
111
+ while ((m = tagRe.exec(s))) blocks.push(m[1]!);
112
+ if (!blocks.length) blocks.push(s);
113
+ const out: Array<{ name: string; args: string }> = [];
114
+ for (const b of blocks) {
115
+ for (const o of extractJsonObjects(b)) {
116
+ const name = typeof o.name === "string" ? o.name : typeof o.tool === "string" ? (o.tool as string) : "";
117
+ if (!name || !toolByName.has(name)) continue;
118
+ const raw = o.arguments ?? o.args ?? o.parameters ?? {};
119
+ out.push({ name, args: typeof raw === "string" ? raw : JSON.stringify(raw) });
120
+ }
121
+ }
122
+ return out.length ? out : null;
123
+ }
124
+
125
+ const COMPACT_AT = Number(process.env.ADA_COMPACT_AT) || 100_000;
126
+
127
+ const PLAN_NOTE =
128
+ "PLAN MODE: do not write, edit, or run commands. Investigate with read-only tools if needed, then present a concise numbered plan and stop. The user will approve before you execute.";
129
+
130
+ const DECOMPOSE_NOTE =
131
+ "Break the user's request into 2-5 independent subtasks that can each be done on their own. Output one subtask per line — nothing else.";
132
+
133
+ // ---- orchestration: pluggable agent architectures over a shared Engine ----
134
+ // The Engine holds the harness primitives (streaming, tool-call recovery, compaction, approval,
135
+ // sessions). An Orchestrator is a Strategy that only decides WHEN to call those primitives, so a
136
+ // new agent architecture is one Orchestrator and zero changes to the engine.
137
+
138
+ /** The harness primitives a strategy composes. */
139
+ export interface Engine {
140
+ step(opts?: { allowTools?: boolean; note?: string }): Promise<StepResult | null>; // null = aborted
141
+ runTools(calls: ToolCall[]): Promise<void>;
142
+ say(s: string): void;
143
+ interrupted(): void;
144
+ addSystem(text: string): void;
145
+ aborted(): boolean;
146
+ drainSteer(): boolean;
147
+ spawn(prompt: string): Promise<string>;
148
+ soleIntegration(): string | null;
149
+ readDocs(name: string): Promise<string>;
150
+ writeSkills(drafts: { name: string; content: string }[]): Promise<number>;
151
+ }
152
+
153
+ export interface Orchestrator {
154
+ readonly name: string;
155
+ run(e: Engine): Promise<void>;
156
+ }
157
+
158
+ const reAct: Orchestrator = {
159
+ name: "react", // reason → act → observe → repeat (the default; = the original loop)
160
+ async run(e) {
161
+ for (;;) {
162
+ const turn = await e.step();
163
+ if (!turn) return;
164
+ if (!turn.toolCalls.length) {
165
+ e.say("\n");
166
+ if (e.drainSteer()) continue;
167
+ return;
168
+ }
169
+ await e.runTools(turn.toolCalls);
170
+ if (e.aborted()) {
171
+ e.interrupted();
172
+ return;
173
+ }
174
+ e.drainSteer();
175
+ }
176
+ },
177
+ };
178
+
179
+ const singleShot: Orchestrator = {
180
+ name: "single", // one model turn, no tools — quick Q&A
181
+ async run(e) {
182
+ if (await e.step({ allowTools: false })) e.say("\n");
183
+ },
184
+ };
185
+
186
+ const planExecute: Orchestrator = {
187
+ name: "plan", // read-only plan first, then execute it
188
+ async run(e) {
189
+ if (!(await e.step({ allowTools: false, note: PLAN_NOTE }))) return;
190
+ e.addSystem("Now execute the plan above, step by step, using your tools.");
191
+ for (;;) {
192
+ const turn = await e.step();
193
+ if (!turn) return;
194
+ if (!turn.toolCalls.length) {
195
+ e.say("\n");
196
+ return;
197
+ }
198
+ await e.runTools(turn.toolCalls);
199
+ if (e.aborted()) {
200
+ e.interrupted();
201
+ return;
202
+ }
203
+ }
204
+ },
205
+ };
206
+
207
+ const splitLines = (s: string): string[] =>
208
+ s
209
+ .split("\n")
210
+ .map((l) => l.replace(/^[-*\d.)\s]+/, "").trim())
211
+ .filter((l) => l.length > 1);
212
+
213
+ const multiAgent: Orchestrator = {
214
+ name: "multi", // decompose → fan out to subagents → synthesize
215
+ async run(e) {
216
+ const plan = await e.step({ allowTools: false, note: DECOMPOSE_NOTE });
217
+ if (!plan) return;
218
+ const tasks = splitLines(plan.content).filter((t) => t.length > 8);
219
+ if (!tasks.length) {
220
+ e.say("\n");
221
+ return;
222
+ }
223
+ e.say(`\n\x1b[2m• delegating ${tasks.length} subtasks\x1b[0m\n`);
224
+ const results = await Promise.all(tasks.map((t) => e.spawn(t)));
225
+ e.addSystem(`Subagent results:\n\n${results.map((r, i) => `### ${tasks[i]}\n${r}`).join("\n\n")}\n\nSynthesize the final answer for the user.`);
226
+ await e.step({ allowTools: false });
227
+ e.say("\n");
228
+ },
229
+ };
230
+
231
+ const toolsmith: Orchestrator = {
232
+ name: "toolsmith", // read the lone integration's docs → subagents author skills for it
233
+ async run(e) {
234
+ const integ = e.soleIntegration();
235
+ if (!integ) {
236
+ e.say("\x1b[33mtoolsmith needs exactly one integration configured (ada mcp add <name>).\x1b[0m\n");
237
+ return;
238
+ }
239
+ e.say(`\x1b[2m• reading ${integ} docs…\x1b[0m\n`);
240
+ const docs = await e.readDocs(integ);
241
+ const plan = await e.step({
242
+ allowTools: false,
243
+ note: `These are the ${integ} integration's capabilities:\n\n${docs}\n\nList 4-8 capability AREAS to build skills for — one short kebab-case slug per line, nothing else.`,
244
+ });
245
+ if (!plan) return;
246
+ const areas = splitLines(plan.content)
247
+ .map((a) => a.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""))
248
+ .filter((a) => a.length > 1)
249
+ .slice(0, 8);
250
+ if (!areas.length) {
251
+ e.say("\x1b[33mtoolsmith: could not derive capability areas from the docs.\x1b[0m\n");
252
+ return;
253
+ }
254
+ e.say(`\x1b[2m• ${integ}: ${areas.join(", ")}\x1b[0m\n`);
255
+ const drafts = await Promise.all(
256
+ areas.map(async (area) => ({
257
+ name: `${integ}-${area}`,
258
+ content: await e.spawn(
259
+ `Write an ada SKILL.md for the "${integ}" integration's "${area}" capability. Output ONLY the file, in EXACTLY this format:\n` +
260
+ `---\nname: ${integ}-${area}\ndescription: <one imperative line, <=110 chars>\ncategory: integration-${integ}\n---\n\n# <Title>\n\n<one-sentence intro>\n\n1. <step>\n... (4-6 steps that reference the relevant ${integ}__* tools)\n\n## Rules\n- <3-5 rules>\n\n` +
261
+ `Base it strictly on these ${integ} tools:\n${docs}`,
262
+ ),
263
+ })),
264
+ );
265
+ const n = await e.writeSkills(drafts);
266
+ e.say(`\n\x1b[38;5;214m✓\x1b[0m toolsmith wrote ${n} ${integ} skills → .ada/skills/ (browse: list_skills category=integration-${integ})\n`);
267
+ },
268
+ };
269
+
270
+ const ORCHESTRATORS: Record<string, Orchestrator> = { react: reAct, single: singleShot, plan: planExecute, multi: multiAgent, toolsmith };
271
+
272
+ /** A short, transient hint naming the most relevant skills for a request (or null if none stand out). */
273
+ function suggestSkillNote(query: string): string | null {
274
+ const top = routeSkills(query, 3).filter((r) => r.score >= 2);
275
+ if (!top.length) return null;
276
+ return `Possibly relevant skills for this request — call use_skill to load one if it helps, otherwise ignore: ${top.map((r) => `${r.name} (${r.description})`).join("; ")}`;
277
+ }
278
+
279
+ /** The only integration configured in .ada/mcp.json, or null if zero or several. */
280
+ export function soleIntegration(): string | null {
281
+ const servers = configuredServers();
282
+ return servers.length === 1 ? servers[0]! : null;
283
+ }
284
+
285
+ /** "Docs" for an integration = the descriptions + schemas of its registered <name>__* tools. */
286
+ export function readIntegrationDocs(name: string): string {
287
+ const tools_ = [...toolByName.values()].filter((t) => t.name.startsWith(`${name}__`));
288
+ if (!tools_.length) return `(no tools registered for "${name}" — connect it in a trusted project first)`;
289
+ return tools_.map((t) => `## ${t.name}\n${t.description}\nparameters: ${JSON.stringify(t.parameters)}`).join("\n\n");
290
+ }
291
+
292
+ /** Persist subagent-authored skills under the project's .ada/skills/. Returns how many were written. */
293
+ export function writeProjectSkills(drafts: { name: string; content: string }[]): number {
294
+ let n = 0;
295
+ for (const d of drafts) {
296
+ const body = String(d.content ?? "").trim();
297
+ if (!body.startsWith("---")) continue; // skip non-skill output
298
+ const dir = resolve(process.cwd(), ".ada", "skills", d.name);
299
+ mkdirSync(dir, { recursive: true });
300
+ writeFileSync(join(dir, "SKILL.md"), `${body}\n`);
301
+ n++;
302
+ }
303
+ return n;
304
+ }
305
+
306
+ // $ per 1M tokens [input, output] for a few common models; substring-matched.
307
+ const PRICES: Record<string, [number, number]> = {
308
+ "gpt-4o-mini": [0.15, 0.6],
309
+ "gpt-4o": [2.5, 10],
310
+ "claude-opus": [5, 25],
311
+ "claude-sonnet": [3, 15],
312
+ "claude-haiku": [1, 5],
313
+ "deepseek": [0.27, 1.1],
314
+ };
315
+ function priceFor(model: string): [number, number] | null {
316
+ const md = priceOf(model); // models.dev catalog (prefetched), if available
317
+ if (md) return md;
318
+ for (const k of Object.keys(PRICES)) if (model.includes(k)) return PRICES[k]!;
319
+ return null;
320
+ }
321
+
322
+ function summarize(args: unknown): string {
323
+ const s = typeof args === "string" ? args : JSON.stringify(args ?? {});
324
+ return s.length > 80 ? `${s.slice(0, 80)}…` : s;
325
+ }
326
+
327
+ /** Human-readable (label, detail) for a tool call — clearer than dumping the raw args JSON. */
328
+ export function describeCall(name: string, args: Record<string, unknown>): { label: string; detail: string } {
329
+ const a = args ?? {};
330
+ const s = (v: unknown): string => (v == null ? "" : String(v));
331
+ switch (name) {
332
+ case "bash":
333
+ return { label: "shell", detail: s(a.command) };
334
+ case "read_file":
335
+ return { label: "read", detail: s(a.path) };
336
+ case "write_file":
337
+ return { label: "write", detail: s(a.path) };
338
+ case "edit_file":
339
+ return { label: "edit", detail: s(a.path) };
340
+ case "apply_patch":
341
+ return { label: "patch", detail: "" };
342
+ case "ls":
343
+ return { label: "list", detail: s(a.path) || "." };
344
+ case "glob":
345
+ return { label: "find", detail: s(a.pattern) };
346
+ case "grep":
347
+ return { label: "search", detail: s(a.pattern) };
348
+ case "web_fetch":
349
+ return { label: "fetch", detail: s(a.url) };
350
+ case "web_search":
351
+ return { label: "web", detail: s(a.query) };
352
+ case "use_skill":
353
+ return { label: "skill", detail: s(a.name) };
354
+ case "spawn_agent":
355
+ return { label: "sub-agent", detail: s(a.task) };
356
+ case "background_task":
357
+ return { label: "background", detail: s(a.task) };
358
+ default:
359
+ if (name.includes("__")) return { label: name.split("__")[0]!, detail: name.split("__").slice(1).join("__") };
360
+ return { label: name, detail: summarize(a) };
361
+ }
362
+ }
363
+
364
+ /** What permission the call is asking for, in plain words (for the approval prompt). */
365
+ export function permPhrase(name: string, destructive: boolean): string {
366
+ if (name === "bash") return destructive ? "⚠ run a shell command that may modify your system" : "run a shell command on your machine";
367
+ if (name === "write_file" || name === "edit_file" || name === "apply_patch") return "create or modify files on disk";
368
+ if (name === "web_fetch" || name === "web_search") return "make a network request";
369
+ if (name.includes("__")) return `use the ${name.split("__")[0]} connector`;
370
+ return `run the ${name} tool`;
371
+ }
372
+
373
+ async function safeRun(tool: Tool, args: Record<string, unknown>): Promise<ToolResult> {
374
+ try {
375
+ return await tool.run(args);
376
+ } catch (e) {
377
+ return { output: String(e), isError: true };
378
+ }
379
+ }
380
+
381
+ function isTransient(e: unknown): boolean {
382
+ const status = (e as { status?: number }).status;
383
+ if (status && [408, 409, 429, 500, 502, 503, 504, 529].includes(status)) return true;
384
+ const msg = (e instanceof Error ? e.message : String(e)).toLowerCase();
385
+ return /timeout|econn|temporarily|overloaded|rate.?limit|fetch failed|socket hang/.test(msg);
386
+ }
387
+
388
+ /** Run `fn`, retrying transient failures (429/5xx/network) with exponential backoff. */
389
+ async function withRetry<T>(fn: () => Promise<T>, signal: AbortSignal | undefined, max = 3): Promise<T> {
390
+ let delay = 800;
391
+ for (let attempt = 0; ; attempt++) {
392
+ try {
393
+ return await fn();
394
+ } catch (e) {
395
+ if (signal?.aborted || attempt >= max || !isTransient(e)) throw e;
396
+ process.stdout.write(`\x1b[2m[retrying in ${(delay / 1000).toFixed(1)}s — ${e instanceof Error ? e.message : e}]\x1b[0m\n`);
397
+ await new Promise((r) => setTimeout(r, delay));
398
+ delay *= 2;
399
+ }
400
+ }
401
+ }
402
+
403
+ export class Agent {
404
+ model: string;
405
+ reasoning?: "low" | "medium" | "high";
406
+ planMode = false;
407
+ private client: OpenAI;
408
+ private messages: Msg[];
409
+ private session: Session;
410
+ private onApprove: OnApprove;
411
+ private autoApprove: boolean;
412
+ private compactAt: number;
413
+ private apiTools: ToolDef[];
414
+ private promptTokens = 0;
415
+ private completionTokens = 0;
416
+ private lastAssistant = "";
417
+ private strategy = "react"; // orchestration architecture (see ORCHESTRATORS)
418
+ private pendingNote: string | null = null; // transient skill-routing hint for the next model turn
419
+
420
+ constructor(opts: {
421
+ client: OpenAI;
422
+ model: string;
423
+ session: Session;
424
+ onApprove: OnApprove;
425
+ autoApprove?: boolean;
426
+ reasoning?: "low" | "medium" | "high";
427
+ project?: boolean;
428
+ compactAt?: number;
429
+ history?: Msg[];
430
+ }) {
431
+ this.client = opts.client;
432
+ this.model = opts.model;
433
+ this.reasoning = opts.reasoning;
434
+ this.session = opts.session;
435
+ this.onApprove = opts.onApprove;
436
+ this.autoApprove = !!opts.autoApprove;
437
+ this.compactAt = opts.compactAt || COMPACT_AT;
438
+ this.apiTools = buildApiTools(); // snapshot the registry (incl. extension/skill/MCP tools) at construction
439
+ this.messages = [{ role: "system", content: systemPrompt(opts.project ?? true) }, ...(opts.history ?? [])];
440
+ }
441
+
442
+ setModel(m: string): void {
443
+ this.model = m;
444
+ }
445
+
446
+ setStrategy(s: string): void {
447
+ this.strategy = s;
448
+ }
449
+
450
+ getStrategy(): string {
451
+ return this.strategy;
452
+ }
453
+
454
+ /** Inject a system message (used by named-agent profiles). */
455
+ pushSystem(text: string): void {
456
+ const m: Msg = { role: "system", content: text };
457
+ this.messages.push(m);
458
+ this.session.append(m);
459
+ }
460
+
461
+ setOnApprove(fn: OnApprove): void {
462
+ this.onApprove = fn;
463
+ }
464
+
465
+ setReasoning(r: "low" | "medium" | "high" | undefined): void {
466
+ this.reasoning = r;
467
+ }
468
+
469
+ setAutoApprove(on: boolean): void {
470
+ this.autoApprove = on;
471
+ }
472
+
473
+ setPlanMode(on: boolean): void {
474
+ this.planMode = on;
475
+ }
476
+
477
+ /** Branch the conversation: future messages go to a new session; returns its file. */
478
+ fork(): string {
479
+ this.session = Session.fork(this.session.file, this.messages);
480
+ return this.session.file;
481
+ }
482
+
483
+ /** Time-travel: drop the last turn from context, back to the previous user message. */
484
+ rewind(): string {
485
+ let i = this.messages.length - 1;
486
+ while (i > 0 && this.messages[i]!.role !== "user") i--;
487
+ if (i <= 0) return "Nothing to rewind.";
488
+ const removed = this.messages.length - i;
489
+ this.messages = this.messages.slice(0, i);
490
+ return `Rewound ${removed} message(s); context now ~${estimateTokens(this.messages)} est. tokens.`;
491
+ }
492
+
493
+ async send(input: string, ctrl?: SendCtrl): Promise<string> {
494
+ let replyStarted = false;
495
+ const say = (s: string): void => {
496
+ if (ctrl?.quiet) return;
497
+ if (!replyStarted && s.trim()) {
498
+ replyStarted = true;
499
+ ctrl?.onReplyStart?.(); // first visible output of the turn — let the TUI swap spinner → ◆
500
+ s = s.replace(/^\n+/, ""); // already on a fresh line; drop the leading blank
501
+ }
502
+ process.stdout.write(s);
503
+ };
504
+ const interrupted = (): void => say("\n\x1b[2m[interrupted]\x1b[0m\n");
505
+ const drainSteer = (): boolean => {
506
+ const queued = ctrl?.steer?.splice(0) ?? [];
507
+ for (const s of queued) {
508
+ const m: Msg = { role: "user", content: s };
509
+ this.messages.push(m);
510
+ this.session.append(m);
511
+ }
512
+ return queued.length > 0;
513
+ };
514
+
515
+ input = await transformInput(input);
516
+ const userMsg: Msg = {
517
+ role: "user",
518
+ content: ctrl?.images?.length
519
+ ? ([{ type: "text", text: input }, ...ctrl.images.map((url) => ({ type: "image_url", image_url: { url } }))] as OpenAI.Chat.Completions.ChatCompletionContentPart[])
520
+ : input,
521
+ };
522
+ this.messages.push(userMsg);
523
+ this.session.append(userMsg);
524
+
525
+ if (estimateTokens(this.messages) > this.compactAt) await this.autoCompact("size threshold");
526
+ if (!ctrl?.quiet) {
527
+ // Orchestrate skills: when one clearly fits, apply it (inject its procedure into context so
528
+ // even a weak model follows it, persisted across the tool loop). Otherwise, a soft hint.
529
+ const fit = routeConfident(input);
530
+ if (fit) {
531
+ const sys: Msg = { role: "system", content: `A skill fits this request: "${fit.name}". Follow its procedure for this task unless it clearly doesn't fit what was asked, in which case ignore it and proceed.\n\n${fit.body}` };
532
+ this.messages.push(sys);
533
+ this.session.append(sys);
534
+ say(`\x1b[2m↳ skill: ${fit.name}\x1b[0m\n`);
535
+ } else {
536
+ this.pendingNote = suggestSkillNote(input);
537
+ }
538
+ }
539
+
540
+ const engine = this.makeEngine(ctrl, say, interrupted, drainSteer);
541
+ await (ORCHESTRATORS[this.strategy] ?? reAct).run(engine);
542
+ return this.lastAssistant;
543
+ }
544
+
545
+ // ---- Engine: the harness primitives an Orchestrator composes ----
546
+
547
+ private makeEngine(ctrl: SendCtrl | undefined, say: (s: string) => void, interrupted: () => void, drainSteer: () => boolean): Engine {
548
+ const signal = ctrl?.signal;
549
+ return {
550
+ step: (opts) => this.modelTurn(ctrl, say, interrupted, opts),
551
+ runTools: (calls) => this.execTools(calls, ctrl, say),
552
+ say,
553
+ interrupted,
554
+ addSystem: (text) => {
555
+ const m: Msg = { role: "system", content: text };
556
+ this.messages.push(m);
557
+ this.session.append(m);
558
+ },
559
+ aborted: () => !!signal?.aborted,
560
+ drainSteer,
561
+ spawn: (prompt) => this.spawnSub(prompt),
562
+ soleIntegration,
563
+ readDocs: async (name) => readIntegrationDocs(name),
564
+ writeSkills: async (drafts) => writeProjectSkills(drafts),
565
+ };
566
+ }
567
+
568
+ /** A fresh, headless sub-agent (autoApprove, quiet). Returns its final text. */
569
+ private async spawnSub(prompt: string): Promise<string> {
570
+ const sub = new Agent({ client: this.client, model: this.model, session: Session.create(), onApprove: this.onApprove, autoApprove: true, project: false });
571
+ return sub.send(prompt, { quiet: true });
572
+ }
573
+
574
+ /** One model turn: stream, collect content + tool calls (recovering leaked ones), push the
575
+ * assistant message. Returns null if interrupted. Retries once on context overflow. */
576
+ private async modelTurn(ctrl: SendCtrl | undefined, say: (s: string) => void, interrupted: () => void, opts?: { allowTools?: boolean; note?: string }): Promise<StepResult | null> {
577
+ const signal = ctrl?.signal;
578
+ if (signal?.aborted) {
579
+ interrupted();
580
+ return null;
581
+ }
582
+ const suggest = this.pendingNote;
583
+ this.pendingNote = null; // consume once — the routing hint applies to this turn only
584
+ const note = [opts?.note ?? (this.planMode ? PLAN_NOTE : null), suggest].filter(Boolean).join("\n\n") || null;
585
+ let overflowRetried = false;
586
+ for (;;) {
587
+ const create = () =>
588
+ this.client.chat.completions.create(
589
+ {
590
+ model: this.model,
591
+ messages: note ? [...this.messages, { role: "system", content: note }] : this.messages,
592
+ tools: this.apiTools,
593
+ tool_choice: opts?.allowTools === false ? "none" : "auto",
594
+ stream: true,
595
+ stream_options: { include_usage: true },
596
+ ...(this.reasoning ? { reasoning_effort: this.reasoning } : {}),
597
+ },
598
+ signal ? { signal } : undefined,
599
+ );
600
+ let stream: Awaited<ReturnType<typeof create>>;
601
+ try {
602
+ stream = await withRetry(create, signal);
603
+ } catch (e) {
604
+ if (signal?.aborted) {
605
+ interrupted();
606
+ return null;
607
+ }
608
+ if (!overflowRetried && isContextOverflowError(e)) {
609
+ overflowRetried = true;
610
+ await this.autoCompact("context overflow");
611
+ continue;
612
+ }
613
+ throw e;
614
+ }
615
+
616
+ let content = "";
617
+ const md = new MarkdownStreamer();
618
+ const calls: Array<{ id: string; name: string; args: string }> = [];
619
+ // If the reply opens like a leaked tool call (raw JSON / <tool_call> / fence), hold the
620
+ // text back instead of streaming it — we may recover it as a real call after the stream.
621
+ let bufferMode = false;
622
+ let sniffed = false;
623
+ try {
624
+ for await (const chunk of stream) {
625
+ if (chunk.usage) {
626
+ this.promptTokens += chunk.usage.prompt_tokens ?? 0;
627
+ this.completionTokens += chunk.usage.completion_tokens ?? 0;
628
+ }
629
+ const delta = chunk.choices[0]?.delta;
630
+ if (delta?.content) {
631
+ content += delta.content;
632
+ if (!sniffed && content.trim()) {
633
+ sniffed = true;
634
+ bufferMode = /^(```(?:json|tool)|<tool_call>|[[{])/i.test(content.trimStart());
635
+ }
636
+ if (!bufferMode) say(md.push(delta.content));
637
+ }
638
+ for (const tc of delta?.tool_calls ?? []) {
639
+ let entry = calls[tc.index];
640
+ if (!entry) {
641
+ entry = { id: "", name: "", args: "" };
642
+ calls[tc.index] = entry;
643
+ }
644
+ if (tc.id) entry.id = tc.id;
645
+ if (tc.function?.name) entry.name += tc.function.name;
646
+ if (tc.function?.arguments) entry.args += tc.function.arguments;
647
+ }
648
+ }
649
+ } catch (e) {
650
+ say(md.end());
651
+ if (signal?.aborted) {
652
+ interrupted();
653
+ return null;
654
+ }
655
+ throw e;
656
+ }
657
+ if (!bufferMode) say(md.end());
658
+
659
+ let toolCalls = calls.filter((c): c is { id: string; name: string; args: string } => !!c);
660
+
661
+ // Recover tool calls the provider leaked into the text (Ollama-over-stream, weak models).
662
+ if (!toolCalls.length && bufferMode) {
663
+ const parsed = parseTextToolCalls(content);
664
+ if (parsed) {
665
+ toolCalls = parsed.map((p, i) => ({ id: `text_${this.completionTokens}_${i}`, name: p.name, args: p.args }));
666
+ content = "";
667
+ } else {
668
+ say(md.push(content) + md.end()); // looked like a call but isn't runnable — show it
669
+ }
670
+ }
671
+
672
+ const assistantMsg: Msg = toolCalls.length
673
+ ? {
674
+ role: "assistant",
675
+ content: content || null,
676
+ tool_calls: toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })),
677
+ }
678
+ : { role: "assistant", content };
679
+ this.messages.push(assistantMsg);
680
+ this.session.append(assistantMsg);
681
+ this.lastAssistant = content;
682
+ return { content, toolCalls };
683
+ }
684
+ }
685
+
686
+ /** Run a turn's tool calls (read-only in parallel, gated ones sequentially with approval) and
687
+ * append one tool message per call. */
688
+ private async execTools(toolCalls: ToolCall[], ctrl: SendCtrl | undefined, say: (s: string) => void): Promise<void> {
689
+ const signal = ctrl?.signal;
690
+ const printCall = (name: string, args: Record<string, unknown>): void => {
691
+ const d = describeCall(name, args);
692
+ const detail = d.detail ? ` ${d.detail.length > 100 ? `${d.detail.slice(0, 99)}…` : d.detail}` : "";
693
+ say(`\n\x1b[2m• ${name}${detail}\x1b[0m\n`);
694
+ };
695
+ const printResult = (r: ToolResult): void => {
696
+ if (r.display) say(`${r.display}\n`);
697
+ else if (r.isError) say(`\x1b[31m ${r.output.split("\n")[0]}\x1b[0m\n`);
698
+ };
699
+ const argsOf = (s: string): Record<string, unknown> => {
700
+ try {
701
+ return JSON.parse(s || "{}");
702
+ } catch {
703
+ return {};
704
+ }
705
+ };
706
+ const runTool = async (tool: Tool, name: string, a: Record<string, unknown>): Promise<ToolResult> => {
707
+ const pre = await beforeTool(name, a);
708
+ if (pre.deny) return { output: pre.deny };
709
+ return afterTool(name, pre.args, await safeRun(tool, pre.args));
710
+ };
711
+
712
+ const results = new Array<ToolResult>(toolCalls.length);
713
+ const parallel: number[] = []; // read-only tools — safe to run concurrently
714
+ for (let i = 0; i < toolCalls.length; i++) {
715
+ const c = toolCalls[i]!;
716
+ const args = argsOf(c.args);
717
+ const tool = toolByName.get(c.name);
718
+ if (signal?.aborted) {
719
+ results[i] = { output: "[interrupted by user]" }; // keep every tool_call paired with a result
720
+ continue;
721
+ }
722
+ if (!tool) {
723
+ printCall(c.name, args);
724
+ results[i] = { output: `Unknown tool: ${c.name}`, isError: true };
725
+ continue;
726
+ }
727
+ const perm = permissionFor(c.name, summarize(args)); // configured allow/ask/deny rule, if any
728
+ if (perm === "deny") {
729
+ printCall(c.name, args);
730
+ results[i] = { output: "Denied by permission policy.", isError: true };
731
+ printResult(results[i]!);
732
+ continue;
733
+ }
734
+ if (!tool.needsApproval && perm !== "ask") {
735
+ parallel.push(i);
736
+ continue;
737
+ }
738
+ // gated tool (or a rule forces "ask") → sequential (so prompts and same-file writes don't race)
739
+ printCall(c.name, args);
740
+ if (this.planMode && tool.needsApproval) {
741
+ results[i] = { output: "Plan mode: not executing — finish the plan; the user approves with /run." };
742
+ printResult(results[i]!);
743
+ continue;
744
+ }
745
+ const forceConfirm = c.name === "bash" && isDestructive(String(args.command ?? ""));
746
+ const autoOk = (this.autoApprove || perm === "allow") && !forceConfirm && perm !== "ask";
747
+ if (autoOk) {
748
+ results[i] = await runTool(tool, c.name, args);
749
+ } else {
750
+ const decision = await this.onApprove(c.name, `${permPhrase(c.name, forceConfirm)}\n${describeCall(c.name, args).detail}`);
751
+ if (decision === "all") {
752
+ this.autoApprove = true;
753
+ results[i] = await runTool(tool, c.name, args);
754
+ } else if (decision === "no") {
755
+ results[i] = { output: "Denied by user." };
756
+ } else {
757
+ results[i] = await runTool(tool, c.name, args);
758
+ }
759
+ }
760
+ printResult(results[i]!);
761
+ }
762
+ await Promise.all(
763
+ parallel.map(async (i) => {
764
+ const c = toolCalls[i]!;
765
+ const args = argsOf(c.args);
766
+ printCall(c.name, args);
767
+ results[i] = await runTool(toolByName.get(c.name)!, c.name, args);
768
+ printResult(results[i]!);
769
+ }),
770
+ );
771
+ for (let i = 0; i < toolCalls.length; i++) {
772
+ const toolMsg: Msg = { role: "tool", tool_call_id: toolCalls[i]!.id, content: results[i]!.output };
773
+ this.messages.push(toolMsg);
774
+ this.session.append(toolMsg);
775
+ }
776
+ }
777
+
778
+ async compactNow(): Promise<string> {
779
+ const before = estimateTokens(this.messages);
780
+ const result = await compact(this.client, this.model, this.messages);
781
+ if (!result) return "Nothing to compact yet.";
782
+ this.messages = result.messages;
783
+ return `Compacted context: ~${before} → ~${estimateTokens(this.messages)} est. tokens.`;
784
+ }
785
+
786
+ contextTokens(): number {
787
+ return estimateTokens(this.messages);
788
+ }
789
+
790
+ usageReport(): string {
791
+ const p = priceFor(this.model);
792
+ const cost = p ? (this.promptTokens / 1e6) * p[0] + (this.completionTokens / 1e6) * p[1] : null;
793
+ return `tokens: ${this.promptTokens} in / ${this.completionTokens} out${cost !== null ? ` · ~$${cost.toFixed(4)}` : " · (no price table for this model)"}`;
794
+ }
795
+
796
+ private async autoCompact(reason: string): Promise<void> {
797
+ const result = await compact(this.client, this.model, this.messages);
798
+ if (result) {
799
+ this.messages = result.messages;
800
+ process.stdout.write(`\x1b[2m[compacted earlier context — ${reason}]\x1b[0m\n`);
801
+ }
802
+ }
803
+ }