codeply-cli 0.1.1 → 0.3.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 (384) hide show
  1. package/THIRD_PARTY_NOTICES.md +63 -0
  2. package/bin/codeply.js +562 -259
  3. package/lib/agent.mjs +1721 -116
  4. package/lib/ai.js +1107 -184
  5. package/lib/apply-limit.js +33 -41
  6. package/lib/apply-patch.js +217 -0
  7. package/lib/arity-table.json +138 -0
  8. package/lib/arity.js +70 -0
  9. package/lib/auth.js +118 -112
  10. package/lib/commands.js +103 -0
  11. package/lib/config.js +392 -98
  12. package/lib/design-library/artifact-template.html +500 -0
  13. package/lib/design-library/assemble-artifact.mjs +21 -0
  14. package/lib/design-library/build.mjs +226 -0
  15. package/lib/design-library/cleanup.mjs +45 -0
  16. package/lib/design-library/index.json +22380 -0
  17. package/lib/design-library/make-table-data.mjs +39 -0
  18. package/lib/design-library/query.mjs +104 -0
  19. package/lib/diagnostics.js +192 -0
  20. package/lib/edit-engine.js +171 -14
  21. package/lib/github-agent.mjs +290 -0
  22. package/lib/history.js +75 -0
  23. package/lib/mcp.js +258 -0
  24. package/lib/native-tools.mjs +189 -0
  25. package/lib/oauth-connectors.js +478 -0
  26. package/lib/permissions.js +109 -0
  27. package/lib/plugins.js +353 -0
  28. package/lib/render-md.mjs +617 -617
  29. package/lib/server.mjs +457 -0
  30. package/lib/session-db.js +220 -0
  31. package/lib/share.js +168 -0
  32. package/lib/skills.js +416 -0
  33. package/lib/snapshot.js +146 -0
  34. package/lib/subagents.js +164 -0
  35. package/lib/terminal-hints.js +154 -0
  36. package/lib/textarea.mjs +258 -258
  37. package/lib/tools.mjs +1709 -73
  38. package/lib/tui.js +7 -7
  39. package/lib/tui.mjs +1086 -899
  40. package/lib/typecheck-worker.js +147 -0
  41. package/lib/web-tools.js +158 -0
  42. package/package.json +5 -3
  43. package/skills/SOURCE.md +58 -0
  44. package/skills/accessibility/SKILL.md +147 -0
  45. package/skills/agent-architecture-audit/SKILL.md +257 -0
  46. package/skills/agent-eval/SKILL.md +147 -0
  47. package/skills/agent-harness-construction/SKILL.md +74 -0
  48. package/skills/agent-introspection-debugging/SKILL.md +154 -0
  49. package/skills/agent-payment-x402/SKILL.md +225 -0
  50. package/skills/agent-self-evaluation/SKILL.md +182 -0
  51. package/skills/agent-self-evaluation/references/evaluation-criteria.md +71 -0
  52. package/skills/agent-self-evaluation/references/hook-integration.md +64 -0
  53. package/skills/agent-sort/SKILL.md +216 -0
  54. package/skills/agentic-engineering/SKILL.md +64 -0
  55. package/skills/agentic-os/SKILL.md +388 -0
  56. package/skills/ai-first-engineering/SKILL.md +52 -0
  57. package/skills/ai-regression-testing/SKILL.md +386 -0
  58. package/skills/android-clean-architecture/SKILL.md +340 -0
  59. package/skills/angular-developer/SKILL.md +155 -0
  60. package/skills/angular-developer/references/angular-animations.md +160 -0
  61. package/skills/angular-developer/references/angular-aria.md +410 -0
  62. package/skills/angular-developer/references/cli.md +86 -0
  63. package/skills/angular-developer/references/component-harnesses.md +59 -0
  64. package/skills/angular-developer/references/component-styling.md +91 -0
  65. package/skills/angular-developer/references/components.md +117 -0
  66. package/skills/angular-developer/references/creating-services.md +97 -0
  67. package/skills/angular-developer/references/data-resolvers.md +69 -0
  68. package/skills/angular-developer/references/define-routes.md +67 -0
  69. package/skills/angular-developer/references/defining-providers.md +72 -0
  70. package/skills/angular-developer/references/di-fundamentals.md +120 -0
  71. package/skills/angular-developer/references/e2e-testing.md +56 -0
  72. package/skills/angular-developer/references/effects.md +83 -0
  73. package/skills/angular-developer/references/hierarchical-injectors.md +43 -0
  74. package/skills/angular-developer/references/host-elements.md +80 -0
  75. package/skills/angular-developer/references/injection-context.md +63 -0
  76. package/skills/angular-developer/references/inputs.md +101 -0
  77. package/skills/angular-developer/references/linked-signal.md +59 -0
  78. package/skills/angular-developer/references/loading-strategies.md +61 -0
  79. package/skills/angular-developer/references/mcp.md +108 -0
  80. package/skills/angular-developer/references/navigate-to-routes.md +69 -0
  81. package/skills/angular-developer/references/outputs.md +86 -0
  82. package/skills/angular-developer/references/reactive-forms.md +122 -0
  83. package/skills/angular-developer/references/rendering-strategies.md +44 -0
  84. package/skills/angular-developer/references/resource.md +77 -0
  85. package/skills/angular-developer/references/route-animations.md +56 -0
  86. package/skills/angular-developer/references/route-guards.md +52 -0
  87. package/skills/angular-developer/references/router-lifecycle.md +45 -0
  88. package/skills/angular-developer/references/router-testing.md +87 -0
  89. package/skills/angular-developer/references/show-routes-with-outlets.md +68 -0
  90. package/skills/angular-developer/references/signal-forms.md +795 -0
  91. package/skills/angular-developer/references/signals-overview.md +94 -0
  92. package/skills/angular-developer/references/tailwind-css.md +69 -0
  93. package/skills/angular-developer/references/template-driven-forms.md +114 -0
  94. package/skills/angular-developer/references/testing-fundamentals.md +65 -0
  95. package/skills/api-connector-builder/SKILL.md +121 -0
  96. package/skills/api-design/SKILL.md +524 -0
  97. package/skills/architecture-decision-records/SKILL.md +180 -0
  98. package/skills/article-writing/SKILL.md +80 -0
  99. package/skills/automation-audit-ops/SKILL.md +143 -0
  100. package/skills/autonomous-agent-harness/SKILL.md +274 -0
  101. package/skills/autonomous-loops/SKILL.md +611 -0
  102. package/skills/backend-patterns/SKILL.md +562 -0
  103. package/skills/benchmark/SKILL.md +95 -0
  104. package/skills/benchmark-methodology/SKILL.md +191 -0
  105. package/skills/benchmark-optimization-loop/SKILL.md +71 -0
  106. package/skills/blender-motion-state-inspection/SKILL.md +165 -0
  107. package/skills/blueprint/SKILL.md +106 -0
  108. package/skills/brand-discovery/SKILL.md +145 -0
  109. package/skills/brand-discovery/references/10_purpose-why.md +40 -0
  110. package/skills/brand-discovery/references/20_positioning.md +44 -0
  111. package/skills/brand-discovery/references/30_audience-niche.md +52 -0
  112. package/skills/brand-discovery/references/40_personality-archetype.md +57 -0
  113. package/skills/brand-discovery/references/50_voice-tone.md +59 -0
  114. package/skills/brand-discovery/references/60_narrative-story.md +50 -0
  115. package/skills/brand-discovery/references/70_founder-tension.md +49 -0
  116. package/skills/brand-discovery/references/90_SYNTHESIS.md +133 -0
  117. package/skills/brand-voice/SKILL.md +98 -0
  118. package/skills/brand-voice/references/voice-profile-schema.md +55 -0
  119. package/skills/browser-qa/SKILL.md +105 -0
  120. package/skills/bun-runtime/SKILL.md +85 -0
  121. package/skills/canary-watch/SKILL.md +108 -0
  122. package/skills/carrier-relationship-management/SKILL.md +212 -0
  123. package/skills/cisco-ios-patterns/SKILL.md +164 -0
  124. package/skills/ck/SKILL.md +148 -0
  125. package/skills/claude-devfleet/SKILL.md +112 -0
  126. package/skills/click-path-audit/SKILL.md +245 -0
  127. package/skills/clickhouse-io/SKILL.md +445 -0
  128. package/skills/code-tour/SKILL.md +254 -0
  129. package/skills/codebase-onboarding/SKILL.md +234 -0
  130. package/skills/codehealth-mcp/SKILL.md +167 -0
  131. package/skills/coding-standards/SKILL.md +551 -0
  132. package/skills/competitive-platform-analysis/SKILL.md +214 -0
  133. package/skills/competitive-report-structure/SKILL.md +162 -0
  134. package/skills/compose-multiplatform-patterns/SKILL.md +300 -0
  135. package/skills/config-gc/SKILL.md +120 -0
  136. package/skills/configure-ecc/SKILL.md +385 -0
  137. package/skills/connections-optimizer/SKILL.md +190 -0
  138. package/skills/content-engine/SKILL.md +132 -0
  139. package/skills/content-hash-cache-pattern/SKILL.md +162 -0
  140. package/skills/context-budget/SKILL.md +136 -0
  141. package/skills/continuous-agent-loop/SKILL.md +46 -0
  142. package/skills/continuous-learning/SKILL.md +132 -0
  143. package/skills/continuous-learning-v2/SKILL.md +361 -0
  144. package/skills/contract-first/SKILL.md +287 -0
  145. package/skills/cost-aware-llm-pipeline/SKILL.md +184 -0
  146. package/skills/cost-tracking/SKILL.md +97 -0
  147. package/skills/council/SKILL.md +204 -0
  148. package/skills/cpp-coding-standards/SKILL.md +724 -0
  149. package/skills/cpp-testing/SKILL.md +325 -0
  150. package/skills/crosspost/SKILL.md +112 -0
  151. package/skills/csharp-testing/SKILL.md +322 -0
  152. package/skills/customer-billing-ops/SKILL.md +141 -0
  153. package/skills/customs-trade-compliance/SKILL.md +263 -0
  154. package/skills/dart-flutter-patterns/SKILL.md +564 -0
  155. package/skills/dashboard-builder/SKILL.md +109 -0
  156. package/skills/data-scraper-agent/SKILL.md +765 -0
  157. package/skills/data-throughput-accelerator/SKILL.md +74 -0
  158. package/skills/database-migrations/SKILL.md +430 -0
  159. package/skills/deep-research/SKILL.md +160 -0
  160. package/skills/defi-amm-security/SKILL.md +167 -0
  161. package/skills/delivery-gate/SKILL.md +126 -0
  162. package/skills/deployment-patterns/SKILL.md +428 -0
  163. package/skills/design-system/SKILL.md +83 -0
  164. package/skills/django-celery/SKILL.md +458 -0
  165. package/skills/django-patterns/SKILL.md +735 -0
  166. package/skills/django-security/SKILL.md +644 -0
  167. package/skills/django-tdd/SKILL.md +730 -0
  168. package/skills/django-verification/SKILL.md +470 -0
  169. package/skills/dmux-workflows/SKILL.md +192 -0
  170. package/skills/docker-patterns/SKILL.md +365 -0
  171. package/skills/documentation-lookup/SKILL.md +91 -0
  172. package/skills/dotnet-patterns/SKILL.md +322 -0
  173. package/skills/dynamic-workflow-mode/SKILL.md +124 -0
  174. package/skills/e2e-testing/SKILL.md +327 -0
  175. package/skills/ecc-guide/SKILL.md +190 -0
  176. package/skills/ecc-recipes/SKILL.md +149 -0
  177. package/skills/ecc-tools-cost-audit/SKILL.md +161 -0
  178. package/skills/email-ops/SKILL.md +122 -0
  179. package/skills/energy-procurement/SKILL.md +228 -0
  180. package/skills/enterprise-agent-ops/SKILL.md +51 -0
  181. package/skills/error-handling/SKILL.md +377 -0
  182. package/skills/eval-harness/SKILL.md +271 -0
  183. package/skills/evm-token-decimals/SKILL.md +131 -0
  184. package/skills/exa-search/SKILL.md +108 -0
  185. package/skills/fal-ai-media/SKILL.md +289 -0
  186. package/skills/fastapi-patterns/SKILL.md +514 -0
  187. package/skills/finance-billing-ops/SKILL.md +128 -0
  188. package/skills/flox-environments/SKILL.md +497 -0
  189. package/skills/flutter-dart-code-review/SKILL.md +436 -0
  190. package/skills/foundation-models-on-device/SKILL.md +243 -0
  191. package/skills/frontend-a11y/SKILL.md +446 -0
  192. package/skills/frontend-design-direction/SKILL.md +93 -0
  193. package/skills/frontend-patterns/SKILL.md +657 -0
  194. package/skills/frontend-slides/SKILL.md +185 -0
  195. package/skills/fsharp-testing/SKILL.md +281 -0
  196. package/skills/gan-style-harness/SKILL.md +279 -0
  197. package/skills/gateguard/SKILL.md +133 -0
  198. package/skills/generating-python-installer/SKILL.md +820 -0
  199. package/skills/git-workflow/SKILL.md +716 -0
  200. package/skills/github-ops/SKILL.md +145 -0
  201. package/skills/golang-patterns/SKILL.md +676 -0
  202. package/skills/golang-testing/SKILL.md +721 -0
  203. package/skills/google-workspace-ops/SKILL.md +96 -0
  204. package/skills/growth-log/SKILL.md +128 -0
  205. package/skills/healthcare-cdss-patterns/SKILL.md +246 -0
  206. package/skills/healthcare-emr-patterns/SKILL.md +160 -0
  207. package/skills/healthcare-eval-harness/SKILL.md +208 -0
  208. package/skills/healthcare-phi-compliance/SKILL.md +146 -0
  209. package/skills/hermes-imports/SKILL.md +89 -0
  210. package/skills/hexagonal-architecture/SKILL.md +277 -0
  211. package/skills/hipaa-compliance/SKILL.md +79 -0
  212. package/skills/homelab-network-readiness/SKILL.md +170 -0
  213. package/skills/homelab-network-setup/SKILL.md +130 -0
  214. package/skills/homelab-pihole-dns/SKILL.md +275 -0
  215. package/skills/homelab-vlan-segmentation/SKILL.md +312 -0
  216. package/skills/homelab-wireguard-vpn/SKILL.md +306 -0
  217. package/skills/hookify-rules/SKILL.md +128 -0
  218. package/skills/inherit-legacy-style/SKILL.md +157 -0
  219. package/skills/intent-driven-development/SKILL.md +360 -0
  220. package/skills/inventory-demand-planning/SKILL.md +247 -0
  221. package/skills/investor-materials/SKILL.md +97 -0
  222. package/skills/investor-outreach/SKILL.md +92 -0
  223. package/skills/ios-icon-gen/SKILL.md +158 -0
  224. package/skills/iterative-retrieval/SKILL.md +212 -0
  225. package/skills/ito-basket-compare/SKILL.md +64 -0
  226. package/skills/ito-compute/SKILL.md +129 -0
  227. package/skills/ito-data-atlas-agent/SKILL.md +64 -0
  228. package/skills/ito-market-intelligence/SKILL.md +61 -0
  229. package/skills/ito-trade-planner/SKILL.md +68 -0
  230. package/skills/java-coding-standards/SKILL.md +384 -0
  231. package/skills/jira-integration/SKILL.md +303 -0
  232. package/skills/jpa-patterns/SKILL.md +152 -0
  233. package/skills/knowledge-ops/SKILL.md +155 -0
  234. package/skills/kotlin-coroutines-flows/SKILL.md +285 -0
  235. package/skills/kotlin-exposed-patterns/SKILL.md +720 -0
  236. package/skills/kotlin-ktor-patterns/SKILL.md +690 -0
  237. package/skills/kotlin-patterns/SKILL.md +712 -0
  238. package/skills/kotlin-testing/SKILL.md +825 -0
  239. package/skills/kubernetes-patterns/SKILL.md +756 -0
  240. package/skills/laravel-patterns/SKILL.md +416 -0
  241. package/skills/laravel-plugin-discovery/SKILL.md +230 -0
  242. package/skills/laravel-security/SKILL.md +948 -0
  243. package/skills/laravel-tdd/SKILL.md +675 -0
  244. package/skills/laravel-verification/SKILL.md +180 -0
  245. package/skills/latency-critical-systems/SKILL.md +75 -0
  246. package/skills/lead-intelligence/SKILL.md +322 -0
  247. package/skills/liquid-glass-design/SKILL.md +279 -0
  248. package/skills/llm-trading-agent-security/SKILL.md +147 -0
  249. package/skills/logistics-exception-management/SKILL.md +222 -0
  250. package/skills/loop-design-check/SKILL.md +143 -0
  251. package/skills/mailtrap-email-integration/SKILL.md +77 -0
  252. package/skills/make-interfaces-feel-better/SKILL.md +152 -0
  253. package/skills/manim-video/SKILL.md +90 -0
  254. package/skills/market-research/SKILL.md +76 -0
  255. package/skills/marketing-campaign/SKILL.md +114 -0
  256. package/skills/mcp-server-patterns/SKILL.md +70 -0
  257. package/skills/messages-ops/SKILL.md +105 -0
  258. package/skills/ml-adoption-playbook/SKILL.md +57 -0
  259. package/skills/mle-workflow/SKILL.md +348 -0
  260. package/skills/motion-advanced/SKILL.md +596 -0
  261. package/skills/motion-foundations/SKILL.md +299 -0
  262. package/skills/motion-patterns/SKILL.md +434 -0
  263. package/skills/motion-ui/SKILL.md +576 -0
  264. package/skills/mysql-patterns/SKILL.md +413 -0
  265. package/skills/nanoclaw-repl/SKILL.md +34 -0
  266. package/skills/nestjs-patterns/SKILL.md +231 -0
  267. package/skills/netmiko-ssh-automation/SKILL.md +174 -0
  268. package/skills/network-bgp-diagnostics/SKILL.md +168 -0
  269. package/skills/network-config-validation/SKILL.md +211 -0
  270. package/skills/network-interface-health/SKILL.md +153 -0
  271. package/skills/nextjs-turbopack/SKILL.md +58 -0
  272. package/skills/nodejs-keccak256/SKILL.md +103 -0
  273. package/skills/nutrient-document-processing/SKILL.md +168 -0
  274. package/skills/nuxt4-patterns/SKILL.md +101 -0
  275. package/skills/openclaw-persona-forge/SKILL.md +289 -0
  276. package/skills/openclaw-persona-forge/references/avatar-style.md +124 -0
  277. package/skills/openclaw-persona-forge/references/boundary-rules.md +53 -0
  278. package/skills/openclaw-persona-forge/references/error-handling.md +53 -0
  279. package/skills/openclaw-persona-forge/references/identity-tension.md +48 -0
  280. package/skills/openclaw-persona-forge/references/naming-system.md +39 -0
  281. package/skills/openclaw-persona-forge/references/output-template.md +166 -0
  282. package/skills/opensource-pipeline/SKILL.md +256 -0
  283. package/skills/orch-add-feature/SKILL.md +45 -0
  284. package/skills/orch-build-mvp/SKILL.md +49 -0
  285. package/skills/orch-change-feature/SKILL.md +43 -0
  286. package/skills/orch-fix-defect/SKILL.md +43 -0
  287. package/skills/orch-pipeline/SKILL.md +121 -0
  288. package/skills/orch-refine-code/SKILL.md +44 -0
  289. package/skills/parallel-execution-optimizer/SKILL.md +74 -0
  290. package/skills/perl-patterns/SKILL.md +505 -0
  291. package/skills/perl-security/SKILL.md +504 -0
  292. package/skills/perl-testing/SKILL.md +476 -0
  293. package/skills/plan-canvas/SKILL.md +153 -0
  294. package/skills/plan-orchestrate/SKILL.md +263 -0
  295. package/skills/plankton-code-quality/SKILL.md +237 -0
  296. package/skills/postgres-patterns/SKILL.md +148 -0
  297. package/skills/prediction-market-oracle-research/SKILL.md +64 -0
  298. package/skills/prediction-market-risk-review/SKILL.md +61 -0
  299. package/skills/premium-web-design/SKILL.md +154 -0
  300. package/skills/prisma-patterns/SKILL.md +401 -0
  301. package/skills/product-capability/SKILL.md +142 -0
  302. package/skills/product-lens/SKILL.md +93 -0
  303. package/skills/production-audit/SKILL.md +207 -0
  304. package/skills/production-scheduling/SKILL.md +238 -0
  305. package/skills/project-flow-ops/SKILL.md +112 -0
  306. package/skills/prompt-optimizer/SKILL.md +398 -0
  307. package/skills/python-patterns/SKILL.md +751 -0
  308. package/skills/python-testing/SKILL.md +817 -0
  309. package/skills/pytorch-patterns/SKILL.md +397 -0
  310. package/skills/quality-nonconformance/SKILL.md +260 -0
  311. package/skills/quarkus-patterns/SKILL.md +723 -0
  312. package/skills/quarkus-security/SKILL.md +468 -0
  313. package/skills/quarkus-tdd/SKILL.md +812 -0
  314. package/skills/quarkus-verification/SKILL.md +480 -0
  315. package/skills/ralphinho-rfc-pipeline/SKILL.md +68 -0
  316. package/skills/react-native-patterns/SKILL.md +326 -0
  317. package/skills/react-patterns/SKILL.md +342 -0
  318. package/skills/react-performance/SKILL.md +575 -0
  319. package/skills/react-testing/SKILL.md +424 -0
  320. package/skills/recsys-pipeline-architect/SKILL.md +115 -0
  321. package/skills/recursive-decision-ledger/SKILL.md +81 -0
  322. package/skills/redis-patterns/SKILL.md +404 -0
  323. package/skills/regex-vs-llm-structured-text/SKILL.md +221 -0
  324. package/skills/remotion-video-creation/SKILL.md +43 -0
  325. package/skills/repo-scan/SKILL.md +79 -0
  326. package/skills/research-ops/SKILL.md +113 -0
  327. package/skills/returns-reverse-logistics/SKILL.md +240 -0
  328. package/skills/rules-distill/SKILL.md +265 -0
  329. package/skills/rust-patterns/SKILL.md +500 -0
  330. package/skills/rust-testing/SKILL.md +501 -0
  331. package/skills/safety-guard/SKILL.md +76 -0
  332. package/skills/santa-method/SKILL.md +307 -0
  333. package/skills/scientific-db-pubmed-database/SKILL.md +176 -0
  334. package/skills/scientific-db-uspto-database/SKILL.md +178 -0
  335. package/skills/scientific-pkg-gget/SKILL.md +167 -0
  336. package/skills/scientific-thinking-literature-review/SKILL.md +193 -0
  337. package/skills/scientific-thinking-scholar-evaluation/SKILL.md +161 -0
  338. package/skills/search-first/SKILL.md +183 -0
  339. package/skills/security-bounty-hunter/SKILL.md +100 -0
  340. package/skills/security-review/SKILL.md +504 -0
  341. package/skills/security-scan/SKILL.md +166 -0
  342. package/skills/seo/SKILL.md +155 -0
  343. package/skills/skill-comply/SKILL.md +59 -0
  344. package/skills/skill-scout/SKILL.md +141 -0
  345. package/skills/skill-stocktake/SKILL.md +195 -0
  346. package/skills/social-graph-ranker/SKILL.md +155 -0
  347. package/skills/social-publisher/SKILL.md +130 -0
  348. package/skills/springboot-patterns/SKILL.md +315 -0
  349. package/skills/springboot-security/SKILL.md +273 -0
  350. package/skills/springboot-tdd/SKILL.md +159 -0
  351. package/skills/springboot-verification/SKILL.md +232 -0
  352. package/skills/strategic-compact/SKILL.md +142 -0
  353. package/skills/swift-actor-persistence/SKILL.md +144 -0
  354. package/skills/swift-concurrency-6-2/SKILL.md +216 -0
  355. package/skills/swift-protocol-di-testing/SKILL.md +191 -0
  356. package/skills/swiftui-patterns/SKILL.md +259 -0
  357. package/skills/taste/SKILL.md +264 -0
  358. package/skills/taste/references/genre-taxonomy.md +87 -0
  359. package/skills/tdd-workflow/SKILL.md +583 -0
  360. package/skills/team-agent-orchestration/SKILL.md +111 -0
  361. package/skills/team-builder/SKILL.md +169 -0
  362. package/skills/terminal-ops/SKILL.md +110 -0
  363. package/skills/tinystruct-patterns/SKILL.md +279 -0
  364. package/skills/tinystruct-patterns/references/architecture.md +90 -0
  365. package/skills/tinystruct-patterns/references/data-handling.md +60 -0
  366. package/skills/tinystruct-patterns/references/database.md +99 -0
  367. package/skills/tinystruct-patterns/references/routing.md +64 -0
  368. package/skills/tinystruct-patterns/references/system-usage.md +97 -0
  369. package/skills/tinystruct-patterns/references/testing.md +72 -0
  370. package/skills/token-budget-advisor/SKILL.md +134 -0
  371. package/skills/ui-demo/SKILL.md +466 -0
  372. package/skills/ui-to-vue/SKILL.md +135 -0
  373. package/skills/uncloud/SKILL.md +344 -0
  374. package/skills/unified-memory/SKILL.md +170 -0
  375. package/skills/unified-notifications-ops/SKILL.md +188 -0
  376. package/skills/verification-loop/SKILL.md +129 -0
  377. package/skills/video-editing/SKILL.md +311 -0
  378. package/skills/videodb/SKILL.md +375 -0
  379. package/skills/visa-doc-translate/SKILL.md +117 -0
  380. package/skills/vite-patterns/SKILL.md +450 -0
  381. package/skills/vue-patterns/SKILL.md +471 -0
  382. package/skills/windows-desktop-e2e/SKILL.md +888 -0
  383. package/skills/workspace-surface-audit/SKILL.md +126 -0
  384. package/skills/x-api/SKILL.md +235 -0
