lcode-cli 0.34.0__tar.gz → 0.35.0__tar.gz

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 (172) hide show
  1. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/CHANGELOG.md +31 -1
  2. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/bench.md +16 -0
  4. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/configuration.md +3 -0
  5. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/quickstart.md +1 -1
  6. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/usage.md +46 -0
  7. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/pyproject.toml +1 -0
  8. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/__init__.py +1 -1
  9. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/agent.py +32 -3
  10. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/api.py +5 -0
  11. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/backends.py +7 -2
  12. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/bench.py +53 -17
  13. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/cli.py +22 -1
  14. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/config.py +24 -0
  15. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/context.py +40 -15
  16. lcode_cli-0.35.0/src/lcode/escalate.py +315 -0
  17. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/repl.py +38 -1
  18. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/screenshots.py +21 -9
  19. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/tools.py +2 -1
  20. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/conftest.py +1 -1
  21. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_bench.py +2 -0
  22. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_context.py +35 -0
  23. lcode_cli-0.35.0/tests/test_escalate.py +333 -0
  24. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_screenshots.py +37 -0
  25. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.editorconfig +0 -0
  26. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.github/CODEOWNERS +0 -0
  27. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  28. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  29. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  30. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  31. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.github/dependabot.yml +0 -0
  33. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.github/workflows/ci.yml +0 -0
  34. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.github/workflows/docs.yml +0 -0
  35. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.github/workflows/release.yml +0 -0
  36. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.gitignore +0 -0
  37. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/.pre-commit-config.yaml +0 -0
  38. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/AGENTS.md +0 -0
  39. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/CODE_OF_CONDUCT.md +0 -0
  40. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/CONTRIBUTING.md +0 -0
  41. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/LICENSE +0 -0
  42. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/README.md +0 -0
  43. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/SECURITY.md +0 -0
  44. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/action.yml +0 -0
  45. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/agents.md +0 -0
  46. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/assets/demo.svg +0 -0
  47. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/assets/extra.css +0 -0
  48. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/automation.md +0 -0
  49. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/changelog.md +0 -0
  50. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/commands.md +0 -0
  51. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/contributing.md +0 -0
  52. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/editors.md +0 -0
  53. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/git.md +0 -0
  54. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/hooks.md +0 -0
  55. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/how-it-works.md +0 -0
  56. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/index.md +0 -0
  57. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/installation.md +0 -0
  58. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/lsp.md +0 -0
  59. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/mcp.md +0 -0
  60. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/memory.md +0 -0
  61. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/models.md +0 -0
  62. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/sandbox.md +0 -0
  63. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/search.md +0 -0
  64. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/servers.md +0 -0
  65. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/docs/troubleshooting.md +0 -0
  66. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/examples/README.md +0 -0
  67. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/examples/api/fix_tests.py +0 -0
  68. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/examples/commands/review.md +0 -0
  69. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/examples/skills/commit-message/SKILL.md +0 -0
  70. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  71. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/install.sh +0 -0
  72. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/mkdocs.yml +0 -0
  73. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/overrides/main.html +0 -0
  74. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/scripts/make_logos.py +0 -0
  75. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/scripts/record_demo.py +0 -0
  76. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/scripts/social-preview.html +0 -0
  77. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/__main__.py +0 -0
  78. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/acp.py +0 -0
  79. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/action.py +0 -0
  80. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/bench_backend.py +0 -0
  81. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/bench_runtime.py +0 -0
  82. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/catalog.py +0 -0
  83. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/checkpoints.py +0 -0
  84. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/checks.py +0 -0
  85. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/clipboard.py +0 -0
  86. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/codesearch.py +0 -0
  87. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/extensions.py +0 -0
  88. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/frontmatter.py +0 -0
  89. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/gitflow.py +0 -0
  90. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/hardware.py +0 -0
  91. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/hooks.py +0 -0
  92. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/jobs.py +0 -0
  93. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/limits.py +0 -0
  94. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/lsp.py +0 -0
  95. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/matching.py +0 -0
  96. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/__init__.py +0 -0
  97. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/auth.py +0 -0
  98. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/catalog.py +0 -0
  99. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/catalog.toml +0 -0
  100. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/client.py +0 -0
  101. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/commands.py +0 -0
  102. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/config.py +0 -0
  103. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/http.py +0 -0
  104. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/manager.py +0 -0
  105. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/protocol.py +0 -0
  106. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/server.py +0 -0
  107. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/mcp/stdio.py +0 -0
  108. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/memory.py +0 -0
  109. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/models.toml +0 -0
  110. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/notify.py +0 -0
  111. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/ollama.py +0 -0
  112. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/permissions.py +0 -0
  113. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/planning.py +0 -0
  114. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/render.py +0 -0
  115. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/repeats.py +0 -0
  116. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/repomap.py +0 -0
  117. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/residency.py +0 -0
  118. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/review.py +0 -0
  119. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/sandbox.py +0 -0
  120. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/secretscan.py +0 -0
  121. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/servers/__init__.py +0 -0
  122. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/servers/encord.py +0 -0
  123. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/servers/valohai.py +0 -0
  124. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/servers/xai.py +0 -0
  125. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/sessions.py +0 -0
  126. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/steer.py +0 -0
  127. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/subagents.py +0 -0
  128. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/update.py +0 -0
  129. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/vision.py +0 -0
  130. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/src/lcode/web.py +0 -0
  131. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/fake_lsp_server.py +0 -0
  132. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/fake_mcp_server.py +0 -0
  133. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_acp.py +0 -0
  134. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_action.py +0 -0
  135. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_agent.py +0 -0
  136. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_api.py +0 -0
  137. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_backends.py +0 -0
  138. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_catalog.py +0 -0
  139. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_checkpoints.py +0 -0
  140. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_checks.py +0 -0
  141. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_cli.py +0 -0
  142. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_clipboard.py +0 -0
  143. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_codesearch.py +0 -0
  144. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_config.py +0 -0
  145. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_context_picker.py +0 -0
  146. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_extensions.py +0 -0
  147. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_gitflow.py +0 -0
  148. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_hooks.py +0 -0
  149. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_jobs.py +0 -0
  150. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_lsp.py +0 -0
  151. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_matching.py +0 -0
  152. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_mcp.py +0 -0
  153. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_memory.py +0 -0
  154. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_permissions.py +0 -0
  155. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_plan_mode.py +0 -0
  156. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_repeats.py +0 -0
  157. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_repomap.py +0 -0
  158. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_residency.py +0 -0
  159. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_review.py +0 -0
  160. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_robustness.py +0 -0
  161. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_sandbox.py +0 -0
  162. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_secretscan.py +0 -0
  163. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_servers.py +0 -0
  164. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_sessions.py +0 -0
  165. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_steer.py +0 -0
  166. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_subagents.py +0 -0
  167. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_tools.py +0 -0
  168. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_update.py +0 -0
  169. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_vision.py +0 -0
  170. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_web.py +0 -0
  171. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/tests/test_xai.py +0 -0
  172. {lcode_cli-0.34.0 → lcode_cli-0.35.0}/uv.lock +0 -0
