lcode-cli 0.27.0__tar.gz → 0.28.1__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 (162) hide show
  1. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/CHANGELOG.md +60 -1
  2. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/PKG-INFO +1 -1
  3. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/quickstart.md +1 -1
  4. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/usage.md +33 -3
  5. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/__init__.py +1 -1
  6. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/agent.py +153 -18
  7. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/api.py +2 -0
  8. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/context.py +68 -0
  9. lcode_cli-0.28.1/src/lcode/permissions.py +260 -0
  10. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/repl.py +25 -4
  11. lcode_cli-0.28.1/src/lcode/residency.py +167 -0
  12. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/tools.py +162 -36
  13. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/conftest.py +6 -0
  14. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_agent.py +4 -3
  15. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_mcp.py +2 -1
  16. lcode_cli-0.28.1/tests/test_residency.py +164 -0
  17. lcode_cli-0.28.1/tests/test_robustness.py +221 -0
  18. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_vision.py +5 -4
  19. lcode_cli-0.27.0/src/lcode/permissions.py +0 -138
  20. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.editorconfig +0 -0
  21. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/CODEOWNERS +0 -0
  22. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  23. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  24. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  25. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  26. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/dependabot.yml +0 -0
  28. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/workflows/ci.yml +0 -0
  29. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/workflows/docs.yml +0 -0
  30. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/workflows/release.yml +0 -0
  31. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.gitignore +0 -0
  32. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.pre-commit-config.yaml +0 -0
  33. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/AGENTS.md +0 -0
  34. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/CODE_OF_CONDUCT.md +0 -0
  35. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/CONTRIBUTING.md +0 -0
  36. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/LICENSE +0 -0
  37. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/README.md +0 -0
  38. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/SECURITY.md +0 -0
  39. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/action.yml +0 -0
  40. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/agents.md +0 -0
  41. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/assets/demo.svg +0 -0
  42. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/assets/extra.css +0 -0
  43. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/assets/logo.svg +0 -0
  44. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/assets/social-preview.png +0 -0
  45. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/automation.md +0 -0
  46. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/bench.md +0 -0
  47. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/changelog.md +0 -0
  48. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/commands.md +0 -0
  49. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/configuration.md +0 -0
  50. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/contributing.md +0 -0
  51. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/editors.md +0 -0
  52. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/git.md +0 -0
  53. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/hooks.md +0 -0
  54. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/how-it-works.md +0 -0
  55. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/index.md +0 -0
  56. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/installation.md +0 -0
  57. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/lsp.md +0 -0
  58. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/mcp.md +0 -0
  59. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/memory.md +0 -0
  60. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/models.md +0 -0
  61. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/sandbox.md +0 -0
  62. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/search.md +0 -0
  63. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/servers.md +0 -0
  64. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/troubleshooting.md +0 -0
  65. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/examples/README.md +0 -0
  66. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/examples/api/fix_tests.py +0 -0
  67. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/examples/commands/review.md +0 -0
  68. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/examples/skills/commit-message/SKILL.md +0 -0
  69. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/examples/skills/commit-message/scripts/staged.sh +0 -0
  70. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/install.sh +0 -0
  71. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/mkdocs.yml +0 -0
  72. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/overrides/main.html +0 -0
  73. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/pyproject.toml +0 -0
  74. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/scripts/record_demo.py +0 -0
  75. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/scripts/social-preview.html +0 -0
  76. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/__main__.py +0 -0
  77. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/acp.py +0 -0
  78. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/action.py +0 -0
  79. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/backends.py +0 -0
  80. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/bench.py +0 -0
  81. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/bench_backend.py +0 -0
  82. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/bench_runtime.py +0 -0
  83. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/catalog.py +0 -0
  84. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/checkpoints.py +0 -0
  85. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/cli.py +0 -0
  86. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/clipboard.py +0 -0
  87. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/codesearch.py +0 -0
  88. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/config.py +0 -0
  89. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/extensions.py +0 -0
  90. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/frontmatter.py +0 -0
  91. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/gitflow.py +0 -0
  92. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/hardware.py +0 -0
  93. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/hooks.py +0 -0
  94. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/jobs.py +0 -0
  95. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/limits.py +0 -0
  96. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/lsp.py +0 -0
  97. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/__init__.py +0 -0
  98. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/auth.py +0 -0
  99. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/catalog.py +0 -0
  100. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/catalog.toml +0 -0
  101. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/client.py +0 -0
  102. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/commands.py +0 -0
  103. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/config.py +0 -0
  104. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/http.py +0 -0
  105. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/manager.py +0 -0
  106. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/protocol.py +0 -0
  107. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/server.py +0 -0
  108. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/stdio.py +0 -0
  109. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/memory.py +0 -0
  110. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/models.toml +0 -0
  111. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/notify.py +0 -0
  112. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/ollama.py +0 -0
  113. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/planning.py +0 -0
  114. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/render.py +0 -0
  115. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/repeats.py +0 -0
  116. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/repomap.py +0 -0
  117. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/sandbox.py +0 -0
  118. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/secretscan.py +0 -0
  119. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/servers/__init__.py +0 -0
  120. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/servers/encord.py +0 -0
  121. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/servers/valohai.py +0 -0
  122. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/servers/xai.py +0 -0
  123. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/sessions.py +0 -0
  124. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/subagents.py +0 -0
  125. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/update.py +0 -0
  126. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/vision.py +0 -0
  127. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/web.py +0 -0
  128. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/fake_lsp_server.py +0 -0
  129. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/fake_mcp_server.py +0 -0
  130. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_acp.py +0 -0
  131. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_action.py +0 -0
  132. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_api.py +0 -0
  133. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_backends.py +0 -0
  134. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_bench.py +0 -0
  135. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_catalog.py +0 -0
  136. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_checkpoints.py +0 -0
  137. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_cli.py +0 -0
  138. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_clipboard.py +0 -0
  139. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_codesearch.py +0 -0
  140. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_config.py +0 -0
  141. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_context.py +0 -0
  142. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_context_picker.py +0 -0
  143. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_extensions.py +0 -0
  144. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_gitflow.py +0 -0
  145. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_hooks.py +0 -0
  146. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_jobs.py +0 -0
  147. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_lsp.py +0 -0
  148. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_memory.py +0 -0
  149. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_permissions.py +0 -0
  150. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_plan_mode.py +0 -0
  151. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_repeats.py +0 -0
  152. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_repomap.py +0 -0
  153. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_sandbox.py +0 -0
  154. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_secretscan.py +0 -0
  155. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_servers.py +0 -0
  156. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_sessions.py +0 -0
  157. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_subagents.py +0 -0
  158. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_tools.py +0 -0
  159. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_update.py +0 -0
  160. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_web.py +0 -0
  161. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_xai.py +0 -0
  162. {lcode_cli-0.27.0 → lcode_cli-0.28.1}/uv.lock +0 -0