package/lib/ai.js CHANGED
@@ -1,184 +1,1107 @@
1
- /**
2
- * Codeply CLI — AI client
3
- *
4
- * Two backends, selected by lib/config.js:
5
- *
6
- * codeply the 'ai-proxy' Supabase Edge Function the desktop app uses (see
7
- * Codeply-App/supabase/functions/ai-proxy). No provider key lives in
8
- * this CLI; it authenticates with the signed-in session from
9
- * lib/auth.js, so usage counts against that account's daily cap.
10
- *
11
- * ollama Ollama's OpenAI-compatible /v1/chat/completions, either the cloud
12
- * (ollama.com, bearer key) or a local daemon (no key). No Codeply
13
- * sign-in required, and no daily cap.
14
- *
15
- * Both return the identical shape — {success, data:{choices:[{message}]}, …} —
16
- * so nothing downstream (agent loop, edit engine) knows or cares which ran.
17
- */
18
- const { SUPABASE_URL, SUPABASE_ANON_KEY, getAccessToken } = require('./auth');
19
- const { getConfig } = require('./config');
20
-
21
- const AI_PROXY_URL = `${SUPABASE_URL}/functions/v1/ai-proxy`;
22
-
23
- function isRateLimitError(msg) {
24
- const s = String(msg || '').toLowerCase();
25
- return s.includes('rate limit') || s.includes('429') || s.includes('tokens per day')
26
- || s.includes('tokens per minute') || s.includes(' tpd') || s.includes(' tpm')
27
- || s.includes('quota') || s.includes('daily ai request limit');
28
- }
29
-
30
- const MAX_ATTEMPTS = 3;
31
- const RETRY_BACKOFF_MS = [1200, 4000];
32
-
33
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
34
-
35
- /**
36
- * Statuses worth retrying. 546 is Supabase's own code for an Edge Function
37
- * killed for exceeding its resource limits (WORKER_LIMIT) — it fires on long
38
- * generations and is usually transient, so a retry is far more useful to the
39
- * user than surfacing "HTTP 546". 5xx and 429 get the same treatment.
40
- */
41
- function isTransientStatus(status) {
42
- return status === 429 || status >= 500;
43
- }
44
-
45
- /** Turn a bare status into something a user can act on. */
46
- function describeStatus(status) {
47
- if (status === 546) {
48
- return 'The AI service hit its resource limit mid-request (Supabase 546). ' +
49
- 'This usually happens on very long generations — try a narrower request, ' +
50
- 'or ask for a targeted edit instead of a full-file rewrite.';
51
- }
52
- if (status === 504 || status === 408) return 'The AI service timed out. Try a smaller request.';
53
- if (status === 429) return 'Rate limited by the AI service. Wait a moment and try again.';
54
- if (status >= 500) return `The AI service is temporarily unavailable (HTTP ${status}).`;
55
- return `HTTP ${status}`;
56
- }
57
-
58
- /**
59
- * Retry wrapper shared by both backends.
60
- * `send` returns { done, value } to stop, or { retryable, error } to try again.
61
- */
62
- async function withRetries(send) {
63
- let lastError = 'Request failed';
64
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
65
- if (attempt > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1] ?? 4000);
66
- const step = await send();
67
- if (step.done) return step.value;
68
- lastError = step.error;
69
- if (!step.retryable) return { success: false, error: lastError };
70
- }
71
- return { success: false, error: lastError };
72
- }
73
-
74
- async function chatViaProxy(messages, opts) {
75
- const token = await getAccessToken();
76
- if (!token) return { success: false, error: 'Not signed in. Run `codeply login` first.' };
77
-
78
- return withRetries(async () => {
79
- let res;
80
- try {
81
- res = await fetch(AI_PROXY_URL, {
82
- method: 'POST',
83
- headers: {
84
- 'Content-Type': 'application/json',
85
- 'Authorization': `Bearer ${token}`,
86
- 'apikey': SUPABASE_ANON_KEY,
87
- },
88
- // meta is optional, display-only context (what was asked, which file) —
89
- // the proxy logs it to usage_history so CLI activity shows up in the
90
- // admin dashboard next to the desktop app, same as this app's own calls.
91
- body: JSON.stringify({ messages, opts, meta: opts.meta }),
92
- });
93
- } catch (e) {
94
- // Network-level failure — also worth another go.
95
- return { retryable: true, error: e.message };
96
- }
97
-
98
- const body = await res.json().catch(() => ({}));
99
- if (res.ok && body.success) {
100
- return { done: true, value: { success: true, data: body.data, modelUsed: body.modelUsed } };
101
- }
102
-
103
- // A structured error from the function itself is final: it means the
104
- // function ran and deliberately rejected us (auth, daily cap, provider
105
- // error). Retrying that just burns the user's quota.
106
- if (body.error) return { done: true, value: { success: false, error: body.error } };
107
-
108
- return { retryable: isTransientStatus(res.status), error: describeStatus(res.status) };
109
- });
110
- }
111
-
112
- async function chatViaOllama(messages, opts, cfg) {
113
- const { host, model, apiKey } = cfg.ollama;
114
- const isLocal = /localhost|127\.0\.0\.1/.test(host);
115
- if (!apiKey && !isLocal) {
116
- return { success: false, error: `No Ollama API key set for ${host}. Run \`codeply provider ollama --key <key>\`.` };
117
- }
118
-
119
- const headers = { 'Content-Type': 'application/json' };
120
- if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
121
-
122
- return withRetries(async () => {
123
- let res;
124
- try {
125
- res = await fetch(`${host}/v1/chat/completions`, {
126
- method: 'POST',
127
- headers,
128
- body: JSON.stringify({
129
- model,
130
- messages,
131
- temperature: opts.temperature ?? 0,
132
- ...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
133
- ...(opts.json ? { response_format: { type: 'json_object' } } : {}),
134
- stream: false,
135
- }),
136
- });
137
- } catch (e) {
138
- const hint = isLocal ? ' Is `ollama serve` running?' : '';
139
- return { retryable: true, error: e.message + hint };
140
- }
141
-
142
- const body = await res.json().catch(() => ({}));
143
-
144
- if (res.ok && body.choices?.[0]) {
145
- // Already OpenAI-shaped, so it drops straight into the same call sites.
146
- return { done: true, value: { success: true, data: body, modelUsed: body.model || model } };
147
- }
148
-
149
- const apiError = body?.error?.message || body?.error;
150
- if (apiError) {
151
- const msg = typeof apiError === 'string' ? apiError : JSON.stringify(apiError);
152
- // A missing/unknown model won't fix itself on retry.
153
- const fatal = /not found|does not exist|unknown model|unauthor|invalid.*key|forbidden/i.test(msg);
154
- if (fatal) return { done: true, value: { success: false, error: `Ollama: ${msg}` } };
155
- return { retryable: isTransientStatus(res.status), error: `Ollama: ${msg}` };
156
- }
157
-
158
- return { retryable: isTransientStatus(res.status), error: describeStatus(res.status) };
159
- });
160
- }
161
-
162
- /**
163
- * @param {Array<{role:string, content:string}>} messages
164
- * @param {{ json?: boolean, maxTokens?: number, temperature?: number }} opts
165
- * @returns {Promise<{success:boolean, data?:object, error?:string, modelUsed?:string}>}
166
- */
167
- async function chat(messages, opts = {}) {
168
- const cfg = getConfig();
169
- return cfg.provider === 'ollama'
170
- ? chatViaOllama(messages, opts, cfg)
171
- : chatViaProxy(messages, opts);
172
- }
173
-
174
- /** Chat helper that expects a JSON object back and parses it. */
175
- async function chatJson(messages, opts = {}) {
176
- const r = await chat(messages, { ...opts, json: true });
177
- if (!r.success) return { success: false, error: r.error };
178
- let parsed = null;
179
- try { parsed = JSON.parse(r.data.choices[0].message.content); }
180
- catch { return { success: false, error: 'AI returned an unreadable response.' }; }
181
- return { success: true, json: parsed, usage: r.data.usage || {}, modelUsed: r.modelUsed };
182
- }
183
-
184
- module.exports = { chat, chatJson, isRateLimitError };
1
+ /**
2
+ * Codeply CLI - AI client
3
+ *
4
+ * Backends, selected by lib/config.js:
5
+ *
6
+ * codeply the 'ai-proxy' Supabase Edge Function the desktop app uses
7
+ * (see Codeply-App/supabase/functions/ai-proxy). No provider
8
+ * key lives in this CLI; it authenticates with the signed-in
9
+ * session from lib/auth.js, so usage counts against that
10
+ * account's shared daily cap.
11
+ *
12
+ * ollama Ollama's OpenAI-compatible /v1/chat/completions, either the
13
+ * cloud (ollama.com, bearer key) or a local daemon (no key).
14
+ * No Codeply sign-in required, and no daily cap.
15
+ *
16
+ * openrouter, groq, openai, google, qwen BYOK: the user's own key against
17
+ * that provider's OpenAI-compatible chat completions endpoint.
18
+ * No Codeply account involved at all. OpenRouter is itself an
19
+ * aggregator, so its model ids carry the upstream namespace
20
+ * ('google/gemma-4-26b-a4b-it:free', 'stealth/ox-alpha') - that
21
+ * is also how Codeply reaches models no first-party API serves.
22
+ *
23
+ * anthropic BYOK against Anthropic's Messages API, which is NOT
24
+ * OpenAI-compatible (different endpoint, a top-level `system`
25
+ * field instead of a system message, and a `content` array in
26
+ * the response) - handled by its own converter below.
27
+ *
28
+ * All of these return the identical shape - {success, data:{choices:[{message}]}, …} -
29
+ * so nothing downstream (agent loop, edit engine) knows or cares which ran.
30
+ */
31
+ const { SUPABASE_URL, SUPABASE_ANON_KEY, getAccessToken } = require('./auth');
32
+ const { getConfig, byokHint, PROVIDERS } = require('./config');
33
+
34
+ const AI_PROXY_URL = `${SUPABASE_URL}/functions/v1/ai-proxy`;
35
+
36
+ function isRateLimitError(msg) {
37
+ const s = String(msg || '').toLowerCase();
38
+ return s.includes('rate limit') || s.includes('429') || s.includes('tokens per day')
39
+ || s.includes('tokens per minute') || s.includes(' tpd') || s.includes(' tpm')
40
+ || s.includes('quota') || s.includes('daily ai request limit');
41
+ }
42
+
43
+ const MAX_ATTEMPTS = 4;
44
+ const RETRY_BACKOFF_MS = [1500, 4000, 9000];
45
+ const RETRY_AFTER_CAP_MS = 30000;
46
+
47
+ // Stop must not wait out a backoff: the sleep ends the moment the signal fires.
48
+ const sleep = (ms, signal) => new Promise((r) => {
49
+ if (signal?.aborted) return r();
50
+ const t = setTimeout(r, ms);
51
+ signal?.addEventListener('abort', () => { clearTimeout(t); r(); }, { once: true });
52
+ });
53
+
54
+ /**
55
+ * How long the server asked us to wait, from retry-after-ms / retry-after
56
+ * (seconds or an HTTP date). Same headers opencode's session/retry.ts honours.
57
+ */
58
+ function retryAfterMs(res) {
59
+ try {
60
+ const h = res && res.headers;
61
+ if (!h || typeof h.get !== 'function') return undefined;
62
+ const ms = Number(h.get('retry-after-ms'));
63
+ if (Number.isFinite(ms) && ms > 0) return ms;
64
+ const ra = h.get('retry-after');
65
+ if (!ra) return undefined;
66
+ const secs = Number(ra);
67
+ if (Number.isFinite(secs)) return secs * 1000;
68
+ const at = Date.parse(ra);
69
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
70
+ } catch { return undefined; }
71
+ }
72
+
73
+ /** Backoff with +-25% jitter so several clients don't retry in lockstep. */
74
+ function backoffMs(attempt, hinted) {
75
+ if (hinted !== undefined) return Math.min(RETRY_AFTER_CAP_MS, hinted);
76
+ const base = RETRY_BACKOFF_MS[attempt - 1] ?? RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1];
77
+ return Math.round(base * (0.75 + Math.random() * 0.5));
78
+ }
79
+
80
+ /**
81
+ * Statuses worth retrying. 546 is Supabase's own code for an Edge Function
82
+ * killed for exceeding its resource limits (WORKER_LIMIT) - it fires on long
83
+ * generations and is usually transient, so a retry is far more useful to the
84
+ * user than surfacing "HTTP 546". 5xx and 429 get the same treatment.
85
+ */
86
+ function isTransientStatus(status) {
87
+ return status === 429 || status >= 500;
88
+ }
89
+
90
+ /** Turn a bare status into something a user can act on. */
91
+ function describeStatus(status) {
92
+ if (status === 546) {
93
+ return 'The AI service hit its resource limit mid-request (Supabase 546). ' +
94
+ 'This usually happens on very long generations - try a narrower request, ' +
95
+ 'or ask for a targeted edit instead of a full-file rewrite.';
96
+ }
97
+ if (status === 504 || status === 408) return 'The AI service timed out. Try a smaller request.';
98
+ if (status === 429) return 'Rate limited by the AI service. Wait a moment and try again.';
99
+ if (status >= 500) return `The AI service is temporarily unavailable (HTTP ${status}).`;
100
+ return `HTTP ${status}`;
101
+ }
102
+
103
+ /**
104
+ * Retry wrapper shared by both backends.
105
+ * `send` returns { done, value } to stop, or { retryable, error } to try again.
106
+ */
107
+ async function withRetries(send, signal) {
108
+ let lastError = 'Request failed';
109
+ let hinted;
110
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
111
+ if (attempt > 0) {
112
+ await sleep(backoffMs(attempt, hinted), signal);
113
+ if (signal?.aborted) return { success: false, error: 'aborted', aborted: true };
114
+ }
115
+ const step = await send();
116
+ hinted = step.retryAfterMs;
117
+ if (step.done) return step.value;
118
+ // A user-triggered abort must never be retried - retrying is exactly the
119
+ // "stop didn't stop" bug: it would fire a brand new request right after
120
+ // the one Stop just cancelled.
121
+ if (step.aborted) return { success: false, error: 'aborted', aborted: true };
122
+ lastError = step.error;
123
+ if (!step.retryable) return { success: false, error: lastError };
124
+ }
125
+ return { success: false, error: lastError };
126
+ }
127
+
128
+ // ─── Native tool calling ────────────────────────────────────────────────────
129
+ // agent.mjs sends OpenAI-shaped native history (assistant.tool_calls, role
130
+ // "tool" results) plus opts.tools when a model supports function calling.
131
+ // OpenAI-compatible endpoints take that as is; Anthropic and Ollama get it
132
+ // converted below. Any request WITHOUT tools goes through textOnlyMessages, so
133
+ // native-shaped history can never reach an endpoint that would reject it.
134
+
135
+ /** Native history -> plain text turns (tool calls described, results as user text). */
136
+ function textOnlyMessages(messages) {
137
+ if (!messages.some((m) => m.role === 'tool' || m.tool_calls)) return messages;
138
+ const out = [];
139
+ for (const m of messages) {
140
+ if (m.role === 'tool') {
141
+ out.push({ role: 'user', content: `[tool result: ${m.name || 'tool'}]\n${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}` });
142
+ } else if (m.tool_calls) {
143
+ const calls = m.tool_calls.map((c) => `[called ${c.function?.name}(${String(c.function?.arguments || '').slice(0, 400)})]`).join('\n');
144
+ out.push({ role: 'assistant', content: [m.content || '', calls].filter(Boolean).join('\n') });
145
+ } else {
146
+ out.push(m);
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function parseArgs(raw) {
153
+ if (raw && typeof raw === 'object') return raw;
154
+ try { return JSON.parse(raw || '{}'); } catch { return {}; }
155
+ }
156
+
157
+ /** OpenAI tool definitions -> Anthropic's {name, description, input_schema}. */
158
+ function toAnthropicTools(tools) {
159
+ return (tools || []).map((t) => ({ name: t.function.name, description: t.function.description, input_schema: t.function.parameters }));
160
+ }
161
+
162
+ /**
163
+ * OpenAI-shaped native history -> Anthropic turns. Tool calls become tool_use
164
+ * blocks on the assistant turn; consecutive tool results merge into one user
165
+ * turn of tool_result blocks, which is what the Messages API requires.
166
+ */
167
+ function toAnthropicTurns(messages) {
168
+ const turns = [];
169
+ const push = (role, blocks) => {
170
+ const last = turns[turns.length - 1];
171
+ if (last && last.role === role) last.content.push(...blocks);
172
+ else turns.push({ role, content: blocks });
173
+ };
174
+ const asBlocks = (content) => {
175
+ const c = toAnthropicContent(content);
176
+ return typeof c === 'string' ? (c ? [{ type: 'text', text: c }] : []) : c;
177
+ };
178
+ for (const m of messages) {
179
+ if (m.role === 'system') continue;
180
+ if (m.role === 'tool') {
181
+ push('user', [{ type: 'tool_result', tool_use_id: m.tool_call_id, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }]);
182
+ } else if (m.role === 'assistant') {
183
+ const blocks = asBlocks(m.content || '');
184
+ for (const c of m.tool_calls || []) blocks.push({ type: 'tool_use', id: c.id, name: c.function.name, input: parseArgs(c.function.arguments) });
185
+ if (blocks.length) push('assistant', blocks);
186
+ } else {
187
+ push('user', asBlocks(m.content));
188
+ }
189
+ }
190
+ return turns;
191
+ }
192
+
193
+ /** OpenAI-shaped native history -> Ollama /api/chat messages (arguments as objects). */
194
+ function toOllamaNativeMessages(messages) {
195
+ return messages.map((m) => {
196
+ if (m.role === 'tool') return { role: 'tool', content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content), ...(m.name ? { tool_name: m.name } : {}) };
197
+ const base = toOllamaMessage(m);
198
+ if (m.tool_calls) base.tool_calls = m.tool_calls.map((c) => ({ function: { name: c.function.name, arguments: parseArgs(c.function.arguments) } }));
199
+ return base;
200
+ });
201
+ }
202
+
203
+ /** True when `e` is the AbortError a fetch() throws for an aborted signal. */
204
+ function isAbortError(e) {
205
+ return e && (e.name === 'AbortError' || /aborted|abortsignal/i.test(String(e.message || '')));
206
+ }
207
+
208
+ // Every provider call below is wrapped in withRetries, which has no timeout
209
+ // of its own - the only AbortSignal ever wired into fetch() was the user's
210
+ // own manual Stop button. A connection that stalls (accepted but never
211
+ // responds, or a streamed body that stops sending bytes mid-generation)
212
+ // previously just hung forever with zero CPU and no visible error - "is it
213
+ // thinking or just dead" is exactly what that looks like from the outside.
214
+ // This gives every attempt a hard ceiling so a stalled request surfaces as a
215
+ // real, retryable error instead of an indefinite silent wait.
216
+ const REQUEST_TIMEOUT_MS = 120_000;
217
+
218
+ /** Combines the caller's own abort signal (Stop button) with a hard timeout into one signal to hand fetch(). */
219
+ function withTimeout(signal, ms = REQUEST_TIMEOUT_MS) {
220
+ const controller = new AbortController();
221
+ if (signal) {
222
+ if (signal.aborted) controller.abort();
223
+ else signal.addEventListener('abort', () => controller.abort(), { once: true });
224
+ }
225
+ const timer = setTimeout(() => controller.abort(new Error('timeout')), ms);
226
+ return {
227
+ signal: controller.signal,
228
+ // Whether THIS particular abort was the timeout firing rather than the
229
+ // user's own Stop - callers use this to report "the request stalled"
230
+ // instead of silently treating a stall as if the user had cancelled it.
231
+ isTimeout: () => controller.signal.reason instanceof Error && controller.signal.reason.message === 'timeout',
232
+ cleanup: () => clearTimeout(timer),
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Same idea as withTimeout, but resettable - for a streamed response, a flat
238
+ * ceiling on the whole request would kill a legitimately long generation that
239
+ * just happens to keep actively sending bytes. poke() bumps the clock every
240
+ * time a chunk actually arrives, so this only fires on a genuine stall (no
241
+ * bytes at all for `ms`), never on a slow-but-still-streaming one.
242
+ */
243
+ function withIdleTimeout(signal, ms = REQUEST_TIMEOUT_MS) {
244
+ const controller = new AbortController();
245
+ if (signal) {
246
+ if (signal.aborted) controller.abort();
247
+ else signal.addEventListener('abort', () => controller.abort(), { once: true });
248
+ }
249
+ let timer = setTimeout(() => controller.abort(new Error('timeout')), ms);
250
+ return {
251
+ signal: controller.signal,
252
+ poke: () => { clearTimeout(timer); timer = setTimeout(() => controller.abort(new Error('timeout')), ms); },
253
+ isTimeout: () => controller.signal.reason instanceof Error && controller.signal.reason.message === 'timeout',
254
+ cleanup: () => clearTimeout(timer),
255
+ };
256
+ }
257
+
258
+ async function chatViaProxy(messages, opts) {
259
+ const token = await getAccessToken();
260
+ if (!token) return { success: false, error: 'Not signed in. Sign in to use Auto, or pick one of your own models.' };
261
+
262
+ return withRetries(async () => {
263
+ let res, body;
264
+ const { signal, isTimeout, cleanup } = withTimeout(opts.signal);
265
+ try {
266
+ res = await fetch(AI_PROXY_URL, {
267
+ method: 'POST',
268
+ headers: {
269
+ 'Content-Type': 'application/json',
270
+ 'Authorization': `Bearer ${token}`,
271
+ 'apikey': SUPABASE_ANON_KEY,
272
+ },
273
+ // meta is optional, display-only context (what was asked, which file) -
274
+ // the proxy logs it to usage_history so CLI activity shows up in the
275
+ // admin dashboard next to the desktop app, same as this app's own calls.
276
+ // The proxy declares no tools, so it only ever gets text history.
277
+ body: JSON.stringify({ messages: textOnlyMessages(messages), opts: { ...opts, tools: undefined }, meta: opts.meta }),
278
+ signal,
279
+ });
280
+ body = await res.json().catch(() => ({}));
281
+ } catch (e) {
282
+ if (isTimeout()) return { retryable: true, error: 'The request timed out with no response from the AI proxy - retrying.' };
283
+ if (isAbortError(e)) return { aborted: true };
284
+ // Network-level failure - also worth another go.
285
+ return { retryable: true, error: e.message };
286
+ } finally {
287
+ cleanup();
288
+ }
289
+
290
+ if (res.ok && body.success) {
291
+ return { done: true, value: { success: true, data: body.data, modelUsed: body.modelUsed } };
292
+ }
293
+
294
+ // A structured error from the function itself is final: it means the
295
+ // function ran and deliberately rejected us (auth, daily cap, provider
296
+ // error). Retrying that just burns the user's quota.
297
+ if (body.error) {
298
+ const hitCap = isRateLimitError(body.error) || /daily .* (limit|cap)/i.test(body.error);
299
+ const error = hitCap ? `${body.error} ${byokHint()}` : body.error;
300
+ return { done: true, value: { success: false, error } };
301
+ }
302
+
303
+ return { retryable: isTransientStatus(res.status), retryAfterMs: retryAfterMs(res), error: describeStatus(res.status) };
304
+ }, opts.signal);
305
+ }
306
+
307
+ /**
308
+ * Failures that are about THIS key rather than about the request - an
309
+ * exhausted daily/hourly quota, a rate limit, a revoked or invalid key, an
310
+ * account with nothing left to spend. When a second key is configured these
311
+ * are worth giving up on immediately: no amount of retrying the same key
312
+ * fixes any of them, and the other account very likely can serve the request
313
+ * right now.
314
+ */
315
+ function isKeyLevelFailure(msg, status) {
316
+ if (status === 401 || status === 402 || status === 403 || status === 429) return true;
317
+ const s = String(msg || '');
318
+ return isRateLimitError(s)
319
+ || /insufficient|billing|payment|credit|exceed|capacity|unauthor|invalid.*key|forbidden|expired|suspend/i.test(s);
320
+ }
321
+
322
+ /**
323
+ * Errors that mean the REQUEST is wrong, not the key - a model name that
324
+ * doesn't exist on the host, say. Switching accounts can't help, so the
325
+ * second key is left alone rather than spending it on the same guaranteed
326
+ * failure (and showing the user the same error twice as long).
327
+ */
328
+ function isKeyIndependentFailure(msg) {
329
+ return /not found|does not exist|unknown model/i.test(String(msg || ''));
330
+ }
331
+
332
+ /**
333
+ * Ollama with automatic account failover.
334
+ *
335
+ * Two cloud keys can be configured (apiKey and apiKeyFallback in
336
+ * ~/.codeply/config.json - two separate ollama.com accounts). This tries
337
+ * them in order: the first key gets the normal retry cycle for ordinary
338
+ * transient hiccups, but the moment a failure looks like it's about that
339
+ * KEY - rate limited, over quota, out of credit, revoked - the request moves
340
+ * straight to the other account instead of burning retries on an account
341
+ * that has already said no. One key being full or broken therefore doesn't
342
+ * stop the app; it just quietly finishes on the other one.
343
+ *
344
+ * A local daemon (no key at all) is the one case with nothing to fail over
345
+ * to, and needs none - it runs as the single "keyless" attempt.
346
+ */
347
+ async function chatViaOllama(messages, opts, cfg) {
348
+ const { host, model, apiKey, apiKeyFallback, numCtx } = cfg.ollama;
349
+ const isLocal = /localhost|127\.0\.0\.1/.test(host);
350
+
351
+ const keys = [];
352
+ for (const k of [apiKey, apiKeyFallback]) {
353
+ if (k && !keys.includes(k)) keys.push(k);
354
+ }
355
+ if (!keys.length) {
356
+ if (!isLocal) {
357
+ return { success: false, error: `No Ollama API key set for ${host}. Run \`codeply provider ollama --key <key>\`.` };
358
+ }
359
+ keys.push(''); // local daemon - one keyless attempt
360
+ }
361
+
362
+ let last = { success: false, error: 'Request failed' };
363
+ for (let i = 0; i < keys.length; i++) {
364
+ const hasSpare = i < keys.length - 1;
365
+ last = await ollamaRequest(host, model, keys[i], isLocal, messages, opts, numCtx, hasSpare);
366
+ // Success, or the user pressed Stop: either way we're done - an abort
367
+ // must never be "retried" on the other account.
368
+ if (last.success || last.aborted) return last;
369
+ if (!hasSpare) break;
370
+ if (isKeyIndependentFailure(last.error)) return last;
371
+ }
372
+
373
+ if (keys.length > 1) {
374
+ return { ...last, error: `${last.error} (both Ollama accounts were tried)` };
375
+ }
376
+ return last;
377
+ }
378
+
379
+ function ollamaRequest(host, model, apiKey, isLocal, messages, opts, numCtx, hasSpare) {
380
+ return streamingChatRequest({
381
+ url: `${host}/v1/chat/completions`, label: 'Ollama', model, apiKey, isLocal, messages, opts, hasSpare,
382
+ extraBody: numCtx ? { options: { num_ctx: numCtx } } : null,
383
+ });
384
+ }
385
+
386
+ /**
387
+ * One streamed OpenAI-compatible chat completion, reassembled into the plain
388
+ * non-streaming response shape. Shared by the Ollama cloud/local path and by
389
+ * every user-added custom model. Falls back cleanly to a plain JSON body for
390
+ * servers that ignore `stream: true`.
391
+ */
392
+ async function streamingChatRequest({ url, label, model, apiKey, isLocal, messages, opts, hasSpare, extraBody, idleMs }) {
393
+ const headers = { 'Content-Type': 'application/json' };
394
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
395
+ // Harmless for a real Ollama host (unrecognized header, ignored) - but
396
+ // without it, an ngrok free-tier tunnel intercepts every request itself and
397
+ // returns its own "you're about to visit..." interstitial page instead of
398
+ // ever forwarding to the actual server behind it. A self-hosted preset
399
+ // (Colab notebook, home server) tunneled through ngrok's free tier is
400
+ // common enough that this is worth sending unconditionally rather than
401
+ // only when the host string happens to contain "ngrok".
402
+ headers['ngrok-skip-browser-warning'] = 'true';
403
+
404
+ return withRetries(async () => {
405
+ let res;
406
+ const { signal, poke, isTimeout, cleanup } = withIdleTimeout(opts.signal, idleMs);
407
+ try {
408
+ res = await fetch(url, {
409
+ method: 'POST',
410
+ headers,
411
+ body: JSON.stringify({
412
+ model,
413
+ messages: opts.tools ? messages : textOnlyMessages(messages),
414
+ temperature: opts.temperature ?? 0,
415
+ ...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
416
+ ...(opts.json ? { response_format: { type: 'json_object' } } : {}),
417
+ ...(opts.tools ? { tools: opts.tools, tool_choice: 'auto' } : {}),
418
+ // Provider-specific extras (e.g. Ollama's `options.num_ctx`).
419
+ ...(extraBody || {}),
420
+ // Streamed, not buffered: a non-streaming request sends nothing over
421
+ // the wire until generation is completely done, and proxies/tunnels
422
+ // kill a connection that stays silent for a minute or two. Streaming
423
+ // keeps bytes flowing from the first token; the chunks are
424
+ // reassembled into the non-streaming shape below.
425
+ stream: true,
426
+ }),
427
+ signal,
428
+ });
429
+ } catch (e) {
430
+ cleanup();
431
+ if (isTimeout()) return { retryable: true, error: `${label} timed out with no response${isLocal ? ' - is the local server running?' : ' - retrying.'}` };
432
+ if (isAbortError(e)) return { aborted: true };
433
+ const hint = isLocal ? ` Is the ${label} server running at ${url}?` : '';
434
+ return { retryable: true, error: e.message + hint };
435
+ }
436
+
437
+ // Errors (bad model name, auth, ...) come back as one plain JSON body,
438
+ // not a stream - content-type is the reliable signal for which shape
439
+ // actually arrived, since res.ok alone can't distinguish a streamed 200
440
+ // from a JSON-error 200 some proxies in front of Ollama return.
441
+ const isStream = (res.headers.get('content-type') || '').includes('text/event-stream');
442
+
443
+ if (!isStream) {
444
+ cleanup(); // no more bytes expected - the idle clock has nothing left to guard
445
+ const body = await res.json().catch(() => ({}));
446
+ if (res.ok && body.choices?.[0]) {
447
+ return { done: true, value: { success: true, data: body, modelUsed: body.model || model } };
448
+ }
449
+ const apiError = body?.error?.message || body?.error;
450
+ if (apiError) {
451
+ const msg = typeof apiError === 'string' ? apiError : JSON.stringify(apiError);
452
+ const fatal = /not found|does not exist|unknown model|unauthor|invalid.*key|forbidden/i.test(msg);
453
+ if (fatal) return { done: true, value: { success: false, error: `${label}: ${msg}` } };
454
+ // Another account is standing by, and this failure is this account's
455
+ // own (quota, rate limit, no credit) - stop retrying it and let
456
+ // chatViaOllama move the request over there right now.
457
+ if (hasSpare && isKeyLevelFailure(msg, res.status)) {
458
+ return { done: true, value: { success: false, error: `${label}: ${msg}` } };
459
+ }
460
+ return { retryable: isTransientStatus(res.status), retryAfterMs: retryAfterMs(res), error: `${label}: ${msg}` };
461
+ }
462
+ if (hasSpare && isKeyLevelFailure('', res.status)) {
463
+ return { done: true, value: { success: false, error: `${label}: ${describeStatus(res.status)}` } };
464
+ }
465
+ return { retryable: isTransientStatus(res.status), retryAfterMs: retryAfterMs(res), error: describeStatus(res.status) };
466
+ }
467
+
468
+ // Reassemble the OpenAI-shaped SSE chunks (data: {...choices[0].delta...})
469
+ // into the exact same {choices:[{message:{content,reasoning}}]} shape a
470
+ // non-streaming call returns.
471
+ let content = '';
472
+ let reasoning = '';
473
+ let finishReason = null;
474
+ let modelUsed = model;
475
+ let buffer = '';
476
+ // Native tool calls stream in pieces keyed by index: the id and name
477
+ // arrive once, the JSON arguments a few characters at a time.
478
+ const toolCalls = [];
479
+ try {
480
+ for await (const chunk of res.body) {
481
+ poke(); // a real chunk arrived - the stream is alive, push the stall clock back out
482
+ buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : Buffer.from(chunk).toString('utf8');
483
+ let nlIndex;
484
+ while ((nlIndex = buffer.indexOf('\n')) !== -1) {
485
+ const line = buffer.slice(0, nlIndex).trim();
486
+ buffer = buffer.slice(nlIndex + 1);
487
+ if (!line.startsWith('data:')) continue;
488
+ const payload = line.slice(5).trim();
489
+ if (!payload || payload === '[DONE]') continue;
490
+ let evt;
491
+ try { evt = JSON.parse(payload); } catch { continue; }
492
+ modelUsed = evt.model || modelUsed;
493
+ const delta = evt.choices?.[0]?.delta;
494
+ if (delta?.content) content += delta.content;
495
+ if (delta?.reasoning) reasoning += delta.reasoning;
496
+ if (delta?.reasoning_content) reasoning += delta.reasoning_content;
497
+ for (const tc of delta?.tool_calls || []) {
498
+ const i = typeof tc.index === 'number' ? tc.index : toolCalls.length;
499
+ const slot = toolCalls[i] || (toolCalls[i] = { id: '', type: 'function', function: { name: '', arguments: '' } });
500
+ if (tc.id) slot.id = tc.id;
501
+ if (tc.function?.name) slot.function.name += tc.function.name;
502
+ if (tc.function?.arguments) slot.function.arguments += typeof tc.function.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function.arguments);
503
+ }
504
+ if (evt.choices?.[0]?.finish_reason) finishReason = evt.choices[0].finish_reason;
505
+ }
506
+ }
507
+ } catch (e) {
508
+ if (isTimeout()) return { retryable: true, error: `${label} stopped sending data mid-response (stalled stream) - retrying.` };
509
+ if (isAbortError(e)) return { aborted: true };
510
+ return { retryable: true, error: `${label} stream interrupted: ${e.message}` };
511
+ } finally {
512
+ cleanup();
513
+ }
514
+
515
+ if (!res.ok && !content) {
516
+ if (hasSpare && isKeyLevelFailure('', res.status)) {
517
+ return { done: true, value: { success: false, error: `${label}: ${describeStatus(res.status)}` } };
518
+ }
519
+ return { retryable: isTransientStatus(res.status), retryAfterMs: retryAfterMs(res), error: describeStatus(res.status) };
520
+ }
521
+
522
+ const message = { role: 'assistant', content };
523
+ if (reasoning) message.reasoning = reasoning;
524
+ const calls = toolCalls.filter((t) => t && t.function.name);
525
+ if (calls.length) message.tool_calls = calls.map((t, i) => ({ ...t, id: t.id || `call_${Date.now().toString(36)}_${i}` }));
526
+ return {
527
+ done: true,
528
+ value: {
529
+ success: true,
530
+ data: { choices: [{ message, finish_reason: finishReason }], model: modelUsed },
531
+ modelUsed,
532
+ },
533
+ };
534
+ }, opts.signal);
535
+ }
536
+
537
+ // OpenAI-compatible /v1/chat/completions endpoints for the providers below,
538
+ // all of which speak that same dialect. Ollama has its own function above because it
539
+ // also has to handle "no key at all for a local daemon", which these never do.
540
+ const OPENAI_COMPATIBLE = {
541
+ // OpenRouter fronts every other vendor plus the anonymously-published
542
+ // stealth models (stealth/ox-alpha, ~1M context) that have no first-party
543
+ // API at all, so one key here reaches models the rest of this table can't.
544
+ openrouter: {
545
+ url: 'https://openrouter.ai/api/v1/chat/completions',
546
+ label: 'OpenRouter',
547
+ extraHeaders: { 'HTTP-Referer': 'https://codeply.online', 'X-Title': 'Codeply' },
548
+ },
549
+ groq: {
550
+ url: 'https://api.groq.com/openai/v1/chat/completions',
551
+ label: 'Groq',
552
+ },
553
+ openai: {
554
+ url: 'https://api.openai.com/v1/chat/completions',
555
+ label: 'OpenAI',
556
+ },
557
+ // Google AI Studio (Gemini) exposes an OpenAI-compatible chat completions
558
+ // endpoint alongside its native API - using it means Gemini needs no
559
+ // special-cased request/response shape, same as openrouter/groq/openai.
560
+ google: {
561
+ url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
562
+ label: 'Google AI Studio',
563
+ },
564
+ // No fixed `url` here - Alibaba issues a deployment-specific Model Studio
565
+ // host per account/region, so it comes from cfg.qwen.baseUrl instead (see
566
+ // below and lib/config.js).
567
+ qwen: {
568
+ label: 'Qwen (Alibaba Model Studio)',
569
+ },
570
+ // DeepSeek's own base URL genuinely doesn't need a /v1 segment (unlike the
571
+ // other OpenAI-compatible hosts above) - they document both /v1/... and
572
+ // the bare path as equivalent, since /v1 exists there only for client-SDK
573
+ // compatibility, not real API versioning.
574
+ deepseek: {
575
+ url: 'https://api.deepseek.com/chat/completions',
576
+ label: 'DeepSeek',
577
+ },
578
+ };
579
+
580
+ /**
581
+ * 402 is the one status in this family that must never be retried: it means
582
+ * the key authenticated fine and the account simply has no balance to spend
583
+ * (OpenRouter's paid models, once the free allowance is gone). Retrying that
584
+ * three times just makes the user wait three times as long for the same
585
+ * answer, and the provider's own message - "Insufficient credits" - matches
586
+ * none of the fatal patterns below, so it needs saying explicitly.
587
+ */
588
+ const OUT_OF_CREDIT_HINT = {
589
+ openrouter: 'your OpenRouter account is out of credit. Top it up at ' +
590
+ 'https://openrouter.ai/credits - the key itself is valid, it just has no ' +
591
+ 'balance to spend. Free models (ids ending in :free, and the stealth ones) ' +
592
+ 'keep working without a balance.',
593
+ };
594
+
595
+ async function chatViaOpenAICompatible(messages, opts, providerName, cfg) {
596
+ const { url: fixedUrl, label, extraHeaders } = OPENAI_COMPATIBLE[providerName];
597
+ const { apiKey, model, baseUrl } = cfg[providerName];
598
+ if (!apiKey) {
599
+ return { success: false, error: `No ${label} API key set. Run \`codeply provider ${providerName} --key <key>\`.` };
600
+ }
601
+ const url = fixedUrl || (baseUrl && `${baseUrl.replace(/\/+$/, '')}/chat/completions`);
602
+ if (!url) {
603
+ return { success: false, error: `No ${label} base URL set. Run \`codeply provider ${providerName} --base-url <url>\`.` };
604
+ }
605
+
606
+ return withRetries(async () => {
607
+ let res, body;
608
+ const { signal, isTimeout, cleanup } = withTimeout(opts.signal);
609
+ try {
610
+ res = await fetch(url, {
611
+ method: 'POST',
612
+ headers: {
613
+ 'Content-Type': 'application/json',
614
+ 'Authorization': `Bearer ${apiKey}`,
615
+ ...extraHeaders,
616
+ },
617
+ body: JSON.stringify({
618
+ model,
619
+ messages: opts.tools ? messages : textOnlyMessages(messages),
620
+ temperature: opts.temperature ?? 0,
621
+ ...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
622
+ ...(opts.json ? { response_format: { type: 'json_object' } } : {}),
623
+ ...(opts.tools ? { tools: opts.tools, tool_choice: 'auto' } : {}),
624
+ }),
625
+ signal,
626
+ });
627
+ body = await res.json().catch(() => ({}));
628
+ } catch (e) {
629
+ if (isTimeout()) return { retryable: true, error: `${label} timed out with no response - retrying.` };
630
+ if (isAbortError(e)) return { aborted: true };
631
+ return { retryable: true, error: e.message };
632
+ } finally {
633
+ cleanup();
634
+ }
635
+
636
+ if (res.ok && body.choices?.[0]) {
637
+ return { done: true, value: { success: true, data: body, modelUsed: body.model || model } };
638
+ }
639
+
640
+ if (res.status === 402) {
641
+ const hint = OUT_OF_CREDIT_HINT[providerName] || 'this account is out of credit.';
642
+ return { done: true, value: { success: false, error: `${label}: ${hint}` } };
643
+ }
644
+
645
+ const apiError = body?.error?.message || body?.error;
646
+ if (apiError) {
647
+ const msg = typeof apiError === 'string' ? apiError : JSON.stringify(apiError);
648
+ const fatal = /not found|does not exist|unknown model|unauthor|invalid.*key|forbidden/i.test(msg);
649
+ if (fatal) return { done: true, value: { success: false, error: `${label}: ${msg}` } };
650
+ return { retryable: isTransientStatus(res.status), retryAfterMs: retryAfterMs(res), error: `${label}: ${msg}` };
651
+ }
652
+
653
+ return { retryable: isTransientStatus(res.status), retryAfterMs: retryAfterMs(res), error: describeStatus(res.status) };
654
+ }, opts.signal);
655
+ }
656
+
657
+ // Below this many characters a block essentially never clears Anthropic's
658
+ // minimum-token floor for caching (1024 tokens on Sonnet-class models, higher
659
+ // on Haiku) - offering cache_control on it just adds a cache-write surcharge
660
+ // for a block that will never actually be served from cache. ~4 chars/token is
661
+ // a deliberately conservative floor, not a precise count.
662
+ const ANTHROPIC_CACHEABLE_MIN_CHARS = 4000;
663
+
664
+ /**
665
+ * Anthropic's Messages API isn't OpenAI-shaped: system prompt is a top-level
666
+ * field (not a message with role "system"), and the reply comes back as a
667
+ * `content` block array rather than `choices[0].message`. Converted both ways
668
+ * here so the rest of the codebase never has to know Anthropic is different.
669
+ *
670
+ * Prompt caching (`cache_control: {type:'ephemeral'}`) is applied at two
671
+ * breakpoints, because of how the agent loop in lib/agent.mjs actually calls
672
+ * this: one user turn can take up to 24 steps, and EVERY step resends the
673
+ * ENTIRE message array built up so far - there is no other way to talk to a
674
+ * stateless chat completions API. Without caching, a 20-step turn re-bills the
675
+ * system prompt and the whole growing transcript from scratch 20 times over.
676
+ * With it:
677
+ * 1. The system prompt (mode instructions + tool reference + skill index +
678
+ * project context) is byte-identical across every step of a turn, and
679
+ * usually across the whole session - cached at ~10% of its input-token
680
+ * cost after the first call.
681
+ * 2. Everything except the newest message is marked as a second breakpoint,
682
+ * so step N's request reuses step N-1's cache instead of re-billing the
683
+ * whole transcript-so-far - turning per-step cost from O(conversation
684
+ * length) into roughly O(what's new since the last step).
685
+ * Anthropic allows the read side of a cache hit to apply automatically for any
686
+ * request sharing a cached prefix, so these two breakpoints are enough; more
687
+ * would just spend the (4/hr) breakpoint limit for no further benefit here.
688
+ */
689
+ /**
690
+ * Message content is either a plain string (the overwhelming majority of
691
+ * calls - every tool result, every system prompt) or, when the user pasted
692
+ * an image, an OpenAI-shaped array: [{type:'text',text}, {type:'image_url',
693
+ * image_url:{url:'data:...;base64,...'}}]. That array format is what
694
+ * agent.mjs builds and what OpenAI-compatible endpoints (Ollama, OpenRouter,
695
+ * Groq, OpenAI, the codeply proxy) all speak natively, so those backends
696
+ * need no conversion at all - messages just flow through as-is. Anthropic's
697
+ * Messages API is the one exception: it wants {type:'image', source:
698
+ * {type:'base64', media_type, data}}, not an image_url. This is the only
699
+ * place that translation has to happen.
700
+ */
701
+ function toAnthropicContent(content) {
702
+ if (typeof content === 'string' || !Array.isArray(content)) return content;
703
+ return content
704
+ .map((part) => {
705
+ if (part.type === 'text') return { type: 'text', text: part.text };
706
+ if (part.type === 'image_url') {
707
+ const m = /^data:([^;]+);base64,(.+)$/.exec(part.image_url?.url || '');
708
+ if (!m) return null;
709
+ return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } };
710
+ }
711
+ return null;
712
+ })
713
+ .filter(Boolean);
714
+ }
715
+
716
+ async function chatViaAnthropic(messages, opts, cfg) {
717
+ const { apiKey, model } = cfg.anthropic;
718
+ if (!apiKey) {
719
+ return { success: false, error: 'No Anthropic API key set. Run `codeply provider anthropic --key <key>`.' };
720
+ }
721
+
722
+ const systemText = messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n');
723
+ const turns = opts.tools
724
+ ? toAnthropicTurns(messages)
725
+ : textOnlyMessages(messages)
726
+ .filter((m) => m.role !== 'system')
727
+ .map((m) => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: toAnthropicContent(m.content) }));
728
+
729
+ // Anthropic has no json_object response mode - ask for it in plain English instead.
730
+ const systemText2 = opts.json
731
+ ? `${systemText}\n\nRespond with ONLY a single valid JSON object. No prose, no markdown fences, nothing before or after it.`
732
+ : systemText;
733
+
734
+ const cacheControl = { type: 'ephemeral' };
735
+ const system = systemText2.length >= ANTHROPIC_CACHEABLE_MIN_CHARS
736
+ ? [{ type: 'text', text: systemText2, cache_control: cacheControl }]
737
+ : systemText2;
738
+
739
+ // Breakpoint 2: everything but the newest message. A single-message request
740
+ // (the very first call of a brand-new turn) has nothing "so far" to cache.
741
+ if (turns.length >= 2) {
742
+ const idx = turns.length - 2;
743
+ const c = turns[idx].content;
744
+ if (typeof c === 'string' && c.length >= ANTHROPIC_CACHEABLE_MIN_CHARS) {
745
+ turns[idx] = {
746
+ role: turns[idx].role,
747
+ content: [{ type: 'text', text: c, cache_control: cacheControl }],
748
+ };
749
+ } else if (Array.isArray(c) && c.length && JSON.stringify(c).length >= ANTHROPIC_CACHEABLE_MIN_CHARS) {
750
+ // Block-array turns (tool use/results): the breakpoint goes on the last block.
751
+ const last = c[c.length - 1];
752
+ c[c.length - 1] = { ...last, cache_control: cacheControl };
753
+ }
754
+ }
755
+
756
+ return withRetries(async () => {
757
+ let res;
758
+ try {
759
+ res = await fetch('https://api.anthropic.com/v1/messages', {
760
+ method: 'POST',
761
+ headers: {
762
+ 'Content-Type': 'application/json',
763
+ 'x-api-key': apiKey,
764
+ 'anthropic-version': '2023-06-01',
765
+ },
766
+ body: JSON.stringify({
767
+ model,
768
+ system,
769
+ messages: turns,
770
+ max_tokens: opts.maxTokens || 8192,
771
+ temperature: opts.temperature ?? 0,
772
+ ...(opts.tools ? { tools: toAnthropicTools(opts.tools) } : {}),
773
+ }),
774
+ signal: opts.signal,
775
+ });
776
+ } catch (e) {
777
+ if (isAbortError(e)) return { aborted: true };
778
+ return { retryable: true, error: e.message };
779
+ }
780
+
781
+ const body = await res.json().catch(() => ({}));
782
+
783
+ if (res.ok && Array.isArray(body.content)) {
784
+ const text = body.content.filter((b) => b.type === 'text').map((b) => b.text).join('');
785
+ const message = { role: 'assistant', content: text };
786
+ const uses = body.content.filter((b) => b.type === 'tool_use');
787
+ if (uses.length) message.tool_calls = uses.map((b) => ({ id: b.id, type: 'function', function: { name: b.name, arguments: JSON.stringify(b.input || {}) } }));
788
+ const data = {
789
+ choices: [{ message, finish_reason: body.stop_reason === 'max_tokens' ? 'length' : body.stop_reason === 'tool_use' ? 'tool_calls' : 'stop' }],
790
+ usage: {
791
+ prompt_tokens: body.usage?.input_tokens || 0,
792
+ completion_tokens: body.usage?.output_tokens || 0,
793
+ total_tokens: (body.usage?.input_tokens || 0) + (body.usage?.output_tokens || 0),
794
+ // Surfaced for anyone instrumenting cost later - not read anywhere yet.
795
+ cache_read_tokens: body.usage?.cache_read_input_tokens || 0,
796
+ cache_write_tokens: body.usage?.cache_creation_input_tokens || 0,
797
+ },
798
+ };
799
+ return { done: true, value: { success: true, data, modelUsed: model } };
800
+ }
801
+
802
+ const apiError = body?.error?.message || body?.error;
803
+ if (apiError) {
804
+ const msg = typeof apiError === 'string' ? apiError : JSON.stringify(apiError);
805
+ const fatal = /not_found_error|authentication_error|permission_error|invalid.*key/i.test(JSON.stringify(body.error || {})) || res.status === 401 || res.status === 403 || res.status === 404;
806
+ if (fatal) return { done: true, value: { success: false, error: `Anthropic: ${msg}` } };
807
+ return { retryable: isTransientStatus(res.status), retryAfterMs: retryAfterMs(res), error: `Anthropic: ${msg}` };
808
+ }
809
+
810
+ return { retryable: isTransientStatus(res.status), retryAfterMs: retryAfterMs(res), error: describeStatus(res.status) };
811
+ }, opts.signal);
812
+ }
813
+
814
+ /**
815
+ * @param {Array<{role:string, content:string}>} messages
816
+ * @param {{ json?: boolean, maxTokens?: number, temperature?: number }} opts
817
+ * @returns {Promise<{success:boolean, data?:object, error?:string, modelUsed?:string}>}
818
+ */
819
+ /**
820
+ * Apply a per-request routing override (see lib/model-router.js) on top of the
821
+ * stored config, without persisting anything.
822
+ *
823
+ * Falls back to the unrouted config whenever the target provider isn't
824
+ * actually usable - no API key for a BYOK provider, no key for remote Ollama.
825
+ * A route that can't authenticate would otherwise turn a working setup into a
826
+ * hard "No API key set" error purely because of which words were in the user's
827
+ * message, which is a far worse outcome than answering on the model they
828
+ * already had configured.
829
+ */
830
+ function applyRoute(cfg, route) {
831
+ if (!route || !route.provider || !route.model) return cfg;
832
+ if (!PROVIDERS.includes(route.provider)) return cfg;
833
+
834
+ const section = { ...(cfg[route.provider] || {}), model: route.model };
835
+ if (route.provider === 'ollama') {
836
+ const isLocal = /localhost|127\.0\.0\.1/.test(section.host || '');
837
+ if (!section.apiKey && !isLocal) return cfg;
838
+ } else if (route.provider !== 'codeply' && !section.apiKey) {
839
+ return cfg;
840
+ }
841
+ return { ...cfg, provider: route.provider, [route.provider]: section };
842
+ }
843
+
844
+ // ─── User-added models (desktop model picker) ───────────────────────────────
845
+
846
+ const isLocalUrl = (u) => /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(u || '');
847
+
848
+ /**
849
+ * People paste base URLs in every shape: ".../v1", ".../openai", the full
850
+ * ".../chat/completions", or a bare host. Normalize to the completions URL.
851
+ */
852
+ function chatCompletionsUrl(baseUrl) {
853
+ const base = String(baseUrl || '').trim().replace(/\/+$/, '');
854
+ if (/\/chat\/completions$/i.test(base)) return base;
855
+ if (/\/v\d+[a-z]*$/i.test(base) || /\/openai$/i.test(base) || /\/compatible-mode\/v\d+$/i.test(base)) return `${base}/chat/completions`;
856
+ return `${base}/v1/chat/completions`;
857
+ }
858
+
859
+ /** Ollama's native API lives at the host root - strip an /api or /v1 suffix someone pasted. */
860
+ function ollamaHost(baseUrl) {
861
+ return String(baseUrl || 'http://localhost:11434').trim().replace(/\/+$/, '').replace(/\/(api|v1)$/i, '');
862
+ }
863
+
864
+ // Local models can take a while to load into memory before the first byte -
865
+ // a longer stall window than the hosted providers get.
866
+ const LOCAL_IDLE_TIMEOUT_MS = 300_000;
867
+ // Ollama's default context window (2-4K tokens) is far too small for an agent
868
+ // system prompt plus history; without raising it the model silently loses the
869
+ // start of the conversation. 32K fits comfortably on most machines.
870
+ const OLLAMA_NUM_CTX = 32768;
871
+
872
+ /** OpenAI-shaped content array → Ollama native { content, images } fields. */
873
+ function toOllamaMessage(m) {
874
+ if (typeof m.content === 'string' || !Array.isArray(m.content)) return { role: m.role, content: m.content || '' };
875
+ const text = m.content.filter((p) => p.type === 'text').map((p) => p.text).join('\n');
876
+ const images = m.content
877
+ .filter((p) => p.type === 'image_url')
878
+ .map((p) => /^data:[^;]+;base64,(.+)$/.exec(p.image_url?.url || '')?.[1])
879
+ .filter(Boolean);
880
+ return images.length ? { role: m.role, content: text, images } : { role: m.role, content: text };
881
+ }
882
+
883
+ /**
884
+ * Ollama's native /api/chat, not its OpenAI shim: only the native endpoint
885
+ * reliably honors options.num_ctx, which the agent needs (see above).
886
+ * Streams NDJSON and reassembles it into the standard response shape.
887
+ */
888
+ async function chatViaOllamaNative(messages, opts, m) {
889
+ const host = ollamaHost(m.baseUrl);
890
+ const local = isLocalUrl(host);
891
+ const headers = { 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': 'true' };
892
+ if (m.apiKey) headers.Authorization = `Bearer ${m.apiKey}`;
893
+
894
+ return withRetries(async () => {
895
+ let res;
896
+ const { signal, poke, isTimeout, cleanup } = withIdleTimeout(opts.signal, local ? LOCAL_IDLE_TIMEOUT_MS : REQUEST_TIMEOUT_MS);
897
+ try {
898
+ res = await fetch(`${host}/api/chat`, {
899
+ method: 'POST',
900
+ headers,
901
+ body: JSON.stringify({
902
+ model: m.model,
903
+ messages: opts.tools ? toOllamaNativeMessages(messages) : textOnlyMessages(messages).map(toOllamaMessage),
904
+ stream: true,
905
+ ...(opts.json ? { format: 'json' } : {}),
906
+ ...(opts.tools ? { tools: opts.tools } : {}),
907
+ options: {
908
+ num_ctx: OLLAMA_NUM_CTX,
909
+ temperature: opts.temperature ?? 0,
910
+ ...(opts.maxTokens ? { num_predict: opts.maxTokens } : {}),
911
+ },
912
+ }),
913
+ signal,
914
+ });
915
+ } catch (e) {
916
+ cleanup();
917
+ if (isTimeout()) return { retryable: true, error: `Ollama at ${host} didn't respond in time. Large models can take a while to load - try again, or pick a smaller model.` };
918
+ if (isAbortError(e)) return { aborted: true };
919
+ return { retryable: false, error: local ? `Can't reach Ollama at ${host}. Is Ollama running? (Start it with \`ollama serve\` or open the Ollama app.)` : `Can't reach ${host}: ${e.message}` };
920
+ }
921
+
922
+ if (!res.ok) {
923
+ cleanup();
924
+ const body = await res.json().catch(() => ({}));
925
+ const msg = body.error || describeStatus(res.status);
926
+ const fatal = res.status === 404 || /not found|pull/i.test(String(msg));
927
+ if (fatal) return { done: true, value: { success: false, error: `Ollama: ${msg}${/not found/i.test(String(msg)) ? ` - run \`ollama pull ${m.model}\` first.` : ''}` } };
928
+ return { retryable: isTransientStatus(res.status), retryAfterMs: retryAfterMs(res), error: `Ollama: ${msg}` };
929
+ }
930
+
931
+ let content = '';
932
+ let reasoning = '';
933
+ let buffer = '';
934
+ let streamError = null;
935
+ const toolCalls = [];
936
+ try {
937
+ for await (const chunk of res.body) {
938
+ poke();
939
+ buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : Buffer.from(chunk).toString('utf8');
940
+ let nl;
941
+ while ((nl = buffer.indexOf('\n')) !== -1) {
942
+ const line = buffer.slice(0, nl).trim();
943
+ buffer = buffer.slice(nl + 1);
944
+ if (!line) continue;
945
+ let evt;
946
+ try { evt = JSON.parse(line); } catch { continue; }
947
+ if (evt.error) streamError = evt.error;
948
+ if (evt.message?.content) content += evt.message.content;
949
+ if (evt.message?.thinking) reasoning += evt.message.thinking;
950
+ // Ollama sends each tool call whole (arguments as an object).
951
+ for (const tc of evt.message?.tool_calls || []) {
952
+ if (!tc.function?.name) continue;
953
+ toolCalls.push({
954
+ id: tc.id || `call_${Date.now().toString(36)}_${toolCalls.length}`,
955
+ type: 'function',
956
+ function: { name: tc.function.name, arguments: typeof tc.function.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function.arguments || {}) },
957
+ });
958
+ }
959
+ }
960
+ }
961
+ } catch (e) {
962
+ if (isTimeout()) return { retryable: true, error: 'Ollama stopped sending data mid-response - retrying.' };
963
+ if (isAbortError(e)) return { aborted: true };
964
+ return { retryable: true, error: `Ollama stream interrupted: ${e.message}` };
965
+ } finally {
966
+ cleanup();
967
+ }
968
+ if (streamError && !content && !toolCalls.length) return { done: true, value: { success: false, error: `Ollama: ${streamError}` } };
969
+
970
+ const message = { role: 'assistant', content };
971
+ if (reasoning) message.reasoning = reasoning;
972
+ if (toolCalls.length) message.tool_calls = toolCalls;
973
+ return { done: true, value: { success: true, data: { choices: [{ message }], model: m.model }, modelUsed: m.model } };
974
+ }, opts.signal);
975
+ }
976
+
977
+ function chatViaCustom(messages, opts, m) {
978
+ if (m.kind === 'ollama') return chatViaOllamaNative(messages, opts, m);
979
+ const url = chatCompletionsUrl(m.baseUrl);
980
+ const local = isLocalUrl(url);
981
+ return streamingChatRequest({
982
+ url, label: m.name || m.model, model: m.model, apiKey: m.apiKey, isLocal: local,
983
+ messages, opts, hasSpare: false, idleMs: local ? LOCAL_IDLE_TIMEOUT_MS : REQUEST_TIMEOUT_MS,
984
+ });
985
+ }
986
+
987
+ /** Lists the models an Ollama host has pulled - powers "Connect Ollama". */
988
+ async function listOllamaModels(baseUrl) {
989
+ const host = ollamaHost(baseUrl);
990
+ try {
991
+ const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(5000) });
992
+ if (!res.ok) return { ok: false, host, error: `Ollama at ${host} answered HTTP ${res.status}.` };
993
+ const body = await res.json();
994
+ const models = (body.models || []).map((x) => ({ name: x.name, size: x.size || 0, family: x.details?.family || '', params: x.details?.parameter_size || '' }));
995
+ return { ok: true, host, models };
996
+ } catch {
997
+ return { ok: false, host, error: `Couldn't find Ollama at ${host}. Install it from ollama.com and make sure it's running.` };
998
+ }
999
+ }
1000
+
1001
+ /** A tiny real request against a model entry, so "Save" can confirm it actually works. */
1002
+ async function testModel(m) {
1003
+ const started = Date.now();
1004
+ const r = await chatViaCustom(
1005
+ [{ role: 'user', content: 'Reply with exactly: OK' }],
1006
+ { maxTokens: 16, signal: AbortSignal.timeout(90_000) },
1007
+ m,
1008
+ );
1009
+ if (!r.success) return { ok: false, error: r.aborted ? 'The model took longer than 90 seconds to answer.' : r.error };
1010
+ return { ok: true, ms: Date.now() - started, reply: (r.data.choices?.[0]?.message?.content || '').trim().slice(0, 80) };
1011
+ }
1012
+
1013
+ // ─── Auto ───────────────────────────────────────────────────────────────────
1014
+ // "Auto" is Gemma 4 31B on Ollama Cloud. Two account keys back it (a release
1015
+ // build bakes OLLAMA_API_KEY / OLLAMA_API_KEY_FALLBACK in via
1016
+ // scripts/embed-secrets.js; from source they come from ~/.codeply/config.json).
1017
+ // chatViaOllama moves to the second key the moment the first is over quota or
1018
+ // failing. Only if no key works at all does it fall back to the hosted proxy.
1019
+ const AUTO_MODEL = { host: 'https://ollama.com', model: 'gemma4:31b' };
1020
+
1021
+ async function chatViaAuto(messages, opts) {
1022
+ const cfg = getConfig();
1023
+ const keys = { apiKey: cfg.ollama.apiKey, apiKeyFallback: cfg.ollama.apiKeyFallback };
1024
+ if (!keys.apiKey && !keys.apiKeyFallback) return chatViaProxy(messages, opts);
1025
+ const r = await chatViaOllama(messages, opts, { ollama: { ...AUTO_MODEL, ...keys, numCtx: null } });
1026
+ if (r.success || r.aborted) return r;
1027
+ // A bad request (e.g. model issue) won't be fixed by another provider, but
1028
+ // an exhausted or unreachable account might be.
1029
+ if (isKeyLevelFailure(r.error) || /timed out|unreachable|fetch failed|ECONN|ENOTFOUND/i.test(r.error || '')) {
1030
+ const fallback = await chatViaProxy(messages, opts);
1031
+ if (fallback.success) return fallback;
1032
+ }
1033
+ return r;
1034
+ }
1035
+
1036
+ async function chat(messages, opts = {}) {
1037
+ // A user-added model (desktop model picker) always wins when routed.
1038
+ if (opts.route && opts.route.custom) return chatViaCustom(messages, opts, opts.route.custom);
1039
+ // Auto in the desktop app = the hosted Codeply model, regardless of what
1040
+ // the CLI's own `codeply provider` setting says.
1041
+ if (opts.route && opts.route.auto) return chatViaAuto(messages, opts);
1042
+ const cfg = applyRoute(getConfig(), opts.route);
1043
+ switch (cfg.provider) {
1044
+ case 'ollama': return chatViaOllama(messages, opts, cfg);
1045
+ case 'anthropic': return chatViaAnthropic(messages, opts, cfg);
1046
+ case 'openrouter':
1047
+ case 'groq':
1048
+ case 'openai':
1049
+ case 'google':
1050
+ case 'qwen':
1051
+ case 'deepseek':
1052
+ return chatViaOpenAICompatible(messages, opts, cfg.provider, cfg);
1053
+ default: return chatViaProxy(messages, opts);
1054
+ }
1055
+ }
1056
+
1057
+ function tryParseJson(s) {
1058
+ try { return { ok: true, value: JSON.parse(s) }; }
1059
+ catch { return { ok: false }; }
1060
+ }
1061
+
1062
+ /**
1063
+ * `{ json: true }` asks the provider for a strict JSON response, but that's
1064
+ * only ever a hint - response_format: json_object is a best-effort request,
1065
+ * not a guarantee, and plenty of models (especially smaller/self-hosted
1066
+ * ones) still answer with a markdown code fence around the object, or a
1067
+ * sentence of prose before/after it, even when told not to. A single strict
1068
+ * JSON.parse on the raw content turned every one of those into a hard
1069
+ * failure - "AI returned an unreadable response" - even though the actual
1070
+ * JSON was sitting right there. Three attempts, each a superset of what the
1071
+ * last one handles:
1072
+ * 1. The raw content, as-is (the common, well-behaved case).
1073
+ * 2. The content inside a ```json ... ``` / ``` ... ``` fence, if present.
1074
+ * 3. The first '{' to the last '}' in the whole reply - covers stray
1075
+ * leading/trailing prose the model added despite being asked not to.
1076
+ */
1077
+ function extractJsonObject(text) {
1078
+ const direct = tryParseJson(text);
1079
+ if (direct.ok) return direct.value;
1080
+
1081
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
1082
+ if (fenced) {
1083
+ const inner = tryParseJson(fenced[1].trim());
1084
+ if (inner.ok) return inner.value;
1085
+ }
1086
+
1087
+ const start = text.indexOf('{');
1088
+ const end = text.lastIndexOf('}');
1089
+ if (start !== -1 && end > start) {
1090
+ const sliced = tryParseJson(text.slice(start, end + 1));
1091
+ if (sliced.ok) return sliced.value;
1092
+ }
1093
+
1094
+ return undefined;
1095
+ }
1096
+
1097
+ /** Chat helper that expects a JSON object back and parses it. */
1098
+ async function chatJson(messages, opts = {}) {
1099
+ const r = await chat(messages, { ...opts, json: true });
1100
+ if (!r.success) return { success: false, error: r.error };
1101
+ const raw = r.data.choices?.[0]?.message?.content || '';
1102
+ const parsed = extractJsonObject(raw);
1103
+ if (parsed === undefined) return { success: false, error: 'AI returned an unreadable response.' };
1104
+ return { success: true, json: parsed, usage: r.data.usage || {}, modelUsed: r.modelUsed };
1105
+ }
1106
+
1107
+ module.exports = { chat, chatJson, isRateLimitError, listOllamaModels, testModel, chatCompletionsUrl };