fixo-cli 1.0.4 → 2.0.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.

Potentially problematic release.


This version of fixo-cli might be problematic. Click here for more details.

Files changed (199) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +18 -14
  3. package/dist/agent/agent-client.d.ts +28 -6
  4. package/dist/agent/agent-client.d.ts.map +1 -1
  5. package/dist/agent/agent-client.js +118 -39
  6. package/dist/agent/agent-client.js.map +1 -1
  7. package/dist/agent/agent-pool.d.ts +55 -6
  8. package/dist/agent/agent-pool.d.ts.map +1 -1
  9. package/dist/agent/agent-pool.js +120 -20
  10. package/dist/agent/agent-pool.js.map +1 -1
  11. package/dist/agent/auto-verifier.d.ts +55 -0
  12. package/dist/agent/auto-verifier.d.ts.map +1 -0
  13. package/dist/agent/auto-verifier.js +50 -0
  14. package/dist/agent/auto-verifier.js.map +1 -0
  15. package/dist/agent/command-parser.d.ts.map +1 -1
  16. package/dist/agent/command-parser.js +176 -0
  17. package/dist/agent/command-parser.js.map +1 -1
  18. package/dist/agent/context-builder.d.ts +24 -0
  19. package/dist/agent/context-builder.d.ts.map +1 -0
  20. package/dist/agent/context-builder.js +197 -0
  21. package/dist/agent/context-builder.js.map +1 -0
  22. package/dist/agent/conversation.d.ts +14 -1
  23. package/dist/agent/conversation.d.ts.map +1 -1
  24. package/dist/agent/conversation.js +53 -7
  25. package/dist/agent/conversation.js.map +1 -1
  26. package/dist/agent/mcp-bridge.js +1 -1
  27. package/dist/agent/mcp-bridge.js.map +1 -1
  28. package/dist/agent/orchestrator.d.ts +45 -0
  29. package/dist/agent/orchestrator.d.ts.map +1 -1
  30. package/dist/agent/orchestrator.js +140 -3
  31. package/dist/agent/orchestrator.js.map +1 -1
  32. package/dist/agent/parser-adapter.d.ts +17 -0
  33. package/dist/agent/parser-adapter.d.ts.map +1 -1
  34. package/dist/agent/parser-adapter.js +254 -2
  35. package/dist/agent/parser-adapter.js.map +1 -1
  36. package/dist/agent/predictive-gate.d.ts.map +1 -1
  37. package/dist/agent/predictive-gate.js +4 -1
  38. package/dist/agent/predictive-gate.js.map +1 -1
  39. package/dist/agent/providers-manager.d.ts +5 -0
  40. package/dist/agent/providers-manager.d.ts.map +1 -1
  41. package/dist/agent/providers-manager.js +119 -8
  42. package/dist/agent/providers-manager.js.map +1 -1
  43. package/dist/agent/repo-map.d.ts +18 -1
  44. package/dist/agent/repo-map.d.ts.map +1 -1
  45. package/dist/agent/repo-map.js +144 -54
  46. package/dist/agent/repo-map.js.map +1 -1
  47. package/dist/agent/retry.js +1 -2
  48. package/dist/agent/retry.js.map +1 -1
  49. package/dist/agent/single-agent.d.ts.map +1 -1
  50. package/dist/agent/single-agent.js +129 -22
  51. package/dist/agent/single-agent.js.map +1 -1
  52. package/dist/agent/skills.d.ts.map +1 -1
  53. package/dist/agent/skills.js +2 -1
  54. package/dist/agent/skills.js.map +1 -1
  55. package/dist/agent/subagent.js +2 -2
  56. package/dist/agent/subagent.js.map +1 -1
  57. package/dist/agent/task-router.d.ts +46 -0
  58. package/dist/agent/task-router.d.ts.map +1 -0
  59. package/dist/agent/task-router.js +352 -0
  60. package/dist/agent/task-router.js.map +1 -0
  61. package/dist/agent/telemetry.d.ts +29 -1
  62. package/dist/agent/telemetry.d.ts.map +1 -1
  63. package/dist/agent/telemetry.js +25 -10
  64. package/dist/agent/telemetry.js.map +1 -1
  65. package/dist/agent/tool-definitions.d.ts +3 -0
  66. package/dist/agent/tool-definitions.d.ts.map +1 -0
  67. package/dist/agent/tool-definitions.js +519 -0
  68. package/dist/agent/tool-definitions.js.map +1 -0
  69. package/dist/agent/tool-executor.d.ts +6 -1
  70. package/dist/agent/tool-executor.d.ts.map +1 -1
  71. package/dist/agent/tool-executor.js +99 -553
  72. package/dist/agent/tool-executor.js.map +1 -1
  73. package/dist/agent/tools/command-tools.d.ts +6 -0
  74. package/dist/agent/tools/command-tools.d.ts.map +1 -0
  75. package/dist/agent/tools/command-tools.js +104 -0
  76. package/dist/agent/tools/command-tools.js.map +1 -0
  77. package/dist/agent/tools/file-tools.d.ts +15 -0
  78. package/dist/agent/tools/file-tools.d.ts.map +1 -0
  79. package/dist/agent/tools/file-tools.js +551 -0
  80. package/dist/agent/tools/file-tools.js.map +1 -0
  81. package/dist/agent/tools/todo-tools.d.ts +3 -0
  82. package/dist/agent/tools/todo-tools.d.ts.map +1 -0
  83. package/dist/agent/tools/todo-tools.js +70 -0
  84. package/dist/agent/tools/todo-tools.js.map +1 -0
  85. package/dist/agent/web-impl.d.ts.map +1 -1
  86. package/dist/agent/web-impl.js +45 -0
  87. package/dist/agent/web-impl.js.map +1 -1
  88. package/dist/agent/worker-agent.d.ts +3 -1
  89. package/dist/agent/worker-agent.d.ts.map +1 -1
  90. package/dist/agent/worker-agent.js +51 -14
  91. package/dist/agent/worker-agent.js.map +1 -1
  92. package/dist/config.d.ts +242 -0
  93. package/dist/config.d.ts.map +1 -1
  94. package/dist/config.js +79 -0
  95. package/dist/config.js.map +1 -1
  96. package/dist/git/git-manager.d.ts +33 -2
  97. package/dist/git/git-manager.d.ts.map +1 -1
  98. package/dist/git/git-manager.js +111 -15
  99. package/dist/git/git-manager.js.map +1 -1
  100. package/dist/git/git-ops.d.ts.map +1 -1
  101. package/dist/git/git-ops.js +2 -1
  102. package/dist/git/git-ops.js.map +1 -1
  103. package/dist/index.js +85 -8
  104. package/dist/index.js.map +1 -1
  105. package/dist/lsp/lsp-manager.js +1 -1
  106. package/dist/lsp/lsp-manager.js.map +1 -1
  107. package/dist/model-outcomes.d.ts.map +1 -1
  108. package/dist/model-outcomes.js +2 -1
  109. package/dist/model-outcomes.js.map +1 -1
  110. package/dist/planner.d.ts +0 -9
  111. package/dist/planner.d.ts.map +1 -1
  112. package/dist/planner.js +0 -9
  113. package/dist/planner.js.map +1 -1
  114. package/dist/project-memory.d.ts +12 -1
  115. package/dist/project-memory.d.ts.map +1 -1
  116. package/dist/project-memory.js +8 -6
  117. package/dist/project-memory.js.map +1 -1
  118. package/dist/runtime/loop-mitigation.d.ts +78 -7
  119. package/dist/runtime/loop-mitigation.d.ts.map +1 -1
  120. package/dist/runtime/loop-mitigation.js +122 -9
  121. package/dist/runtime/loop-mitigation.js.map +1 -1
  122. package/dist/runtime/os-sandbox.d.ts +100 -0
  123. package/dist/runtime/os-sandbox.d.ts.map +1 -0
  124. package/dist/runtime/os-sandbox.js +246 -0
  125. package/dist/runtime/os-sandbox.js.map +1 -0
  126. package/dist/runtime/run-inventory.d.ts +17 -0
  127. package/dist/runtime/run-inventory.d.ts.map +1 -0
  128. package/dist/runtime/run-inventory.js +49 -0
  129. package/dist/runtime/run-inventory.js.map +1 -0
  130. package/dist/runtime/staging.d.ts.map +1 -1
  131. package/dist/runtime/staging.js +4 -1
  132. package/dist/runtime/staging.js.map +1 -1
  133. package/dist/runtime/task-session.d.ts +14 -0
  134. package/dist/runtime/task-session.d.ts.map +1 -1
  135. package/dist/runtime/task-session.js +26 -0
  136. package/dist/runtime/task-session.js.map +1 -1
  137. package/dist/setup-wizard.d.ts +11 -3
  138. package/dist/setup-wizard.d.ts.map +1 -1
  139. package/dist/setup-wizard.js +113 -15
  140. package/dist/setup-wizard.js.map +1 -1
  141. package/dist/types.d.ts +8 -0
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/ui/commands/context-commands.d.ts +7 -0
  144. package/dist/ui/commands/context-commands.d.ts.map +1 -0
  145. package/dist/ui/commands/context-commands.js +241 -0
  146. package/dist/ui/commands/context-commands.js.map +1 -0
  147. package/dist/ui/commands/index.d.ts +3 -0
  148. package/dist/ui/commands/index.d.ts.map +1 -0
  149. package/dist/ui/commands/index.js +46 -0
  150. package/dist/ui/commands/index.js.map +1 -0
  151. package/dist/ui/commands/info-commands.d.ts +15 -0
  152. package/dist/ui/commands/info-commands.d.ts.map +1 -0
  153. package/dist/ui/commands/info-commands.js +122 -0
  154. package/dist/ui/commands/info-commands.js.map +1 -0
  155. package/dist/ui/commands/model-commands.d.ts +5 -0
  156. package/dist/ui/commands/model-commands.d.ts.map +1 -0
  157. package/dist/ui/commands/model-commands.js +417 -0
  158. package/dist/ui/commands/model-commands.js.map +1 -0
  159. package/dist/ui/commands/session-commands.d.ts +5 -0
  160. package/dist/ui/commands/session-commands.d.ts.map +1 -0
  161. package/dist/ui/commands/session-commands.js +154 -0
  162. package/dist/ui/commands/session-commands.js.map +1 -0
  163. package/dist/ui/commands/task-commands.d.ts +8 -0
  164. package/dist/ui/commands/task-commands.d.ts.map +1 -0
  165. package/dist/ui/commands/task-commands.js +152 -0
  166. package/dist/ui/commands/task-commands.js.map +1 -0
  167. package/dist/ui/commands/types.d.ts +46 -0
  168. package/dist/ui/commands/types.d.ts.map +1 -0
  169. package/dist/ui/commands/types.js +2 -0
  170. package/dist/ui/commands/types.js.map +1 -0
  171. package/dist/ui/commands/workspace-commands.d.ts +8 -0
  172. package/dist/ui/commands/workspace-commands.d.ts.map +1 -0
  173. package/dist/ui/commands/workspace-commands.js +131 -0
  174. package/dist/ui/commands/workspace-commands.js.map +1 -0
  175. package/dist/ui/loading-animation.d.ts +24 -0
  176. package/dist/ui/loading-animation.d.ts.map +1 -0
  177. package/dist/ui/loading-animation.js +123 -0
  178. package/dist/ui/loading-animation.js.map +1 -0
  179. package/dist/ui/markdown-stream.js +2 -2
  180. package/dist/ui/markdown-stream.js.map +1 -1
  181. package/dist/ui/prompt.d.ts +7 -0
  182. package/dist/ui/prompt.d.ts.map +1 -1
  183. package/dist/ui/prompt.js +435 -1214
  184. package/dist/ui/prompt.js.map +1 -1
  185. package/dist/ui/render-primitives.d.ts +6 -0
  186. package/dist/ui/render-primitives.d.ts.map +1 -1
  187. package/dist/ui/render-primitives.js +30 -13
  188. package/dist/ui/render-primitives.js.map +1 -1
  189. package/dist/ui/render.d.ts.map +1 -1
  190. package/dist/ui/render.js +2 -0
  191. package/dist/ui/render.js.map +1 -1
  192. package/package.json +17 -3
  193. package/scripts/check-vendor-wasm.js +11 -0
  194. package/vendor/tree-sitter-go.wasm +0 -0
  195. package/vendor/tree-sitter-javascript.wasm +0 -0
  196. package/vendor/tree-sitter-python.wasm +0 -0
  197. package/vendor/tree-sitter-rust.wasm +0 -0
  198. package/vendor/tree-sitter-tsx.wasm +0 -0
  199. package/vendor/tree-sitter-typescript.wasm +0 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ All notable changes to FixO CLI will be documented in this file.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ---