@@ -6,6 +6,35 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.35.0] - 2026-10-14
10
+
11
+ ### Added
12
+
13
+ - A stronger model when stuck: with `escalate_model` set (a cloud model with your API key, such as
14
+ `anthropic/…`, `openai/…` or `xai/…`, or a bigger local one, `ollama/qwen3.8-27b`), lcode
15
+ offers to ask it for advice when the loop breaker would stop a request or the checks fail twice
16
+ in a row, and `/escalate` asks at any time. Nothing is sent without asking, in any permission
17
+ mode: you see why, what goes (your requests, the task sheet, the failure, the last steps and the
18
+ files in question, never the whole session), where, and the cost (`escalate_price`), and can
19
+ read it all first. Keys come only from environment variables and go only to their provider.
20
+ `lcode -p --escalate` allows it for one run, and `lcode bench --retry --escalate MODEL`
21
+ measures it. Measured on a 12 GB laptop with qwen3.6-35b and the bigger local qwen3.8-27b as
22
+ the advisor, on `runtime-bug` with a second try: 1 of 3 passed with its advice, as without it
23
+ (6 of 9 hidden-test problems fixed against 4), at about 9 minutes per question. A local advisor
24
+ helps a little; a cloud model is the one to try. See
25
+ [A stronger model when stuck](https://nasser1941.github.io/lcode/usage/#a-stronger-model-when-stuck).
26
+
27
+ ### Fixed
28
+
29
+ - Long command output can't fill the disk any more. lcode keeps the full text of long outputs so the
30
+ model can read parts of them, but a command that printed gigabytes was saved whole, and the
31
+ saved outputs had no total limit, only an age (7 days). Now one is kept to its first 20 MB (the
32
+ model still gets the end and the error lines), and all of them together to 200 MB, oldest first.
33
+
34
+ - A screenshot of a web app no longer starts its server a second time when one already answers at
35
+ the URL (one the model started itself, say), which used to fail on the busy port and put the
36
+ error in the result. A page that answers also counts when the server command itself exits.
37
+
9
38
  ## [0.34.0] - 2026-10-14
10
39
 
11
40
  ### Added
@@ -740,7 +769,8 @@ First public release.
740
769
  - `AGENTS.md` project instructions and `/init` to generate them.
741
770
  - One-line installer for Linux and macOS.
742
771
 
743
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.34.0...HEAD
772
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.35.0...HEAD
773
+ [0.35.0]: https://github.com/nasser1941/lcode/compare/v0.34.0...v0.35.0
744
774
  [0.34.0]: https://github.com/nasser1941/lcode/compare/v0.33.1...v0.34.0
745
775
  [0.33.1]: https://github.com/nasser1941/lcode/compare/v0.33.0...v0.33.1
746
776
  [0.33.0]: https://github.com/nasser1941/lcode/compare/v0.32.0...v0.33.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.34.0
3
+ Version: 0.35.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -45,6 +45,22 @@ cause. Neither is in the default set, so results stay comparable with earlier ve
45
45
  `--code-search` indexes each task's folder for [semantic search](search.md#semantic-code-search);
46
46
  both are off in `lcode bench` by default, for the same reason.
47
47
 
48
+ ### A second try, and a stronger model
49
+
50
+ `--retry` gives a failed task one more try, as a user would ask: "That didn't fix it: it still
51
+ doesn't work right. Look again." `--escalate MODEL` sets a [stronger model](usage.md#a-stronger-model-when-stuck)
52
+ that's asked when the model is stuck and, with `--retry`, before the second try, as `/escalate`
53
+ would: no questions asked, since the flag is your OK. Comparing the two shows what the stronger
54
+ model's advice adds to the same second chance:
55
+
56
+ ```bash
57
+ lcode bench --tasks runtime-bug --retry --rounds 3
58
+ lcode bench --tasks runtime-bug --retry --rounds 3 --escalate ollama/qwen3.8-27b
59
+ ```
60
+
61
+ The JSON report has, for each task, `first_passed` (with `--retry`, whether the first try passed)
62
+ and `escalations`.
63
+
48
64
  ### Long sessions
49
65
 
50
66
  Each task normally starts a new conversation. `--session` runs them all in **one** conversation
@@ -45,6 +45,9 @@ lcode config path # print the file location
45
45
  | `notify_after` | `30` | Seconds a request runs before it notifies |
46
46
  | `review` | `off` | Before the answer, [review a change](usage.md#a-review-before-the-answer) against the request and send problems back to the model: `auto` or `off` |
47
47
  | `checks` | `auto` | Run the project's [checks](usage.md#checks-before-done) after a request changed files: `auto` (found from the project), `off`, or a command |
48
+ | `escalate_model` | — | A [stronger model](usage.md#a-stronger-model-when-stuck) to ask, with your OK, when the model is stuck: `anthropic/…`, `openai/…`, `xai/…` (keys from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`), `ollama/<model>`, or `custom/<model>` with `escalate_url` |
49
+ | `escalate_url` | — | Its server, when it isn't the provider's usual one: another Ollama, or any OpenAI-compatible API |
50
+ | `escalate_price` | — | Its price in dollars per million tokens, input/output (e.g. `3/15`), for the cost shown before each use |
48
51
  | `repeat_limit` | `3` | Identical tool calls with identical results before lcode [steps in](how-it-works.md#when-the-model-repeats-itself); `0` turns it off |
49
52
  | `secret_check` | `true` | Before `git commit` and `git push`, look for passwords, keys and tokens they'd add ([details](usage.md#secrets-in-commits)) |
50
53
  | `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.34.0 — local coding agent │
15
+ │ lcode v0.35.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -83,6 +83,7 @@ reasoning is on.
83
83
  | `/cd DIR` | Change the working directory |
84
84
  | `/todos` | Show the model's task list |
85
85
  | `/status` | The task sheet: your latest requests, the plan and the model's notes, files changed, the last check |
86
+ | `/escalate` | Ask the [stronger model](#a-stronger-model-when-stuck) for advice on the work, with your OK; `/escalate <what's wrong>` says what to look at |
86
87
  | `/jobs [stop ID]` | The [background commands](#background-commands) the model started; stop one |
87
88
  | `/commit [notes]` | [Commit](git.md#commit) the changes with a message in the repository's style, once you approve it |
88
89
  | `/review [base]` | [Review](git.md#review) the uncommitted changes, or the branch against a base |
@@ -208,6 +209,51 @@ The review is asked on top of the conversation rather than in a fresh one. The m
208
209
  the processed conversation cached, and a separate review would push it out, so the next request
209
210
  would have to read the whole conversation again. On a small GPU that's tens of seconds.
210
211
 
212
+ ## A stronger model when stuck
213
+
214
+ Some problems are beyond a local model. When it's stuck, lcode can ask a stronger one for
215
+ advice: a cloud model with your API key, or a bigger local model. The local model gets the advice
216
+ and carries on with its own tools. It's off until you choose the model:
217
+
218
+ ```bash
219
+ lcode config set escalate_model anthropic/<model> # with ANTHROPIC_API_KEY in your environment
220
+ lcode config set escalate_model openai/<model> # OPENAI_API_KEY
221
+ lcode config set escalate_model xai/<model> # XAI_API_KEY
222
+ lcode config set escalate_model ollama/qwen3.8-27b # a bigger local model: free, and nothing leaves
223
+ lcode config set escalate_price 3/15 # dollars per million tokens in/out, for the cost
224
+ ```
225
+
226
+ **When.** lcode offers it when the [loop breaker](how-it-works.md#when-the-model-repeats-itself)
227
+ would stop the request, and when the project's [checks](#checks-before-done) fail twice in a row
228
+ after the model's fixes, at most once per request. You can also ask at any time: `/escalate`, or
229
+ `/escalate the window still grows` to say what's wrong.
230
+
231
+ **Nothing is sent without asking**, in any permission mode:
232
+
233
+ ```text
234
+ ╭─ Ask claude-… for help? ──────────────────────────────────────────────────╮
235
+ │ Why: the project's checks (`pytest -q`) failed 2 times in a row │
236
+ │ What goes: your requests, the task sheet, the failure, the last steps │
237
+ │ and these files: wall.py, simulate.py (3.1K tokens) │
238
+ │ Where: claude-… (Anthropic), at api.anthropic.com │
239
+ │ Cost: about $0.009 to $0.071 (3.1K tokens in at $3/M, up to 4.1K out …) │
240
+ ╰───────────────────────────────────────────────────────────────────────────╯
241
+ Send it? [y]es / [n]o / [s]how what's sent:
242
+ ```
243
+
244
+ - **What goes:** your last requests, the [task sheet](how-it-works.md#context-management), why
245
+ it's stuck (the failing check's output, the repeated call), the last few steps, and the files the
246
+ request changed or read: never the whole session or the rest of the repository. Press `s` to read
247
+ all of it before you decide.
248
+ - **Keys** come from environment variables, are read when needed, and go only to their own
249
+ provider. lcode never stores them.
250
+ - **Cost.** The token count is always shown; the price only when you set `escalate_price`, since
251
+ prices change. A local model is free.
252
+ - **Another server:** `escalate_url` points at another Ollama (`ollama/<model>`) or any OpenAI
253
+ API (`custom/<model>`, key in `LCODE_ESCALATE_API_KEY`).
254
+ - `lcode -p` can't ask, so it doesn't escalate unless you add `--escalate`, which is your OK for
255
+ that run. `--output json` lists what was asked in `escalations`.
256
+
211
257
  ## Background commands
212
258
 
213
259
  Some commands keep running: a dev server, a test watcher, a build in watch mode. The model starts
@@ -82,6 +82,7 @@ ignore = [
82
82
  "src/lcode/subagents.py" = ["E501"]
83
83
  "src/lcode/planning.py" = ["E501"]
84
84
  "src/lcode/review.py" = ["E501"] # the review prompt is prose
85
+ "src/lcode/escalate.py" = ["E501"] # the advisor prompt is prose
85
86
  "src/lcode/extensions.py" = ["E501"]
86
87
  "src/lcode/context.py" = ["E501"]
87
88
  "src/lcode/lsp.py" = ["E501"]
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.34.0"
6
+ __version__ = "0.35.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -29,6 +29,7 @@ from lcode import (
29
29
  catalog,
30
30
  checks,
31
31
  codesearch,
32
+ escalate,
32
33
  extensions,
33
34
  limits,
34
35
  notify,
@@ -307,6 +308,9 @@ class Settings:
307
308
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
308
309
  checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
309
310
  review: str = "off" # check a change against the request before the answer (lcode.review): auto or off
311
+ escalate_model: str | None = None # a stronger model to ask when stuck (lcode.escalate): provider/model
312
+ escalate_url: str | None = None # its server, when not the provider's usual one
313
+ escalate_price: str | None = None # dollars per million tokens in/out, e.g. "3/15", for the cost estimate
310
314
 
311
315
 
312
316
  class Agent:
@@ -369,6 +373,8 @@ class Agent:
369
373
  self._check: tuple[Path, checks.Check | None] | None = None
370
374
  self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
371
375
  self.review_results: list[str] | None = None # what the last request's review found (None: no review)
376
+ self.escalations: list[dict] = [] # the stronger model asked in the last request (lcode.escalate)
377
+ self.escalate_approved: bool | None = None # decided in advance (lcode -p --escalate, the benchmark)
372
378
  self.sent_chars = 0 # characters of the conversation the model had already read
373
379
  self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
374
380
  self.turn_status = "" # how the last request ended: success, max_steps, loop
@@ -952,9 +958,8 @@ class Agent:
952
958
  )
953
959
  return None
954
960
  if round_ >= checks.MAX_ROUNDS:
955
- self.say(
956
- f"Checks still fail ({check.command}, exit code {code}): the model will say what's left.", warning=True
957
- )
961
+ then = "" if self.can_escalate() else ": the model will say what's left"
962
+ self.say(f"Checks still fail ({check.command}, exit code {code}){then}.", warning=True)
958
963
  else:
959
964
  self.say(
960
965
  f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
@@ -1103,6 +1108,8 @@ class Agent:
1103
1108
  self.repeats.reset()
1104
1109
  self.check_results = []
1105
1110
  self.review_results = None
1111
+ self.escalations = []
1112
+ escalated = False # the stronger model is asked at most once per request
1106
1113
  self.tools.before = {}
1107
1114
  reviewed = False
1108
1115
  changed = verified = False # files changed in this request; the checks passed since
@@ -1166,6 +1173,15 @@ class Agent:
1166
1173
  check_rounds += 1
1167
1174
  feedback = self.run_check(check, check_rounds)
1168
1175
  if feedback:
1176
+ if check_rounds >= checks.MAX_ROUNDS and not escalated and self.can_escalate():
1177
+ escalated = True
1178
+ why = f"the project's checks (`{check.command}`) failed {check_rounds} times in a row"
1179
+ advice = escalate.run(self, why, failure=feedback)
1180
+ if advice:
1181
+ self.messages.append({"role": "user", "content": advice})
1182
+ check_rounds -= 1 # the checks run once more after the advice
1183
+ changed = False
1184
+ continue
1169
1185
  self.messages.append({"role": "user", "content": feedback})
1170
1186
  changed = False # run them again only if the model changes something more
1171
1187
  continue
@@ -1259,6 +1275,14 @@ class Agent:
1259
1275
  skipped["tool_call_id"] = rest["id"]
1260
1276
  self.messages.append(skipped)
1261
1277
  break
1278
+ if looping and not escalated and self.can_escalate():
1279
+ escalated = True
1280
+ advice = escalate.run(self, f"it kept making the same call with the same result: {looping}")
1281
+ if advice:
1282
+ self.messages.append({"role": "user", "content": advice})
1283
+ self.repeats.reset()
1284
+ looping = ""
1285
+ continue
1262
1286
  if looping:
1263
1287
  self.turn_status = "loop"
1264
1288
  self.console.print(
@@ -1276,6 +1300,11 @@ class Agent:
1276
1300
  self.turn_status = "success"
1277
1301
  self.print_stats()
1278
1302
 
1303
+ def can_escalate(self) -> bool:
1304
+ """Whether a stronger model may be asked here: one is set, and this isn't a subagent or plan mode."""
1305
+ model = (self.settings.escalate_model or "").strip().lower()
1306
+ return bool(model) and model != "off" and self.allowed_tools is None and not self.planning()
1307
+
1279
1308
  def steer(self) -> None:
1280
1309
  """Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
1281
1310
  steering = self.steering
@@ -78,6 +78,7 @@ class Result:
78
78
  usage: dict = field(default_factory=dict) # requests, prompt_tokens, output_tokens
79
79
  checks: list[dict] = field(default_factory=list) # {"command", "exit_code", "by": "lcode" | "model"}
80
80
  review: list[str] | None = None # problems the review sent back ([] when it found none; None: no review)
81
+ escalations: list[dict] = field(default_factory=list) # a stronger model asked: model, why, tokens, seconds
81
82
  seconds: float = 0.0
82
83
  error: str = ""
83
84
 
@@ -200,6 +201,9 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
200
201
  repeat_limit=cfg["repeat_limit"],
201
202
  checks=cfg["checks"],
202
203
  review=cfg["review"],
204
+ escalate_model=cfg["escalate_model"],
205
+ escalate_url=cfg["escalate_url"],
206
+ escalate_price=cfg["escalate_price"],
203
207
  compact_at=cfg["compact_at"],
204
208
  )
205
209
  agent = Agent(ollama, settings, cwd, console=console)
@@ -309,6 +313,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
309
313
  usage=usage,
310
314
  checks=list(agent.check_results),
311
315
  review=agent.review_results,
316
+ escalations=list(agent.escalations),
312
317
  seconds=round(time.monotonic() - started, 2),
313
318
  error=error,
314
319
  )
@@ -265,11 +265,16 @@ class OpenAICompatible:
265
265
  return self.chat({k: v for k, v in payload.items() if k not in ("format", "tools")}, timeout)
266
266
  if r.status_code != 200:
267
267
  raise self._failed(r.status_code, r.text)
268
- choice = (r.json().get("choices") or [{}])[0].get("message") or {}
268
+ data = r.json()
269
+ choice = (data.get("choices") or [{}])[0].get("message") or {}
269
270
  message: dict = {"role": "assistant", "content": strip_thinking(choice.get("content") or "")}
270
271
  if choice.get("tool_calls"):
271
272
  message["tool_calls"] = [self._ollama_call(c) for c in choice["tool_calls"]]
272
- return {"message": message}
273
+ reply: dict = {"message": message}
274
+ usage = data.get("usage") or {}
275
+ if usage.get("prompt_tokens") is not None: # as Ollama counts them
276
+ reply["prompt_eval_count"], reply["eval_count"] = usage["prompt_tokens"], usage.get("completion_tokens")
277
+ return reply
273
278
 
274
279
  @staticmethod
275
280
  def _ollama_call(call: dict) -> dict:
@@ -29,7 +29,7 @@ from rich.markup import escape
29
29
  from rich.table import Table
30
30
  from rich.text import Text
31
31
 
32
- from lcode import __version__
32
+ from lcode import __version__, escalate
33
33
  from lcode.agent import Agent, Settings
34
34
  from lcode.config import format_tokens
35
35
  from lcode.hardware import Hardware
@@ -790,6 +790,8 @@ class TaskResult:
790
790
  tool_errors: int = 0 # failed tool calls, including malformed ones
791
791
  error: str | None = None # why the run stopped early: a timeout or an Ollama error
792
792
  errors_by_tool: dict[str, int] = field(default_factory=dict) # which tools failed, "malformed" for unreadable calls
793
+ first_passed: bool | None = None # with --retry: whether the first try passed (None: no retry was needed)
794
+ escalations: int = 0 # times the stronger model was asked (--escalate)
793
795
 
794
796
 
795
797
  @dataclass
@@ -887,8 +889,13 @@ def run_task(
887
889
  keep: bool,
888
890
  usage: dict,
889
891
  agent: Agent | None = None,
892
+ retry: bool = False,
890
893
  ) -> TaskResult:
891
- """Run one task in a new folder: in a new conversation, or continuing `agent`'s (--session)."""
894
+ """Run one task in a new folder: in a new conversation, or continuing `agent`'s (--session).
895
+
896
+ With `retry`, a failed task gets one more try, as a user would ask: "That didn't fix it. Look again." With a
897
+ stronger model set (--escalate), its advice goes along, as with /escalate.
898
+ """
892
899
  folder = Path(tempfile.mkdtemp(prefix=f"lcode-bench-{task.id}-"))
893
900
  try:
894
901
  for rel, content in task.files.items():
@@ -906,25 +913,28 @@ def run_task(
906
913
  if model:
907
914
  codesearch.Index(folder, model).update(ollama) # on the CPU: lcode's model stays loaded
908
915
  agent._code_index = None
916
+ agent.escalate_approved = True if settings.escalate_model else None # the --escalate flag is the OK
909
917
  before = dict(agent.usage)
910
918
  first = len(agent.messages)
911
- error = None
912
919
  started = time.monotonic()
913
- try:
914
- with time_limit(timeout):
915
- agent.run_turn(task.prompt)
916
- except TaskTimeout:
917
- error = f"timed out after {timeout:.0f}s"
918
- except OllamaError as e:
919
- error = str(e).splitlines()[0][:200]
920
+ escalations = 0
921
+ error = attempt(agent, task.prompt, timeout)
922
+ escalations += len(agent.escalations)
923
+ check = checked(task, folder, agent)
924
+ first_passed = None
925
+ if retry and not (check.passed and error is None):
926
+ first_passed = False
927
+ text = RETRY
928
+ if error is None and agent.can_escalate():
929
+ advice = escalate.run(agent, "the user says the fix didn't work")
930
+ escalations += 1 if advice else 0
931
+ text = f"{RETRY}\n\n{advice}" if advice else RETRY
932
+ error = attempt(agent, text, timeout)
933
+ escalations += len(agent.escalations)
934
+ check = checked(task, folder, agent)
920
935
  seconds = time.monotonic() - started
921
936
  for key, value in agent.usage.items():
922
937
  usage[key] = usage.get(key, 0) + value - before.get(key, 0)
923
- answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
924
- try:
925
- check = task.check(folder, answer)
926
- except Exception as e: # a check must never crash the benchmark
927
- check = Check(False, f"check failed: {type(e).__name__}: {e}")
928
938
  finally: # also when Ctrl+C stops the task
929
939
  from lcode.codesearch import index_dir
930
940
 
@@ -942,9 +952,34 @@ def run_task(
942
952
  tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
943
953
  errors_by_tool=errors_by_tool(agent.messages[first:] if len(agent.messages) > first else agent.messages),
944
954
  error=error,
955
+ first_passed=first_passed,
956
+ escalations=escalations,
945
957
  )
946
958
 
947
959
 
960
+ RETRY = "That didn't fix it: it still doesn't work right. Look again."
961
+
962
+
963
+ def attempt(agent: Agent, text: str, timeout: float) -> str | None:
964
+ """One request of a task; why it stopped early, if it did."""
965
+ try:
966
+ with time_limit(timeout):
967
+ agent.run_turn(text)
968
+ except TaskTimeout:
969
+ return f"timed out after {timeout:.0f}s"
970
+ except OllamaError as e:
971
+ return str(e).splitlines()[0][:200]
972
+ return None
973
+
974
+
975
+ def checked(task: Task, folder: Path, agent: Agent) -> Check:
976
+ answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
977
+ try:
978
+ return task.check(folder, answer)
979
+ except Exception as e: # a check must never crash the benchmark
980
+ return Check(False, f"check failed: {type(e).__name__}: {e}")
981
+
982
+
948
983
  def prompt_speed(ollama: Ollama, settings: Settings, options: dict) -> float | None:
949
984
  """Prompt tokens per second for prompts the model hasn't seen: the faster of two readings.
950
985
 
@@ -998,6 +1033,7 @@ def run_model(
998
1033
  keep: bool = False,
999
1034
  verbose: bool = False,
1000
1035
  session: bool = False,
1036
+ retry: bool = False,
1001
1037
  ) -> ModelRun:
1002
1038
  run = ModelRun(name, settings.model, settings.context, settings.num_batch, settings.think, session=session)
1003
1039
  shared: Agent | None = None # with --session, one conversation for all tasks
@@ -1038,10 +1074,10 @@ def run_model(
1038
1074
  try:
1039
1075
  if verbose:
1040
1076
  console.rule(f"{task.id}: {task.title}")
1041
- result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
1077
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared, retry)
1042
1078
  else:
1043
1079
  with console.status(Elapsed(f" {task.id:<13} {task.title}")):
1044
- result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
1080
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared, retry)
1045
1081
  except KeyboardInterrupt:
1046
1082
  console.print(" [yellow]Stopped.[/]")
1047
1083
  run.interrupted = True
@@ -582,11 +582,14 @@ def cmd_bench(args) -> None:
582
582
  compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
583
583
  checks=cfg["checks"] if args.checks is None else args.checks,
584
584
  review=cfg["review"] if args.review is None else args.review,
585
+ escalate_model=args.escalate_model,
586
+ escalate_url=cfg["escalate_url"],
587
+ escalate_price=cfg["escalate_price"],
585
588
  repo_map=args.repo_map,
586
589
  embed_model=cfg["embed_model"] if args.code_search else "off",
587
590
  )
588
591
  run = bench.run_model(
589
- ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose, args.session
592
+ ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose, args.session, args.retry
590
593
  )
591
594
  runs.append(run)
592
595
  if run.interrupted:
@@ -662,6 +665,8 @@ def cmd_chat(args) -> None:
662
665
  sys.exit(1)
663
666
  fail(str(e))
664
667
  exit_code = 0
668
+ if args.escalate and args.prompt:
669
+ agent.escalate_approved = True # the flag is the user's OK for this run
665
670
  try:
666
671
  if quiet:
667
672
  agent.perms.approve = lambda request: False # nobody to ask: refuse what needs permission
@@ -727,6 +732,11 @@ def build_parser() -> argparse.ArgumentParser:
727
732
  parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
728
733
  parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
729
734
  parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
735
+ parser.add_argument(
736
+ "--escalate",
737
+ action="store_true",
738
+ help="with -p: let lcode ask escalate_model for help when stuck, without asking you (it costs money)",
739
+ )
730
740
  parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
731
741
  parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
732
742
  parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
@@ -779,6 +789,17 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
779
789
  p.add_argument(
780
790
  "--review", choices=["auto", "off"], help="review each change before it counts (default: the setting)"
781
791
  )
792
+ p.add_argument(
793
+ "--retry",
794
+ action="store_true",
795
+ help='give a failed task one more try, as a user would: "That didn\'t fix it. Look again."',
796
+ )
797
+ p.add_argument(
798
+ "--escalate",
799
+ dest="escalate_model",
800
+ metavar="MODEL",
801
+ help="a stronger model to ask when stuck and on --retry, e.g. ollama/qwen3.8-27b (no questions asked)",
802
+ )
782
803
  p.add_argument(
783
804
  "--checks",
784
805
  metavar="auto|off|COMMAND",
@@ -61,6 +61,14 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
61
61
  "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
62
62
  "review": ("off", str, "before the answer, check a change against the request and send problems back: auto | off"),
63
63
  "checks": ("auto", str, "run the project's checks before calling work done: auto | off | a command"),
64
+ "escalate_model": (
65
+ None,
66
+ str,
67
+ "a stronger model to ask, with your OK, when the model is stuck: anthropic/…, openai/…, xai/… (key from "
68
+ "ANTHROPIC_API_KEY, OPENAI_API_KEY, XAI_API_KEY) or ollama/<model> (local or at escalate_url)",
69
+ ),
70
+ "escalate_url": (None, str, "the stronger model's server, if not its provider's (another Ollama, any OpenAI API)"),
71
+ "escalate_price": (None, str, "its price in dollars per million tokens in/out, e.g. 3/15, for the cost shown"),
64
72
  "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
65
73
  "secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
66
74
  "compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),
@@ -165,6 +173,22 @@ def coerce(key: str, value: object) -> object:
165
173
  if value not in MEMORY_MODES:
166
174
  raise ConfigError(f"memory must be one of {', '.join(MEMORY_MODES)}")
167
175
  return value
176
+ if key in ("escalate_model", "escalate_url", "escalate_price"):
177
+ from lcode import escalate
178
+
179
+ text = str(value).strip()
180
+ if key == "escalate_url":
181
+ return normalize_host(text)
182
+ if key == "escalate_model":
183
+ provider = text.partition("/")[0].lower()
184
+ if "/" in text and provider not in escalate.PROVIDERS:
185
+ raise ConfigError(f"escalate_model's provider must be one of {', '.join(escalate.PROVIDERS)}")
186
+ return text
187
+ try:
188
+ escalate.parse_price(text)
189
+ except escalate.EscalateError as e:
190
+ raise ConfigError(str(e)) from e
191
+ return text
168
192
  if key == "lsp" and str(value).lower() not in ("auto", "off"):
169
193
  raise ConfigError("lsp must be auto or off")
170
194
  if key == "lsp":
@@ -41,6 +41,8 @@ ERROR_LINE = re.compile(
41
41
  )
42
42
  MAX_ERROR_LINES = 40
43
43
  OUTPUT_DAYS = 7 # saved full outputs are deleted after this
44
+ MAX_SAVED = 20_000_000 # characters of one saved output: a runaway command doesn't fill the disk
45
+ MAX_SAVED_TOTAL = 200_000_000 # bytes of saved outputs in all, the oldest go first
44
46
 
45
47
 
46
48
  # ----------------------------------------------------------------------------- the user's requests
@@ -262,17 +264,38 @@ def save_output(text: str, label: str) -> Path | None:
262
264
  folder = outputs_dir()
263
265
  try:
264
266
  folder.mkdir(parents=True, exist_ok=True)
265
- cutoff = time.time() - OUTPUT_DAYS * 86400
266
- for old in folder.glob("*.txt"):
267
- if old.stat().st_mtime < cutoff:
268
- old.unlink(missing_ok=True)
267
+ clean_outputs(folder, room=min(len(text), MAX_SAVED))
269
268
  path = folder / f"{time.strftime('%Y%m%d-%H%M%S')}-{re.sub(r'[^a-z0-9]+', '-', label.lower())[:30]}.txt"
270
- path.write_text(text)
269
+ path.write_text(text if len(text) <= MAX_SAVED else text[:MAX_SAVED] + CUT_SAVED)
271
270
  return path
272
271
  except OSError:
273
272
  return None
274
273
 
275
274
 
275
+ CUT_SAVED = "\n[lcode stopped saving here: the output went on, but a saved output is kept to 20 MB]\n"
276
+
277
+
278
+ def clean_outputs(folder: Path, room: int = 0) -> None:
279
+ """Delete saved outputs older than OUTPUT_DAYS, then the oldest until they take MAX_SAVED_TOTAL with `room`."""
280
+ cutoff = time.time() - OUTPUT_DAYS * 86400
281
+ files = []
282
+ for old in folder.glob("*.txt"):
283
+ try:
284
+ info = old.stat()
285
+ except OSError:
286
+ continue
287
+ if info.st_mtime < cutoff:
288
+ old.unlink(missing_ok=True)
289
+ else:
290
+ files.append((info.st_mtime, info.st_size, old))
291
+ total = sum(size for _, size, _ in files) + room
292
+ for _, size, old in sorted(files):
293
+ if total <= MAX_SAVED_TOTAL:
294
+ break
295
+ old.unlink(missing_ok=True)
296
+ total -= size
297
+
298
+
276
299
  def shorten(text: str, limit: int, label: str = "output") -> str:
277
300
  """Head, tail and the error-looking lines in between; the full text goes to a file."""
278
301
  if len(text) <= limit:
@@ -315,6 +338,7 @@ class OutputSpool:
315
338
  self.chars = 0
316
339
  self.file = None
317
340
  self.path: Path | None = None
341
+ self.saved = 0 # characters written to the file
318
342
 
319
343
  def add(self, line: str) -> None:
320
344
  self.chars += len(line)
@@ -323,10 +347,12 @@ class OutputSpool:
323
347
  if self.chars > self.limit * 2:
324
348
  self._spool()
325
349
  return
326
- try:
327
- self.file.write(line)
328
- except OSError:
329
- pass
350
+ if self.saved <= MAX_SAVED: # past it, only the end and the error lines are kept (in memory)
351
+ try:
352
+ self.file.write(line if self.saved + len(line) <= MAX_SAVED else CUT_SAVED)
353
+ except OSError:
354
+ pass
355
+ self.saved += len(line)
330
356
  if ERROR_LINE.search(line) and len(self.errors) < MAX_ERROR_LINES:
331
357
  self.errors.append(line.strip()[:300])
332
358
  self.tail.append(line)
@@ -337,6 +363,7 @@ class OutputSpool:
337
363
  def _spool(self) -> None:
338
364
  text = "".join(self.head)
339
365
  self.path = save_output(text, self.label)
366
+ self.saved = len(text)
340
367
  if self.path is not None:
341
368
  try:
342
369
  self.file = self.path.open("a")
@@ -359,12 +386,10 @@ class OutputSpool:
359
386
  note += "\n[...]\n\n"
360
387
  else:
361
388
  note += "] ...\n\n"
362
- where = (
363
- f"\n[The full {self.label} ({self.chars} characters) is in {self.path}: read parts of it with read_file "
364
- "if you need them.]"
365
- if self.path
366
- else ""
367
- )
389
+ whole = f"The full {self.label} ({self.chars} characters)"
390
+ if self.saved > MAX_SAVED:
391
+ whole = f"The first {MAX_SAVED // 1_000_000} MB of the {self.label} ({self.chars} characters in all)"
392
+ where = f"\n[{whole} is in {self.path}: read parts of it with read_file if you need them.]" if self.path else ""
368
393
  return f"{head}{note}{tail}{where}"
369
394
 
370
395