@@ -6,6 +6,63 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.28.1] - 2026-10-13
10
+
11
+ ### Fixed
12
+
13
+ Found by testing lcode's tools on real files and commands (#118):
14
+
15
+ - Some commands that change things ran without asking, because they were taken for read-only:
16
+ `sort -o`, `uniq in out`, `tree -o`, `git branch -D` and other branch changes, `git remote
17
+ add/remove`, `git log/diff --output`, `rg --pre` (which runs a program), `find -fls`, `date -s`,
18
+ and `nvidia-smi` settings. Arguments are now checked. `2>&1` and `>/dev/null` no longer make a
19
+ read-only command ask.
20
+ - "Always allow" for a command also covered chains that started with it: allowing `npm test` let
21
+ `npm test && rm -rf build` run without asking. Now a chain needs its own "always".
22
+ - A JSON tool call that the model only showed as an example in its answer was run. Text is now
23
+ taken as a call only when the whole message is one.
24
+ - A change you made to a file while lcode asked to edit it was overwritten. lcode now notices and
25
+ doesn't apply the edit, and the model redoes it on the current content.
26
+ - `replace_all: "false"`, and other values sent as text, were taken as true. They now get the
27
+ type the tool declares.
28
+ - The shell timeout wasn't enforced while a command kept printing, so a watcher started without
29
+ `background` hung the request. Long output is no longer held in memory either: 400 MB of output
30
+ used to cost 2.5 GB, and now it goes to a file, keeping its start, end and error lines.
31
+ - One tool result could overflow the context window: `read_file` returned up to ~40K tokens,
32
+ however small the window, and compaction only looked at the previous request. A result now takes
33
+ at most a quarter of the window (`read_file` says where to read on), and lcode makes room based on
34
+ the size of the next request.
35
+ - Sessions were saved only when a request ended, so a crash lost the whole request. They're now
36
+ saved after every step, and atomically, so a crash while writing can't corrupt them.
37
+ - Editing a file with Windows line endings turned the whole file to Unix line endings, and editing a
38
+ file that isn't UTF-8 (Windows-1252 or Latin-1) replaced every accented letter in it with `�`,
39
+ not only in the edited line. Files now keep their encoding, line endings and byte order mark. A
40
+ character the encoding can't hold is refused instead of written wrong.
41
+
42
+ ## [0.28.0] - 2026-10-13
43
+
44
+ ### Added
45
+
46
+ - lcode says what's happening under the hood, in a line, only when it happens:
47
+ - loading the model and then reading the conversation are named in the status line instead of
48
+ "Thinking";
49
+ - after a load, how long it took and how much of the model is on the GPU, with a warning when less
50
+ is than usual;
51
+ - at startup and on `/model`, a warning when a dense model won't fit on the GPU and will be slow,
52
+ with what to use instead;
53
+ - the vision model making the main model step aside, and the reload after it;
54
+ - an image tool taking the GPU;
55
+ - clearer explanations when Ollama runs out of memory.
56
+
57
+ The status bar shows when the model is still loading at startup. See
58
+ [What's happening under the hood](https://nasser1941.github.io/lcode/usage/#whats-happening-under-the-hood).
59
+
60
+ ### Fixed
61
+
62
+ - After `/model`, images were still described by the vision model chosen for the previous model.
63
+ - The tests no longer write into your lcode state folder; one of them had left about a hundred
64
+ entries in `extensions-approved.json`.
65
+
9
66
  ## [0.27.0] - 2026-10-10
10
67
 
11
68
  ### Changed
@@ -575,7 +632,9 @@ First public release.
575
632
  - `AGENTS.md` project instructions and `/init` to generate them.
576
633
  - One-line installer for Linux and macOS.
577
634
 
578
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.27.0...HEAD
635
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.1...HEAD
636
+ [0.28.1]: https://github.com/nasser1941/lcode/compare/v0.28.0...v0.28.1
637
+ [0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
579
638
  [0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
580
639
  [0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
581
640
  [0.25.0]: https://github.com/nasser1941/lcode/compare/v0.24.0...v0.25.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.27.0
3
+ Version: 0.28.1
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/
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.27.0 — local coding agent │
15
+ │ lcode v0.28.1 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -133,6 +133,33 @@ since last time with `bash_output`, and stops it with `bash_stop`.
133
133
  - Background commands ask for permission like any other command, and run in the
134
134
  [sandbox](sandbox.md) when it's on.
135
135
 
136
+ ## What's happening under the hood
137
+
138
+ Much of the waiting with a local model isn't the model thinking: it's loading the model, or reading
139
+ a long conversation before the first word. lcode says which, in the status line under a request:
140
+
141
+ ```text
142
+ ⠋ Loading qwen3.6-35b into memory… (0 tokens, 9s · Ctrl+C to stop)
143
+ ⠋ Reading the conversation (18K tokens)… (0 tokens, 21s · Ctrl+C to stop)
144
+ ⠋ Thinking… (312 tokens, 27s · Ctrl+C to stop)
145
+ ```
146
+
147
+ It prints a line only when something worth knowing happens, and nothing while the model simply
148
+ stays loaded:
149
+
150
+ | When | What it says |
151
+ |---|---|
152
+ | The model was loaded | `◆ Loaded qwen3.6-35b in 15s: 22.3 GB, 45% on the GPU and the rest in system RAM.`, and a warning when less of it is on the GPU than usual, because another program or model is using GPU memory |
153
+ | A dense model doesn't fit on the GPU (at startup and on `/model`) | `⚠ qwen3.8-27b is a dense model: all 27B parameters work on every token. It's capable, but slow here. Only about 61% of it fits in the 12 GB of GPU memory…`, with what to do instead |
154
+ | The model that looks at images needs the memory | `Loading the vision model … to look at bug.png; qwen3.6-35b makes room and reloads afterwards`, then `◆ qwen3.6-35b was unloaded to make room…`, and the next step says `Reloading qwen3.6-35b into memory (it made room for the vision model …)` |
155
+ | A tool needs the GPU to itself, such as image generation | `◆ Unloaded qwen3.6-35b so comfyui can use the GPU; it reloads afterwards.` |
156
+ | Ollama ran out of memory | `⚠` what ran out (the prompt batch, or the context next to the model), what lcode retries with, and what it remembers for next time |
157
+
158
+ While the model loads in the background at startup, the status bar says
159
+ `loading the model into memory…`. Mixture-of-Experts models, such as qwen3.6-35b, run well with
160
+ part of them in system RAM. Dense ones slow down a lot, to a few tokens a second, which is why they
161
+ get the warning.
162
+
136
163
  ## Notifications
137
164
 
138
165
  Local models take a while, so you'll often switch to something else during a request. lcode shows
@@ -197,11 +224,14 @@ image support off with `lcode config set vision_model off`.
197
224
  `--yolo` start in one.
198
225
 
199
226
  Read-only commands run without asking: `ls`, `cat`, `head`, `tail`, `grep`, `rg`, `find`, `tree`,
200
- `wc`, `diff`, `git status/log/diff/show/branch/blame` and similar, as long as they don't redirect
201
- output, chain commands or run subshells. A `cd <folder> &&` in front is fine.
227
+ `wc`, `diff`, `git status/log/diff/show/branch/blame` and similar. Their arguments count too:
228
+ `sort -o file`, `git branch -D`, `git log --output=file`, `rg --pre` or `find -delete` change
229
+ things, so they ask. Pipes between read-only commands are fine, and so are `cd <folder> &&` in front
230
+ and `2>&1` or `>/dev/null`; anything else (`&&`, `;`, redirecting to a file, `$(…)`) asks.
202
231
 
203
232
  When asked, answer ++y++ (once), ++a++ (always, for this command or for all edits, until you quit)
204
- or ++n++. For lasting answers, write [permission rules](hooks.md#permission-rules): allow lists and
233
+ or ++n++. "Always" covers the same program, or for a chain the same programs in the same order:
234
+ allowing `npm test` doesn't allow `npm test && rm -rf build`. For lasting answers, write [permission rules](hooks.md#permission-rules): allow lists and
205
235
  deny lists, per user or per repository; deny always wins, even in `yolo`. Text after ++n++ goes to the model as instructions: `n run the tests with -x first`.
206
236
 
207
237
  !!! warning "yolo mode"
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.27.0"
6
+ __version__ = "0.28.1"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import base64
6
+ import contextlib
6
7
  import datetime as dt
7
8
  import json
9
+ import os
8
10
  import platform
9
11
  import re
10
12
  import shutil
@@ -23,7 +25,20 @@ from rich.markup import escape
23
25
  from rich.panel import Panel
24
26
  from rich.text import Text
25
27
 
26
- from lcode import catalog, codesearch, extensions, limits, notify, planning, repomap, sessions, subagents, vision, web
28
+ from lcode import (
29
+ catalog,
30
+ codesearch,
31
+ extensions,
32
+ limits,
33
+ notify,
34
+ planning,
35
+ repomap,
36
+ residency,
37
+ sessions,
38
+ subagents,
39
+ vision,
40
+ web,
41
+ )
27
42
  from lcode import context as context_tools
28
43
  from lcode import memory as memory_notes
29
44
  from lcode.checkpoints import Checkpoints
@@ -53,7 +68,12 @@ from lcode.tools import (
53
68
  truncate,
54
69
  )
55
70
 
56
- GPU_MEMORY_ERRORS = ("out of memory", "illegal memory access", "cudamalloc failed")
71
+ GPU_MEMORY_ERRORS = (
72
+ "out of memory",
73
+ "illegal memory access",
74
+ "cudamalloc failed",
75
+ "requires more system memory", # Ollama: the model and its context don't fit in RAM either
76
+ )
57
77
  SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
58
78
  MAX_STEPS_PER_TURN = 150
59
79
  EVENT_OUTPUT = 4000 # characters of a tool's output in an event
@@ -62,6 +82,9 @@ REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's pe
62
82
  MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
63
83
  # How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
64
84
  MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
85
+ RESULT_SHARE = 0.25 # of the context window, at most, for one tool result
86
+ MIN_RESULT_CHARS = 8000
87
+ READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
65
88
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
66
89
  PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
67
90
  # @path, @"path with spaces" or @'path'
@@ -171,6 +194,10 @@ def project_root_or_cwd(cwd: Path) -> Path:
171
194
  return work_tree_for(cwd)
172
195
 
173
196
 
197
+ def conversation_chars(messages: list[dict]) -> int:
198
+ return sum(len(str(m.get("content") or "")) + len(str(m.get("thinking") or "")) for m in messages)
199
+
200
+
174
201
  def call_arguments(call: dict) -> dict:
175
202
  """A tool call's arguments as a dict (some models send them as a JSON string)."""
176
203
  args = call.get("function", {}).get("arguments") or {}
@@ -189,17 +216,22 @@ class Progress:
189
216
  arrives, for example while the model writes a long file into a tool call.
190
217
  """
191
218
 
192
- def __init__(self) -> None:
219
+ def __init__(self, phase: str = "Thinking") -> None:
193
220
  self.started = self.last_chunk = time.time()
194
- self.phase = "Thinking"
221
+ self.phase = phase
195
222
  self.chars = 0
196
223
 
197
224
  def update(self, phase: str, text: str) -> None:
198
225
  self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
199
226
 
227
+ def set_phase(self, phase: str) -> None:
228
+ """What happens before the first token: loading the model, reading the prompt."""
229
+ self.phase, self.last_chunk = phase, time.time()
230
+
200
231
  def __rich__(self) -> Text:
201
232
  now = time.time()
202
- phase = self.phase if now - self.last_chunk < 3 else "Working"
233
+ streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
234
+ phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
203
235
  return Text.from_markup(
204
236
  f"[cyan]{phase}…[/] [dim]({self.chars // 4} tokens, {now - self.started:.0f}s · Ctrl+C to stop)[/]"
205
237
  )
@@ -288,6 +320,12 @@ class Agent:
288
320
  # Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
289
321
  self.plan_review: Callable[[str, str], str] | None = None
290
322
  self.max_steps = MAX_STEPS_PER_TURN
323
+ self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
324
+ self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
325
+ self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
326
+ self.autosave = False # save the session after every step (sessions people resume; not the bench's)
327
+ self.sent_chars = 0 # characters of the conversation the model had already read
328
+ self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
291
329
  self.turn_status = "" # how the last request ended: success, max_steps, loop
292
330
  self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
293
331
  self.response = None # the streaming response, so another thread can abort it
@@ -465,12 +503,33 @@ class Agent:
465
503
  image = vision.read_image(path)
466
504
  # The session's own model keeps its settings, so Ollama doesn't reload it.
467
505
  options = self.options() if model == self.settings.model else None
468
- loading = "" if model == self.settings.model else " (loads it; the next request reloads the main model)"
469
- with self.console.status(f"Looking at {path.name} with {model}{loading}…", spinner="dots"):
506
+ with self.vision_swap(model, path.name):
470
507
  return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
471
508
  except vision.VisionError as e:
472
509
  raise ToolError(str(e)) from e
473
510
 
511
+ @contextlib.contextmanager
512
+ def vision_swap(self, model: str, what: str):
513
+ """Look at an image with `model`, saying when it has to load and when the main model made room for it."""
514
+ main = self.settings.model
515
+ own = model == main
516
+ main_was_loaded = not own and residency.placement(self.ollama, main) is not None
517
+ if own or residency.placement(self.ollama, model) is not None:
518
+ text = f"Looking at {what} with {model}…"
519
+ else:
520
+ text = f"Loading the vision model {model} to look at {what}" + (
521
+ f"; {main} makes room and reloads afterwards…" if main_was_loaded else "…"
522
+ )
523
+ with self.console.status(text, spinner="dots"):
524
+ yield
525
+ if main_was_loaded and residency.placement(self.ollama, main) is None:
526
+ self.made_room_for = f"the vision model {model}"
527
+ self.say(f"{main} was unloaded to make room for {model}; it reloads for the next step.")
528
+
529
+ def say(self, text: str, warning: bool = False) -> None:
530
+ """One line about what happens under the hood (loading, memory), not about the work itself."""
531
+ self.console.print(Text(f"{'⚠' if warning else '◆'} {text}", style="yellow" if warning else "dim cyan"))
532
+
474
533
  def describe_image_data(self, data: str, mime: str) -> str:
475
534
  """For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
476
535
  model = self.vision_model()
@@ -478,7 +537,7 @@ class Agent:
478
537
  return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
479
538
  options = self.options() if model == self.settings.model else None
480
539
  try:
481
- with self.console.status(f"Looking at the {mime} image with {model}…", spinner="dots"):
540
+ with self.vision_swap(model, f"the {mime} image"):
482
541
  text = vision.describe(
483
542
  self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
484
543
  )
@@ -522,6 +581,7 @@ class Agent:
522
581
  self.tools.read_mtimes.clear()
523
582
  self.reflected = len(self.messages)
524
583
  self.ctx_used = len(self.messages[0]["content"]) // 3
584
+ self.counted_chars = None
525
585
 
526
586
  def session_file(self) -> Path:
527
587
  return sessions.sessions_dir() / f"{self.session_id}.json"
@@ -543,7 +603,10 @@ class Agent:
543
603
  "messages": self.messages,
544
604
  "checkpoints": self.checkpoints.to_json(),
545
605
  }
546
- f.write_text(json.dumps(data))
606
+ # Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
607
+ partial = f.with_name(f".{f.name}.partial")
608
+ partial.write_text(json.dumps(data))
609
+ os.replace(partial, f)
547
610
 
548
611
  def new_session(self) -> None:
549
612
  self.reset()
@@ -580,6 +643,7 @@ class Agent:
580
643
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
581
644
  self.reflected = len(self.messages) # checked for notes to remember when that session ended
582
645
  self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
646
+ self.counted_chars = None
583
647
  return note
584
648
 
585
649
  def load_latest(self) -> bool:
@@ -624,9 +688,11 @@ class Agent:
624
688
  if not started and batch and batch > SAFE_NUM_BATCH:
625
689
  # Larger batches read prompts faster but need extra VRAM that isn't always free.
626
690
  self.settings.num_batch = SAFE_NUM_BATCH
627
- self.console.print(
628
- f"[yellow]The GPU ran out of memory with a prompt batch of {batch}; retrying with "
629
- f"{SAFE_NUM_BATCH}.[/] [dim]To skip this retry: lcode config set num_batch {SAFE_NUM_BATCH}[/]"
691
+ self.say(
692
+ f"The GPU ran out of memory reading the prompt in batches of {batch} tokens. Retrying with "
693
+ f"batches of {SAFE_NUM_BATCH}: a little slower to read, but it needs less memory. To start "
694
+ f"there next time: lcode config set num_batch {SAFE_NUM_BATCH}",
695
+ warning=True,
630
696
  )
631
697
  yield from self.chat(messages, tools, think)
632
698
  return
@@ -636,16 +702,19 @@ class Agent:
636
702
  smaller = max(catalog.MIN_USEFUL_CONTEXT, context // 2)
637
703
  self.settings.context = smaller
638
704
  limits.record(self.settings.model, smaller)
639
- self.console.print(
640
- f"[yellow]{self.settings.model} didn't fit in GPU memory with a {format_tokens(context)} "
641
- f"context; retrying with {format_tokens(smaller)}.[/] [dim]lcode will start there next "
642
- "time on this machine.[/]"
705
+ self.say(
706
+ f"{self.settings.model} didn't fit in memory with a {format_tokens(context)} context: the "
707
+ "context needs its own memory next to the model. Reloading it with "
708
+ f"{format_tokens(smaller)}; lcode will start there next time on this machine.",
709
+ warning=True,
643
710
  )
644
711
  yield from self.chat(messages, tools, think)
645
712
  return
646
713
  raise OllamaError(
647
- f"{e}\nThe model ran out of GPU memory. Try a smaller context (/ctx 128k), close other programs "
648
- "using the GPU, or pick a smaller model (/models)."
714
+ f"{e}\n{self.settings.model} ran out of memory. Close other programs that use the GPU (another "
715
+ "model, a game, an image generator), use a smaller context (/ctx "
716
+ f"{format_tokens(max(catalog.MIN_USEFUL_CONTEXT // 2, context // 2)).lower()}), or pick a smaller "
717
+ "model (/models)."
649
718
  ) from e
650
719
  raise
651
720
 
@@ -659,6 +728,7 @@ class Agent:
659
728
  content, thinking, tool_calls, final = "", "", [], {}
660
729
  md = MarkdownStreamer(self.console)
661
730
  progress = Progress()
731
+ watch = self.before_request(progress)
662
732
  status = None
663
733
  printed_thinking = False
664
734
 
@@ -680,6 +750,9 @@ class Agent:
680
750
  if self.cancel is not None and self.cancel.is_set():
681
751
  raise KeyboardInterrupt
682
752
  msg = chunk.get("message", {})
753
+ if watch is not None:
754
+ self.after_load(watch)
755
+ watch = None
683
756
  if msg.get("thinking"):
684
757
  thinking += msg["thinking"]
685
758
  if self.on_delta:
@@ -714,6 +787,8 @@ class Agent:
714
787
  raise
715
788
  finally:
716
789
  stop_spinner()
790
+ if watch is not None:
791
+ watch.stop()
717
792
  if printed_thinking:
718
793
  self.console.print()
719
794
  md.flush()
@@ -732,10 +807,49 @@ class Agent:
732
807
  self.usage["output_tokens"] += final.get("eval_count", 0)
733
808
  self.usage["output_ns"] += final.get("eval_duration", 0)
734
809
  self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
810
+ self.sent_chars = self.counted_chars = conversation_chars(self.messages)
735
811
  seconds = final.get("eval_duration", 0) / 1e9
736
812
  self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
737
813
  return message
738
814
 
815
+ def before_request(self, progress: Progress) -> residency.LoadWatch | None:
816
+ """Set the status line for what comes before the first token: loading the model if it isn't in memory
817
+ (watched until it is), then reading the prompt. Returns the watch while the model loads."""
818
+ if getattr(self.ollama, "kind", "ollama") != "ollama":
819
+ return None
820
+ model, context = self.settings.model, self.settings.context
821
+ fresh = max(0, conversation_chars(self.messages) - self.sent_chars) // 3
822
+ where = residency.placement(self.ollama, model)
823
+ if where is not None and (not where.context or where.context == context):
824
+ if fresh >= READING_NOTE_TOKENS:
825
+ progress.set_phase(f"Reading {format_tokens(fresh)} new tokens")
826
+ return None
827
+ total = conversation_chars(self.messages) // 3
828
+ if where is not None:
829
+ phase = f"Reloading {model} with a {format_tokens(context)} context"
830
+ elif self.made_room_for:
831
+ phase = f"Reloading {model} into memory (it made room for {self.made_room_for})"
832
+ else:
833
+ phase = f"Loading {model} into memory"
834
+ progress.set_phase(phase)
835
+ watch = residency.LoadWatch(
836
+ self.ollama, model, context, progress, f"Reading the conversation ({format_tokens(total)} tokens)"
837
+ )
838
+ watch.reload = where is not None or bool(self.made_room_for)
839
+ return watch
840
+
841
+ def after_load(self, watch: residency.LoadWatch) -> None:
842
+ """The first token after a load: one line on how long it took and how the model fits."""
843
+ seconds = watch.stop()
844
+ self.made_room_for = ""
845
+ self.load_state = ""
846
+ where = residency.placement(self.ollama, self.settings.model)
847
+ dense = residency.is_moe(self.ollama, self.settings.model) is False
848
+ report = residency.load_report(
849
+ self.settings.model, seconds, where, self.settings.context, self.hardware, dense, watch.reload
850
+ )
851
+ self.say(report, warning=report.endswith(("closed.", "slow.")))
852
+
739
853
  def describe_call(self, name: str, args: dict) -> str:
740
854
  if name in ("read_file", "write_file", "edit_file"):
741
855
  path = self.tools.rel(self.tools.resolve(args.get("path", "")))
@@ -839,6 +953,8 @@ class Agent:
839
953
  for step in range(self.max_steps):
840
954
  if self.cancel is not None and self.cancel.is_set():
841
955
  raise KeyboardInterrupt
956
+ if step and self.autosave:
957
+ self.save() # a crash or a closed terminal loses at most this step
842
958
  self.maybe_compact()
843
959
  try:
844
960
  reply = self.assistant_step()
@@ -923,6 +1039,9 @@ class Agent:
923
1039
  self.console.print(Text(f" {result[:300]}", style="red"))
924
1040
  elif name in ("read_file", "grep", "glob", "list_dir"):
925
1041
  self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
1042
+ budget = self.result_budget()
1043
+ if len(result) > budget and name not in ("agent", "skill"):
1044
+ result = context_tools.shorten(result, budget, f"{name} result")
926
1045
  verdict = self.repeats.check(name, args, result)
927
1046
  if verdict.note:
928
1047
  result += verdict.note
@@ -1057,8 +1176,21 @@ class Agent:
1057
1176
  limit = min(limit, self.settings.compact_at)
1058
1177
  return limit
1059
1178
 
1179
+ def next_request_tokens(self) -> int:
1180
+ """The size of the next request: what the model last counted, plus what was added since (tool output,
1181
+ attachments). Deciding on the last count alone let one large tool result overflow the window."""
1182
+ if self.counted_chars is None:
1183
+ return self.ctx_used
1184
+ return self.ctx_used + max(0, conversation_chars(self.messages) - self.counted_chars) // 3
1185
+
1186
+ def result_budget(self) -> int:
1187
+ """Characters one tool result may take: a share of the window, so one large result can't fill it."""
1188
+ return max(MIN_RESULT_CHARS, int(self.settings.context * 3 * RESULT_SHARE))
1189
+
1060
1190
  def maybe_compact(self) -> None:
1061
1191
  limit = self.compact_limit()
1192
+ if self.next_request_tokens() > limit:
1193
+ self.ctx_used, self.counted_chars = self.next_request_tokens(), None
1062
1194
  if self.ctx_used <= limit:
1063
1195
  return
1064
1196
  enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
@@ -1071,6 +1203,7 @@ class Agent:
1071
1203
  ) # not enough: before the last request
1072
1204
  context_tools.note_removed(self.messages, removed)
1073
1205
  if saved:
1206
+ self.sent_chars = 0 # the conversation changed: the model reads it again
1074
1207
  self.pruned += 1
1075
1208
  self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
1076
1209
  self.console.print(
@@ -1126,7 +1259,9 @@ class Agent:
1126
1259
  )
1127
1260
  self.skills_loaded.clear()
1128
1261
  self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
1262
+ self.counted_chars = None
1129
1263
  self.summarized_size = self.ctx_used
1264
+ self.sent_chars = 0
1130
1265
  self.reflected = len(self.messages)
1131
1266
  self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
1132
1267
 
@@ -199,6 +199,8 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
199
199
  compact_at=cfg["compact_at"],
200
200
  )
201
201
  agent = Agent(ollama, settings, cwd, console=console)
202
+ agent.hardware = hw
203
+ agent.autosave = True
202
204
  agent.interactive = options.interactive
203
205
  if options.max_steps:
204
206
  agent.max_steps = options.max_steps
@@ -14,8 +14,10 @@ full output is saved to a file the model can read if it needs more.
14
14
  from __future__ import annotations
15
15
 
16
16
  import json
17
+ import os
17
18
  import re
18
19
  import time
20
+ from collections import deque
19
21
  from dataclasses import dataclass
20
22
  from pathlib import Path
21
23
  from typing import TYPE_CHECKING
@@ -253,6 +255,72 @@ def shorten(text: str, limit: int, label: str = "output") -> str:
253
255
  return f"{head}{note}{tail}{where}"
254
256
 
255
257
 
258
+ class OutputSpool:
259
+ """Output collected line by line with bounded memory: once it's long, everything goes to a file, and only the
260
+ start, the end and the error-looking lines stay in memory. What `shorten` returns comes out the same."""
261
+
262
+ def __init__(self, limit: int, label: str = "output") -> None:
263
+ self.limit, self.label = limit, label
264
+ self.head: list[str] = [] # everything, until the output gets long; then its start
265
+ self.tail: deque[str] = deque()
266
+ self.tail_chars = 0
267
+ self.errors: list[str] = []
268
+ self.chars = 0
269
+ self.file = None
270
+ self.path: Path | None = None
271
+
272
+ def add(self, line: str) -> None:
273
+ self.chars += len(line)
274
+ if self.file is None:
275
+ self.head.append(line)
276
+ if self.chars > self.limit * 2:
277
+ self._spool()
278
+ return
279
+ try:
280
+ self.file.write(line)
281
+ except OSError:
282
+ pass
283
+ if ERROR_LINE.search(line) and len(self.errors) < MAX_ERROR_LINES:
284
+ self.errors.append(line.strip()[:300])
285
+ self.tail.append(line)
286
+ self.tail_chars += len(line)
287
+ while self.tail_chars > self.limit // 4 and len(self.tail) > 1:
288
+ self.tail_chars -= len(self.tail.popleft())
289
+
290
+ def _spool(self) -> None:
291
+ text = "".join(self.head)
292
+ self.path = save_output(text, self.label)
293
+ if self.path is not None:
294
+ try:
295
+ self.file = self.path.open("a")
296
+ except OSError:
297
+ self.file = None
298
+ if self.file is None: # nowhere to keep it: keep only the start and carry on counting
299
+ self.file = open(os.devnull, "w") # noqa: SIM115
300
+ head = text[: self.limit * 3 // 5]
301
+ self.head = [head]
302
+
303
+ def text(self) -> str:
304
+ if self.file is None:
305
+ return shorten("".join(self.head), self.limit, self.label)
306
+ self.file.close()
307
+ head, tail = "".join(self.head), "".join(self.tail)
308
+ cut = self.chars - len(head) - len(tail)
309
+ note = f"\n\n... [{cut} characters cut"
310
+ if self.errors:
311
+ note += f"; {len(self.errors)} line(s) from them that look like errors:]\n" + "\n".join(self.errors)
312
+ note += "\n[...]\n\n"
313
+ else:
314
+ note += "] ...\n\n"
315
+ where = (
316
+ f"\n[The full {self.label} ({self.chars} characters) is in {self.path}: read parts of it with read_file "
317
+ "if you need them.]"
318
+ if self.path
319
+ else ""
320
+ )
321
+ return f"{head}{note}{tail}{where}"
322
+
323
+
256
324
  # ----------------------------------------------------------------------------- what uses the context
257
325
 
258
326