8
+
9
+ ## [1.0.4] – 2025-06-26
10
+
11
+ ### Security
12
+ - **decryptKey** now throws on AES-256-GCM decryption failure instead of silently returning ciphertext, preventing corrupted keys from being used as live credentials.
13
+ - `getOrCreateRunId()` switched from `Math.random()` to `crypto.randomBytes(6)` for cryptographically secure staging-directory namespace IDs.
14
+ - `RETRYABLE_STATUS_CODES` in `agent-client.ts` now includes `504` (Gateway Timeout), matching the canonical set in `retry.ts`.
15
+
16
+ ### Bug Fixes
17
+ - Fixed a duplicate `name === 'AbortError'` condition in `defaultIsRetryable` (dead-code bug in `retry.ts`).
18
+ - `SIGINT` handler is now deduplicated when both the readline interface and the process fire simultaneously.
19
+ - `buildLavaStatusState()` now derives the `transport` field from the actual `provider_mode` config instead of always displaying `'freellmapi'`.
20
+ - `getOrCreateRunId()` uses canonical `MUTATION_TOOL_NAMES` set instead of a fragile string-heuristic for mutating action detection.
21
+
22
+ ### Improvements
23
+ - Non-null assertions (`!`) in setup-wizard provider registry lookups replaced with proper runtime guards.
24
+ - Removed dead empty section headers from `src/ui/prompt.ts`.
25
+ - Simplified `buildLavaStatusState()` ternary chain (removed unreachable `else` branch).
26
+ - Removed unused `width` variable from `drawSuggestions()`.
27
+ - Silent `catch {}` in `exitCleanup` now logs in debug/verbose mode.
28
+ - Trailing whitespace removed from `retry.ts`.
29
+
30
+ ### Packaging
31
+ - Added `"exports"` field to `package.json` for proper ESM resolution.
32
+ - Added `postinstall` script to enforce Node.js >= 20.0.0 at install time.
33
+ - `CHANGELOG.md` added to published `files` list.
34
+
35
+ ---
36
+
37
+ ## [1.0.3] – 2025-06-20
38
+
39
+ ### Added
40
+ - Atomic staging pipeline with rollback (`AtomicStagingManager`).
41
+ - LSP pre-save gate (Pillar 3) for syntax validation before disk writes.
42
+ - Semantic loop detector (`SemanticLoopDetector`) to complement hash-based loop trap.
43
+ - `run_command_async` / `poll_command_status` / `kill_command` tools for long-running tasks.
44
+ - `glob_files` tool using Node.js 22+ native `fs.promises.glob`.
45
+
46
+ ### Security
47
+ - AES-256-GCM encryption for API keys at rest in `providers.json`.
48
+ - `WorkspaceGuard.assertNotPlatformPath()` prevents agent from modifying its own source files.
49
+ - `SCRUB_PATTERNS` expanded to cover OpenAI, Anthropic, OpenRouter, GitHub, Google, AWS, and JWT tokens.
50
+ - Provider credential vault (`ProviderKeyVault`) with scoped `withApiKey` callbacks.
51
+
52
+ ---
53
+
54
+ ## [1.0.0] – 2025-05-01
55
+
56
+ ### Added
57
+ - Initial release of FixO CLI.
58
+ - Multi-provider support: OpenAI, Anthropic, Groq, Google, Mistral, Together, Perplexity, DeepSeek, Cohere, OpenRouter, NVIDIA, xAI, GitHub Models, Ollama, Zen.
59
+ - FreeLLMAPI proxy mode with load-balanced failover.
60
+ - Interactive setup wizard (`/setup`).
61
+ - Loop-trap detection, atomic writes, LSP integration.
62
+ - REPL with slash commands, autocomplete, paste attachments, and session history.
package/README.md CHANGED
@@ -4,11 +4,11 @@
4
4
  [![Language](https://img.shields.io/badge/Language-TypeScript-blue.svg)](https://www.typescriptlang.org/)
5
5
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
6
6
  [![Engine](https://img.shields.io/badge/Engine-Tree--Sitter-orange.svg)](https://tree-sitter.github.io/tree-sitter/)
7
- [![Status](https://img.shields.io/badge/Status-Production--Ready-brightgreen.svg)]()
7
+ [![Status](https://img.shields.io/badge/Status-Beta-yellow.svg)]()
8
8
 
9
- Fixo CLI is a terminal-based autonomous coding assistant designed to execute complex programming tasks directly in your workspace. Built as a self-correcting agent, it analyzes code using abstract syntax trees (AST), writes implementation plans, edits code files, runs test suites, and iterates until the goal is fully achieved.
9
+ Fixo CLI is a terminal-based autonomous coding assistant designed to execute complex programming tasks directly in your workspace. It writes implementation plans, edits code files, runs test suites, and iterates toward the goal. Tree-sitter is used today for shell-command parsing and LSP-fallback syntax checks; expanding it to the workspace symbol map is on the roadmap.
10
10
 
11
- Fixo CLI integrates seamlessly with **FreeLLMAPI**, automatically load-balancing and failing over across **20+ free LLM providers** (such as Gemini, Groq, SambaNova, Cerebras, and NVIDIA NIM) for zero-cost, state-of-the-art agentic coding.
11
+ Fixo CLI ships with **13 direct providers built-in** (OpenAI, Anthropic, Google, Groq, Mistral, Cohere, OpenRouter, NVIDIA, Cerebras, SambaNova, GitHub Models, xAI, Zen) — paste your own key for any of them. The optional **FreeLLMAPI** proxy backend is available as an opt-in convenience for users who want load-balanced failover across free-tier providers without managing individual keys.
12
12
 
13
13
  ---
14
14
 
@@ -18,33 +18,35 @@ Here is how Fixo CLI compares against other prominent terminal and editor-based
18
18
 
19
19
  | Feature / Metric | **Fixo CLI** | **Claude Code** | **Aider** | **Cline** |
20
20
  | :--- | :--- | :--- | :--- | :--- |
21
- | **API Cost** | 💰 **100% Free** (via FreeLLMAPI) | 💸 **Paid** (Anthropic API charges) | 💸 **Paid** (Requires personal keys) | 💸 **Paid** (Requires personal keys) |
22
- | **Multi-Provider Fallback**| 🔄 **Automatic Failover** (No interruptions) | ❌ None (Locked to Anthropic) | ❌ Manual (Requires editing configs) | ❌ Manual (Drops request on 429) |
23
- | **Workspace Indexing** | 🌳 **AST / Tree-Sitter** (Semantic map) | 🔍 Regex / basic grep | 🗺️ Git/ctags-based map | 🔍 Basic file search |
24
- | **Autonomy Loops** | 🤖 **Multi-agent / Planning Mode** | 🤖 Agent loops | 💬 Interactive / chat-driven | 💬 Prompt-to-action loops |
25
- | **Self-Correction** | 🧪 **Built-in test runner & loops** | ❌ Manual trigger | ❌ Requires manual input | ❌ Requires manual input |
26
- | **No-Card Verification** | ✅ **Yes** (Zero billing required) | ❌ No (Requires credit card) | ❌ No (Requires paid API keys) | ❌ No (Requires paid API keys) |
21
+ | **API Cost** | 💰 **BYOK or free via optional FreeLLMAPI proxy** | 💸 **Paid** (Anthropic API charges) | 💸 **Paid** (Requires personal keys) | 💸 **Paid** (Requires personal keys) |
22
+ | **Multi-Provider Fallback**| 🔄 **Automatic failover (FreeLLMAPI proxy mode)** | ❌ None (Locked to Anthropic) | ❌ Manual (Requires editing configs) | ❌ Manual (Drops request on 429) |
23
+ | **Workspace Indexing** | 🗂️ Depth-capped regex scan (tree-sitter symbol map planned) | 🔍 Regex / basic grep | 🗺️ Git/ctags-based map | 🔍 Basic file search |
24
+ | **Autonomy Loops** | 🤖 **Multi-agent / Planning Mode** ¹ | 🤖 Agent loops | 💬 Interactive / chat-driven | 💬 Prompt-to-action loops |
25
+ | **Self-Correction** | 🧪 Opt-in via `/fix-tests` (automatic post-edit verification on the roadmap) | ❌ Manual trigger | ❌ Requires manual input | ❌ Requires manual input |
26
+ | **No-Card Verification** | ✅ **Yes** (BYOK or free proxy — no card required either way) | ❌ No (Requires credit card) | ❌ No (Requires paid API keys) | ❌ No (Requires paid API keys) |
27
+
28
+ ¹ The multi-agent path is currently triggered by a keyword heuristic. An LLM-based complexity classifier is wired-but-dead in `src/planner.ts` and is being moved onto the live code path.
27
29
 
28
30
  ---
29
31
 
30
32
  ## ⚙️ Architecture & Lifecycle Flow
31
33
 
32
- Fixo CLI separates concerns between code understanding (AST parser), task coordination (Planner), and execution (Agent).
34
+ Fixo CLI separates concerns between code understanding (workspace indexer), task coordination (Planner), and execution (Agent).
33
35
 
34
36
  ```mermaid
35
37
  sequenceDiagram
36
38
  autonumber
37
39
  actor User as Developer
38
40
  participant CLI as Fixo CLI
39
- participant Indexer as AST Workspace Indexer
41
+ participant Indexer as Workspace Indexer
40
42
  participant Planner as Plan Engine
41
43
  participant Agent as Autonomous Agent
42
44
  participant Proxy as FreeLLMAPI Proxy
43
45
  participant LLM as Provider (Groq/Gemini/NIM)
44
46
 
45
47
  User->>CLI: Request task (e.g. "Fix auth bug")
46
- CLI->>Indexer: Scan repository & generate AST maps
47
- Indexer-->>CLI: Return semantic codebase layout
48
+ CLI->>Indexer: Scan repository & build export map
49
+ Indexer-->>CLI: Return repository structure summary
48
50
  CLI->>Planner: Propose implementation plan
49
51
  Planner->>Proxy: Fetch reasoning (smart routing)
50
52
  Proxy->>LLM: Try highest ranked provider
@@ -61,12 +63,14 @@ sequenceDiagram
61
63
  Agent-->>User: Task completed successfully!
62
64
  ```
63
65
 
66
+ > **Note on routing:** Smart routing across models (cheaper for planning, stronger for execution) is a feature of the optional FreeLLMAPI proxy backend. In direct-provider mode, requests use the model you selected at setup — local fast/heavy-tier substitution via `preferences.modelRouting` is on the roadmap.
67
+
64
68
  ---
65
69
 
66
70
  ## 🌟 Key Features
67
71
 
68
72
  * **Autonomous Agent Loop:** Fixo CLI runs an agent loop that defines planning sub-agents, writes files, runs shell commands, reads compiler output, and self-corrects until tests pass.
69
- * **Workspace AST Indexer:** Uses **Tree-Sitter** to parse JavaScript, TypeScript, Python, and Go codebases, generating a semantic repository map for precise context insertion.
73
+ * **Workspace Indexer:** Today, a depth-capped (4 levels, 200 files) directory scanner extracts exports via regex for a quick repository map. A tree-sitter-backed symbol map for TS/JS/Python/Go/Rust is on the roadmap; the WASM runtime is already vendored and used elsewhere in the codebase.
70
74
  * **Free Multi-Provider Routing:** Connects to your FreeLLMAPI server to query models like Llama 3.3, Qwen 3, and Gemini 2.5/3.1 without incurring high API costs.
71
75
  * **Smart Cooldown & Failover:** The CLI automatically tracks rate-limited providers (429/402/404) and switches to working alternatives in the fallback chain mid-request.
72
76
  * **Resilience Stack:** Stream recovery, provider cooldown, context-budget enforcement, and a local telemetry sink work together so the agent stays productive on flaky networks and large codebases. See [Resilience](#-resilience) below.
@@ -4,6 +4,7 @@
4
4
  * Includes retry with exponential backoff for transient errors.
5
5
  */
6
6
  import type { ChatMessage, ChatToolDefinition, ChatToolChoice, TokenUsage } from '../shared/types.js';
7
+ import { type ModelRoutingConfig } from '../config.js';
7
8
  export interface ChatOptions {
8
9
  tools?: ChatToolDefinition[];
9
10
  tool_choice?: ChatToolChoice;
@@ -68,19 +69,40 @@ export declare class HttpError extends Error {
68
69
  status: number;
69
70
  constructor(status: number, message: string);
70
71
  }
72
+ /**
73
+ * Thrown when the client is running in direct-provider mode but the
74
+ * model the caller asked for did not resolve to any direct provider
75
+ * via {@link AgentClient.resolveDirectConfig}. Catching this gives
76
+ * the UI a chance to suggest `/model` or `/providers add` instead of
77
+ * silently leaking the request to the FreeLLMAPI proxy.
78
+ */
79
+ export declare class DirectModelUnresolvedError extends Error {
80
+ model: string;
81
+ constructor(model: string);
82
+ }
71
83
  export declare class AgentClient {
72
84
  private baseUrl;
73
85
  private apiKey;
74
86
  private verbose;
75
- constructor(apiKey: string, apiUrl?: string, verbose?: boolean);
87
+ private providerMode;
88
+ private modelRouting;
89
+ constructor(apiKey: string, apiUrl?: string, verbose?: boolean, providerMode?: 'direct' | 'proxy', modelRouting?: ModelRoutingConfig);
90
+ /**
91
+ * Phase 2.4 — substitute the caller-supplied model with a
92
+ * configured tier when `required_capabilities` asks for one.
93
+ * Returns the caller's model unchanged when no matching tier is
94
+ * configured, so the call is a no-op for users who haven't set
95
+ * up routing.
96
+ */
97
+ private applyCapabilityRouting;
76
98
  private resolveDirectConfig;
77
99
  /**
78
- * Maps a model id to the provider that will actually serve the
79
- * request used as the key for `providerCooldown` tracking. The
80
- * `freellmapi` sentinel covers the proxy path; everything else
81
- * routes through a direct provider.
100
+ * Maps a model id to the tracking key for `providerCooldown`.
101
+ * Model-specific isolation ensures a timeout on one model (e.g.
102
+ * `openrouter:claude-3`) does not poison other models on the
103
+ * same provider gateway.
82
104
  */
83
- private getProviderId;
105
+ private getCooldownKey;
84
106
  chat(messages: ChatMessage[], model: string, options?: ChatOptions): Promise<ChatResult>;
85
107
  private executeSingleChatStreamAttempt;
86
108
  chatStream(messages: ChatMessage[], model: string, options?: ChatOptions): AsyncGenerator<StreamChunk>;
@@ -1 +1 @@
1
- {"version":3,"file":"agent-client.d.ts","sourceRoot":"","sources":["../../src/agent/agent-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent-client.d.ts","sourceRoot":"","sources":["../../src/agent/agent-client.ts"],"names":[],"mappings":"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"}
@@ -9,8 +9,23 @@ import { extractTextFromContent } from '../shared/content.js';
9
9
  /* ──────────────────────── Constants ──────────────────────── */
10
10
  const MAX_RETRIES = 5;
11
11
  const BASE_DELAY_MS = 1500;
12
- const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503]);
13
- const BASE_URL = process.env.FIXO_API_URL || DEFAULT_API_URL;
12
+ const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
13
+ function getValidatedApiUrl(urlStr) {
14
+ if (!urlStr)
15
+ return undefined;
16
+ try {
17
+ const parsed = new URL(urlStr);
18
+ if (parsed.protocol === 'http:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
19
+ console.warn(`[Security Warning] API URL is using an insecure HTTP protocol (${urlStr}). HTTPS is required for remote URLs. Falling back to default.`);
20
+ return undefined;
21
+ }
22
+ return urlStr;
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ const BASE_URL = getValidatedApiUrl(process.env.FIXO_API_URL) || DEFAULT_API_URL;
14
29
  /** Wrapper around `providerCooldown.recordFailure` that also emits a
15
30
  * telemetry event. Keeps the 6 callsites terse. */
16
31
  function trackProviderError(providerId, status, message) {
@@ -152,14 +167,55 @@ export class HttpError extends Error {
152
167
  }
153
168
  }
154
169
  /* ──────────────────────── AgentClient ──────────────────────── */
170
+ /**
171
+ * Thrown when the client is running in direct-provider mode but the
172
+ * model the caller asked for did not resolve to any direct provider
173
+ * via {@link AgentClient.resolveDirectConfig}. Catching this gives
174
+ * the UI a chance to suggest `/model` or `/providers add` instead of
175
+ * silently leaking the request to the FreeLLMAPI proxy.
176
+ */
177
+ export class DirectModelUnresolvedError extends Error {
178
+ model;
179
+ constructor(model) {
180
+ super(`Model "${model}" did not match any direct provider configured in your vault. ` +
181
+ `Run /providers to add a key, /model to pick a recognized model, or run setup again to switch to FreeLLMAPI proxy mode.`);
182
+ this.model = model;
183
+ this.name = 'DirectModelUnresolvedError';
184
+ }
185
+ }
155
186
  export class AgentClient {
156
187
  baseUrl;
157
188
  apiKey;
158
189
  verbose;
159
- constructor(apiKey, apiUrl, verbose = false) {
160
- this.baseUrl = process.env.FIXO_API_URL || apiUrl || BASE_URL;
190
+ providerMode;
191
+ modelRouting;
192
+ constructor(apiKey, apiUrl, verbose = false, providerMode = 'proxy', modelRouting) {
193
+ this.baseUrl = getValidatedApiUrl(process.env.FIXO_API_URL) || getValidatedApiUrl(apiUrl) || BASE_URL;
161
194
  this.apiKey = apiKey;
162
195
  this.verbose = verbose;
196
+ this.providerMode = providerMode;
197
+ this.modelRouting = modelRouting ?? {};
198
+ }
199
+ /**
200
+ * Phase 2.4 — substitute the caller-supplied model with a
201
+ * configured tier when `required_capabilities` asks for one.
202
+ * Returns the caller's model unchanged when no matching tier is
203
+ * configured, so the call is a no-op for users who haven't set
204
+ * up routing.
205
+ */
206
+ applyCapabilityRouting(model, capabilities) {
207
+ if (!capabilities || capabilities.length === 0)
208
+ return model;
209
+ if (capabilities.includes('fast') && this.modelRouting.fast) {
210
+ return this.modelRouting.fast;
211
+ }
212
+ if (capabilities.includes('heavy') && this.modelRouting.heavy) {
213
+ return this.modelRouting.heavy;
214
+ }
215
+ if (this.modelRouting.default) {
216
+ return this.modelRouting.default;
217
+ }
218
+ return model;
163
219
  }
164
220
  resolveDirectConfig(model) {
165
221
  const modelLower = model.toLowerCase();
@@ -241,29 +297,39 @@ export class AgentClient {
241
297
  return null;
242
298
  }
243
299
  /**
244
- * Maps a model id to the provider that will actually serve the
245
- * request used as the key for `providerCooldown` tracking. The
246
- * `freellmapi` sentinel covers the proxy path; everything else
247
- * routes through a direct provider.
300
+ * Maps a model id to the tracking key for `providerCooldown`.
301
+ * Model-specific isolation ensures a timeout on one model (e.g.
302
+ * `openrouter:claude-3`) does not poison other models on the
303
+ * same provider gateway.
248
304
  */
249
- getProviderId(model) {
305
+ getCooldownKey(model) {
250
306
  const direct = this.resolveDirectConfig(model);
251
307
  if (direct)
252
- return direct.providerName;
253
- return 'freellmapi';
308
+ return `${direct.providerName}:${model}`;
309
+ return `freellmapi:${model}`;
254
310
  }
255
311
  /* ─── Non-streaming chat ─── */
256
312
  async chat(messages, model, options = {}) {
257
313
  const { signal: externalSignal, ...restOptions } = options;
258
- const providerId = this.getProviderId(model);
259
- providerCooldown.assertAvailable(providerId);
314
+ // Phase 2.4 — substitute the model BEFORE provider resolution so
315
+ // both the routing decision and the eventual request body see
316
+ // the same name. No-op when no capabilities are tagged or no
317
+ // tier is configured.
318
+ model = this.applyCapabilityRouting(model, options.required_capabilities);
319
+ const cooldownKey = this.getCooldownKey(model);
320
+ providerCooldown.assertAvailable(cooldownKey);
260
321
  const direct = this.resolveDirectConfig(model);
261
322
  const isAnthropicDirect = direct && direct.providerName === 'anthropic';
262
- // Combine external abort signal with the internal 60s timeout so
263
- // the request aborts on EITHER signal (timeout OR user cancellation).
264
- const combinedSignal = externalSignal
265
- ? AbortSignal.any([AbortSignal.timeout(60000), externalSignal])
266
- : AbortSignal.timeout(60000);
323
+ // Direct-mode safety: refuse to silently fall through to the
324
+ // FreeLLMAPI proxy when the user explicitly chose direct mode at
325
+ // setup. A user who picked direct deserves a loud error, not a
326
+ // request that surprises them by transiting a third-party SaaS.
327
+ if (this.providerMode === 'direct' && !direct) {
328
+ throw new DirectModelUnresolvedError(model);
329
+ }
330
+ // The timeout was removed to allow slow reasoning models to take as long as they need.
331
+ // The request will only abort if the user explicitly cancels it via `externalSignal`.
332
+ const combinedSignal = externalSignal;
267
333
  let requestUrl = `${this.baseUrl}/chat/completions`;
268
334
  let headers = {
269
335
  'Content-Type': 'application/json',
@@ -335,7 +401,7 @@ export class AgentClient {
335
401
  body = JSON.stringify(bodyObj);
336
402
  }
337
403
  // Check for pre-flight cancellation
338
- if (combinedSignal.aborted) {
404
+ if (combinedSignal?.aborted) {
339
405
  throw new Error('Task cancelled by user.');
340
406
  }
341
407
  let lastError = null;
@@ -356,7 +422,7 @@ export class AgentClient {
356
422
  }
357
423
  // Retryable errors
358
424
  if (RETRYABLE_STATUS_CODES.has(response.status)) {
359
- trackProviderError(providerId, response.status, `HTTP ${response.status}`);
425
+ trackProviderError(cooldownKey, response.status, `HTTP ${response.status}`);
360
426
  const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
361
427
  if (attempt < MAX_RETRIES) {
362
428
  console.log(`${colors.yellow}⚠ [API] Error ${response.status}. Retrying in ${(delayMs / 1000).toFixed(1)}s (${attempt + 1}/${MAX_RETRIES})${colors.reset}`);
@@ -369,9 +435,11 @@ export class AgentClient {
369
435
  throw new Error(`API error (${response.status}): ${errorText}`);
370
436
  }
371
437
  const rawData = await response.json();
372
- const data = isAnthropicDirect ? translateAnthropicToOpenAI(rawData) : rawData;
438
+ const data = isAnthropicDirect
439
+ ? translateAnthropicToOpenAI(rawData)
440
+ : rawData;
373
441
  const choice = data.choices[0];
374
- providerCooldown.recordSuccess(providerId);
442
+ providerCooldown.recordSuccess(cooldownKey);
375
443
  // ChatResult.content is `string | null`. The widened
376
444
  // ChatMessage.content union allows blocks on input, but
377
445
  // every provider we ship returns text-only assistant
@@ -427,7 +495,7 @@ export class AgentClient {
427
495
  }
428
496
  }
429
497
  if (isNetworkError && attempt < MAX_RETRIES) {
430
- trackProviderError(providerId, 0, lastError.message.slice(0, 200));
498
+ trackProviderError(cooldownKey, 0, lastError.message.slice(0, 200));
431
499
  const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
432
500
  console.log(`${colors.yellow}⚠ [Network] ${lastError.message.slice(0, 60)}. Retrying in ${(delayMs / 1000).toFixed(1)}s (${attempt + 1}/${MAX_RETRIES})${colors.reset}`);
433
501
  await sleep(delayMs);
@@ -442,9 +510,9 @@ export class AgentClient {
442
510
  throw lastError ?? new Error('All retry attempts exhausted.');
443
511
  }
444
512
  /* ─── Streaming chat (SSE) ─── */
445
- async *executeSingleChatStreamAttempt(requestUrl, headers, body, model, isAnthropicDirect, signal = AbortSignal.timeout(60000)) {
513
+ async *executeSingleChatStreamAttempt(requestUrl, headers, body, model, isAnthropicDirect, signal) {
446
514
  // Pre-flight cancellation check
447
- if (signal.aborted) {
515
+ if (signal?.aborted) {
448
516
  throw new Error('Task cancelled by user.');
449
517
  }
450
518
  const response = await fetch(requestUrl, {
@@ -722,13 +790,18 @@ export class AgentClient {
722
790
  }
723
791
  async *chatStream(messages, model, options = {}) {
724
792
  const { signal: externalSignal, ...restOptions } = options;
725
- const combinedSignal = externalSignal
726
- ? AbortSignal.any([AbortSignal.timeout(60000), externalSignal])
727
- : AbortSignal.timeout(60000);
728
- const providerId = this.getProviderId(model);
729
- providerCooldown.assertAvailable(providerId);
793
+ // Phase 2.4 — capability-tier substitution (see chat() comment).
794
+ model = this.applyCapabilityRouting(model, options.required_capabilities);
795
+ // The timeout was removed to allow slow reasoning models to take as long as they need.
796
+ const combinedSignal = externalSignal;
797
+ const cooldownKey = this.getCooldownKey(model);
798
+ providerCooldown.assertAvailable(cooldownKey);
730
799
  const direct = this.resolveDirectConfig(model);
731
800
  const isAnthropicDirect = !!(direct && direct.providerName === 'anthropic');
801
+ // Same direct-mode safety as `chat()` — refuse to leak to proxy.
802
+ if (this.providerMode === 'direct' && !direct) {
803
+ throw new DirectModelUnresolvedError(model);
804
+ }
732
805
  let requestUrl = `${this.baseUrl}/chat/completions`;
733
806
  let headers = {
734
807
  'Content-Type': 'application/json',
@@ -807,7 +880,7 @@ export class AgentClient {
807
880
  hasYielded = true;
808
881
  yield chunk;
809
882
  }
810
- providerCooldown.recordSuccess(providerId);
883
+ providerCooldown.recordSuccess(cooldownKey);
811
884
  return; // Success — don't retry
812
885
  }
813
886
  catch (error) {
@@ -850,7 +923,7 @@ export class AgentClient {
850
923
  }
851
924
  }
852
925
  if (lastError instanceof HttpError && RETRYABLE_STATUS_CODES.has(lastError.status)) {
853
- trackProviderError(providerId, lastError.status, `HTTP ${lastError.status}`);
926
+ trackProviderError(cooldownKey, lastError.status, `HTTP ${lastError.status}`);
854
927
  const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
855
928
  if (attempt < MAX_RETRIES) {
856
929
  console.log(`${colors.yellow}⚠ [API] Error ${lastError.status}. Retrying in ${(delayMs / 1000).toFixed(1)}s (${attempt + 1}/${MAX_RETRIES})${colors.reset}`);
@@ -859,7 +932,7 @@ export class AgentClient {
859
932
  }
860
933
  }
861
934
  if (isNetworkError && attempt < MAX_RETRIES) {
862
- trackProviderError(providerId, 0, lastError.message.slice(0, 200));
935
+ trackProviderError(cooldownKey, 0, lastError.message.slice(0, 200));
863
936
  const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
864
937
  console.log(`${colors.yellow}⚠ [Network] ${lastError.message.slice(0, 60)}. Retrying in ${(delayMs / 1000).toFixed(1)}s (${attempt + 1}/${MAX_RETRIES})${colors.reset}`);
865
938
  await sleep(delayMs);
@@ -979,8 +1052,8 @@ export class AgentClient {
979
1052
  }
980
1053
  }
981
1054
  async getEmbedding(text, model = 'text-embedding-3-small') {
982
- const providerId = this.getProviderId(model);
983
- providerCooldown.assertAvailable(providerId);
1055
+ const cooldownKey = this.getCooldownKey(model);
1056
+ providerCooldown.assertAvailable(cooldownKey);
984
1057
  const direct = this.resolveDirectConfig(model);
985
1058
  let requestUrl = `${this.baseUrl}/embeddings`;
986
1059
  let headers = {
@@ -1008,7 +1081,7 @@ export class AgentClient {
1008
1081
  body,
1009
1082
  });
1010
1083
  if (RETRYABLE_STATUS_CODES.has(response.status)) {
1011
- trackProviderError(providerId, response.status, `HTTP ${response.status}`);
1084
+ trackProviderError(cooldownKey, response.status, `HTTP ${response.status}`);
1012
1085
  const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
1013
1086
  if (attempt < MAX_RETRIES) {
1014
1087
  if (this.verbose) {
@@ -1024,7 +1097,7 @@ export class AgentClient {
1024
1097
  }
1025
1098
  const data = await response.json();
1026
1099
  if (data.data && data.data[0] && data.data[0].embedding) {
1027
- providerCooldown.recordSuccess(providerId);
1100
+ providerCooldown.recordSuccess(cooldownKey);
1028
1101
  return data.data[0].embedding;
1029
1102
  }
1030
1103
  throw new Error('Malformed embedding response structure');
@@ -1037,7 +1110,7 @@ export class AgentClient {
1037
1110
  error.message.includes('fetch failed') ||
1038
1111
  error.message.includes('ETIMEDOUT'));
1039
1112
  if (isNetworkError) {
1040
- trackProviderError(providerId, 0, error.message.slice(0, 200));
1113
+ trackProviderError(cooldownKey, 0, error.message.slice(0, 200));
1041
1114
  }
1042
1115
  const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
1043
1116
  await sleep(delayMs);
@@ -1153,6 +1226,11 @@ function messagesForOpenAIWire(messages) {
1153
1226
  }
1154
1227
  function translateOpenAIToAnthropic(messages, model, options) {
1155
1228
  let system = '';
1229
+ // Phase 4.6 — `any[]` paydown. The shape here is the Anthropic
1230
+ // wire-format messages array. The narrower type doesn't capture
1231
+ // every field the SDK accepts (tool_result, document blocks),
1232
+ // but `unknown[]` lets us keep type-safety at this construction
1233
+ // site without inventing a half-typed interface that drifts.
1156
1234
  const anthropicMessages = [];
1157
1235
  for (const msg of messages) {
1158
1236
  if (msg.role === 'system') {
@@ -1173,6 +1251,7 @@ function translateOpenAIToAnthropic(messages, model, options) {
1173
1251
  else if (msg.role === 'assistant') {
1174
1252
  const assistantText = extractTextFromContent(msg.content);
1175
1253
  if (msg.tool_calls && msg.tool_calls.length > 0) {
1254
+ // Phase 4.6 — see `anthropicMessages` comment for rationale.
1176
1255
  const contentBlocks = [];
1177
1256
  if (assistantText.length > 0) {
1178
1257
  contentBlocks.push({ type: 'text', text: assistantText });
@@ -1279,7 +1358,7 @@ function translateAnthropicToOpenAI(anthropicRes) {
1279
1358
  role: 'assistant',
1280
1359
  content: text || null,
1281
1360
  },
1282
- finish_reason: finishReasonMap[anthropicRes.stop_reason] || 'stop',
1361
+ finish_reason: finishReasonMap[anthropicRes.stop_reason ?? ''] || 'stop',
1283
1362
  };
1284
1363
  if (toolCalls.length > 0) {
1285
1364
  choice.message.tool_calls = toolCalls;