lcode-cli 0.26.0__tar.gz → 0.28.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 (160) hide show
  1. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/CHANGELOG.md +49 -1
  2. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/bench.md +12 -1
  4. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/configuration.md +1 -0
  5. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/how-it-works.md +17 -6
  6. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/quickstart.md +1 -1
  7. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/usage.md +27 -0
  8. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/__init__.py +1 -1
  9. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/agent.py +160 -24
  10. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/api.py +2 -0
  11. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/cli.py +15 -0
  12. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/config.py +16 -0
  13. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/context.py +81 -4
  14. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/repl.py +26 -5
  15. lcode_cli-0.28.0/src/lcode/residency.py +167 -0
  16. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/tools.py +2 -3
  17. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/conftest.py +6 -0
  18. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_agent.py +4 -3
  19. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_context.py +86 -0
  20. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_mcp.py +2 -1
  21. lcode_cli-0.28.0/tests/test_residency.py +164 -0
  22. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_vision.py +5 -4
  23. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.editorconfig +0 -0
  24. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/CODEOWNERS +0 -0
  25. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  26. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  27. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  28. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  29. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  30. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/dependabot.yml +0 -0
  31. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/workflows/ci.yml +0 -0
  32. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/workflows/docs.yml +0 -0
  33. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/workflows/release.yml +0 -0
  34. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.gitignore +0 -0
  35. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.pre-commit-config.yaml +0 -0
  36. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/AGENTS.md +0 -0
  37. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/CODE_OF_CONDUCT.md +0 -0
  38. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/CONTRIBUTING.md +0 -0
  39. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/LICENSE +0 -0
  40. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/README.md +0 -0
  41. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/SECURITY.md +0 -0
  42. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/action.yml +0 -0
  43. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/agents.md +0 -0
  44. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/assets/demo.svg +0 -0
  45. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/assets/extra.css +0 -0
  46. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/assets/logo.svg +0 -0
  47. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/assets/social-preview.png +0 -0
  48. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/automation.md +0 -0
  49. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/changelog.md +0 -0
  50. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/commands.md +0 -0
  51. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/contributing.md +0 -0
  52. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/editors.md +0 -0
  53. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/git.md +0 -0
  54. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/hooks.md +0 -0
  55. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/index.md +0 -0
  56. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/installation.md +0 -0
  57. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/lsp.md +0 -0
  58. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/mcp.md +0 -0
  59. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/memory.md +0 -0
  60. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/models.md +0 -0
  61. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/sandbox.md +0 -0
  62. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/search.md +0 -0
  63. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/servers.md +0 -0
  64. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/troubleshooting.md +0 -0
  65. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/examples/README.md +0 -0
  66. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/examples/api/fix_tests.py +0 -0
  67. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/examples/commands/review.md +0 -0
  68. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/examples/skills/commit-message/SKILL.md +0 -0
  69. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  70. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/install.sh +0 -0
  71. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/mkdocs.yml +0 -0
  72. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/overrides/main.html +0 -0
  73. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/pyproject.toml +0 -0
  74. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/scripts/record_demo.py +0 -0
  75. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/scripts/social-preview.html +0 -0
  76. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/__main__.py +0 -0
  77. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/acp.py +0 -0
  78. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/action.py +0 -0
  79. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/backends.py +0 -0
  80. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/bench.py +0 -0
  81. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/bench_backend.py +0 -0
  82. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/bench_runtime.py +0 -0
  83. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/catalog.py +0 -0
  84. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/checkpoints.py +0 -0
  85. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/clipboard.py +0 -0
  86. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/codesearch.py +0 -0
  87. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/extensions.py +0 -0
  88. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/frontmatter.py +0 -0
  89. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/gitflow.py +0 -0
  90. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/hardware.py +0 -0
  91. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/hooks.py +0 -0
  92. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/jobs.py +0 -0
  93. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/limits.py +0 -0
  94. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/lsp.py +0 -0
  95. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/__init__.py +0 -0
  96. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/auth.py +0 -0
  97. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/catalog.py +0 -0
  98. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/catalog.toml +0 -0
  99. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/client.py +0 -0
  100. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/commands.py +0 -0
  101. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/config.py +0 -0
  102. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/http.py +0 -0
  103. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/manager.py +0 -0
  104. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/protocol.py +0 -0
  105. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/server.py +0 -0
  106. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/stdio.py +0 -0
  107. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/memory.py +0 -0
  108. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/models.toml +0 -0
  109. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/notify.py +0 -0
  110. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/ollama.py +0 -0
  111. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/permissions.py +0 -0
  112. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/planning.py +0 -0
  113. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/render.py +0 -0
  114. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/repeats.py +0 -0
  115. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/repomap.py +0 -0
  116. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/sandbox.py +0 -0
  117. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/secretscan.py +0 -0
  118. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/servers/__init__.py +0 -0
  119. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/servers/encord.py +0 -0
  120. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/servers/valohai.py +0 -0
  121. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/servers/xai.py +0 -0
  122. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/sessions.py +0 -0
  123. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/subagents.py +0 -0
  124. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/update.py +0 -0
  125. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/vision.py +0 -0
  126. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/web.py +0 -0
  127. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/fake_lsp_server.py +0 -0
  128. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/fake_mcp_server.py +0 -0
  129. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_acp.py +0 -0
  130. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_action.py +0 -0
  131. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_api.py +0 -0
  132. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_backends.py +0 -0
  133. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_bench.py +0 -0
  134. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_catalog.py +0 -0
  135. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_checkpoints.py +0 -0
  136. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_cli.py +0 -0
  137. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_clipboard.py +0 -0
  138. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_codesearch.py +0 -0
  139. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_config.py +0 -0
  140. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_context_picker.py +0 -0
  141. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_extensions.py +0 -0
  142. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_gitflow.py +0 -0
  143. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_hooks.py +0 -0
  144. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_jobs.py +0 -0
  145. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_lsp.py +0 -0
  146. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_memory.py +0 -0
  147. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_permissions.py +0 -0
  148. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_plan_mode.py +0 -0
  149. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_repeats.py +0 -0
  150. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_repomap.py +0 -0
  151. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_sandbox.py +0 -0
  152. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_secretscan.py +0 -0
  153. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_servers.py +0 -0
  154. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_sessions.py +0 -0
  155. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_subagents.py +0 -0
  156. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_tools.py +0 -0
  157. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_update.py +0 -0
  158. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_web.py +0 -0
  159. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_xai.py +0 -0
  160. {lcode_cli-0.26.0 → lcode_cli-0.28.0}/uv.lock +0 -0
@@ -6,6 +6,52 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.28.0] - 2026-10-13
10
+
11
+ ### Added
12
+
13
+ - lcode says what's happening under the hood, in a line, only when it happens:
14
+ - loading the model and then reading the conversation are named in the status line instead of
15
+ "Thinking";
16
+ - after a load, how long it took and how much of the model is on the GPU, with a warning when less
17
+ is than usual;
18
+ - at startup and on `/model`, a warning when a dense model won't fit on the GPU and will be slow,
19
+ with what to use instead;
20
+ - the vision model making the main model step aside, and the reload after it;
21
+ - an image tool taking the GPU;
22
+ - clearer explanations when Ollama runs out of memory.
23
+
24
+ The status bar shows when the model is still loading at startup. See
25
+ [What's happening under the hood](https://nasser1941.github.io/lcode/usage/#whats-happening-under-the-hood).
26
+
27
+ ### Fixed
28
+
29
+ - After `/model`, images were still described by the vision model chosen for the previous model.
30
+ - The tests no longer write into your lcode state folder; one of them had left about a hundred
31
+ entries in `extensions-approved.json`.
32
+
33
+ ## [0.27.0] - 2026-10-10
34
+
35
+ ### Changed
36
+
37
+ - Long sessions are summarized earlier: once the conversation reaches 64K tokens (`compact_at`),
38
+ even when the window is larger. Before, a 256K window was summarized only at about 220K, far past
39
+ where small models work well. lcode still removes old tool output first, and summarizes only if
40
+ that's not enough. `lcode config set compact_at 0` keeps only the 85% rule, and
41
+ `lcode bench --compact-at` compares ceilings. Measured with qwen3.6-35b: making room earlier
42
+ doesn't cost tasks (21 of 24 either way) and saved 17% of the time. See
43
+ [Context management](https://nasser1941.github.io/lcode/how-it-works/#context-management).
44
+ - Summaries keep your latest requests (up to eight) word for word, so the goal survives the
45
+ retelling. `/context` shows where the session makes room.
46
+
47
+ ### Fixed
48
+
49
+ - After lcode removed an earlier file read to make room, the model sometimes answered questions
50
+ about that file from memory, and got them wrong, instead of reading it again. The files whose
51
+ only copy went are now named next to the request it's working on, and the note in their place
52
+ says plainly that the content is gone. A conversation that was just summarized isn't summarized
53
+ again until it has grown by a quarter of `compact_at`, which must be at least 16K (or 0).
54
+
9
55
  ## [0.26.0] - 2026-10-10
10
56
 
11
57
  ### Added
@@ -553,7 +599,9 @@ First public release.
553
599
  - `AGENTS.md` project instructions and `/init` to generate them.
554
600
  - One-line installer for Linux and macOS.
555
601
 
556
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.26.0...HEAD
602
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.0...HEAD
603
+ [0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
604
+ [0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
557
605
  [0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
558
606
  [0.25.0]: https://github.com/nasser1941/lcode/compare/v0.24.0...v0.25.0
559
607
  [0.24.0]: https://github.com/nasser1941/lcode/compare/v0.23.0...v0.24.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.26.0
3
+ Version: 0.28.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/
@@ -51,7 +51,7 @@ Each task normally starts a new conversation. `--session` runs them all in **one
51
51
  instead, as a long working session would, so the context fills up and lcode's
52
52
  [context management](how-it-works.md#context-management) has to work: the summary shows how often
53
53
  it removed old tool output and how often it summarized the conversation. `--no-prune` turns the
54
- removal off, to compare.
54
+ removal off, to compare, and `--compact-at` sets the ceiling (`--compact-at 0`: only at 85%).
55
55
 
56
56
  ```bash
57
57
  lcode bench --context 32k --session --rounds 3
@@ -74,6 +74,17 @@ at no measurable cost in tasks solved or time. The bench tasks are independent o
74
74
  which is where summaries hurt least; in a real session, where later requests build on earlier
75
75
  ones, keeping the conversation intact matters more.
76
76
 
77
+ With a large window, `compact_at` decides when lcode makes room. In a 128K window, 24 tasks in one
78
+ conversation reach about 40K tokens, so the default ceiling of 64K never comes into play. Making
79
+ room earlier than needed doesn't cost tasks:
80
+
81
+ | 128K window, 24 tasks | No room made (peak ~40K) | `--compact-at 32k` |
82
+ |---|---|---|
83
+ | Conversation summarized | 0 times | 1 time (old tool output removed once) |
84
+ | Tasks solved | 21 of 24 | 21 of 24 |
85
+ | Time | 33 min | 27.5 min |
86
+ | Average prompt | 22.9K tokens | 16.6K tokens |
87
+
77
88
  ## The tasks
78
89
 
79
90
  Every task runs in a new temporary folder, with every permission granted (`yolo` mode) and web
@@ -37,6 +37,7 @@ lcode config path # print the file location
37
37
  | `repo_map` | `true` | Give the model a ranked [map of the repository](search.md) |
38
38
  | `embed_model` | `auto` | Embedding model for [semantic code search](search.md#semantic-code-search): `auto`, `off` or a model name |
39
39
  | `lsp` | `auto` | Use installed [language servers](lsp.md) for code navigation and errors after edits: `auto` or `off` |
40
+ | `compact_at` | `65536` | Make room (prune, then summarize) once the conversation reaches this many tokens, even in a larger window (at least 16K); `0` leaves only the 85% rule ([why](how-it-works.md#context-management)) |
40
41
  | `prune` | `true` | When the context is 85% full, first remove old tool output, and summarize the conversation only if that's not enough ([how](how-it-works.md#context-management)) |
41
42
  | `subagents` | `true` | Let the model hand tasks to [subagents](agents.md) with their own context |
42
43
  | `max_parallel_agents` | `1` | Subagents that run at the same time; more needs `OLLAMA_NUM_PARALLEL` on the Ollama server ([details](agents.md#several-at-once)) |
@@ -73,21 +73,32 @@ in a temporary git worktree, and their changes come back as a diff.
73
73
  ## Context management
74
74
 
75
75
  Ollama keeps the processed conversation in its KV cache, so each turn only processes the new tokens.
76
- lcode tracks how full the window is from Ollama's token counts. Most of a long session's context is
77
- old tool output, so when the window is 85% full:
76
+ lcode tracks how full the window is from Ollama's token counts. It makes room when the window is
77
+ 85% full, or when the conversation reaches 64K tokens (`compact_at`), whichever comes first.
78
+ Small models lose track in long contexts well before a large window is full: in one long session,
79
+ Qwen3.6 35B started repeating itself and overlooking what it had read once the conversation passed
80
+ about 75K tokens. Most of a long session's
81
+ context is old tool output, so:
78
82
 
79
83
  1. **lcode removes old tool output first.** File contents, command output and search results from
80
84
  before your last two requests become one-line notes that say what was there, such as
81
- `[lcode: read src/app.py earlier; removed to save context. Read it again if you need it.]`.
85
+ `[lcode: read src/app.py earlier; its content was removed …. Read it again before answering anything about it.]`.
82
86
  An earlier copy of a file that was read again later goes too, even in recent requests. Subagent
83
- reports, skill instructions and the approved plan stay.
84
- 2. **If that brings the conversation under 70%,** the work simply continues: nothing is summarized,
87
+ reports, skill instructions and the approved plan stay. The files whose only copy went are also
88
+ named next to the request the model is working on, since models tend to overlook a note deep in
89
+ the conversation and answer from memory instead of reading the file again.
90
+ 2. **If that brings the conversation well under the limit** (70% of the window, or about 53K
91
+ tokens with `compact_at`), the work simply continues: nothing is summarized,
85
92
  and the conversation itself stays word for word.
86
93
  If it's not, lcode also removes the tool output of your previous request, keeping only the
87
94
  latest one's. (`lcode config set prune false` skips this step.)
88
95
  3. **Otherwise lcode summarizes.** It asks whether anything is worth [remembering](memory.md), since
89
96
  the summary leaves details out, then has the model write a summary of the conversation (goals,
90
- findings, files changed, next steps) and continues from it.
97
+ findings, files changed, next steps) and continues from it. Your latest requests (up to eight)
98
+ are kept word for word under the summary, so the goal isn't lost in the retelling.
99
+
100
+ `lcode config set compact_at 128k` moves the ceiling, and `0` turns it off, leaving only the 85%
101
+ rule. `/context` shows where the current session makes room.
91
102
 
92
103
  Long tool output (over 30,000 characters) keeps its beginning, its end and the lines in between
93
104
  that look like errors. The full output is saved under `~/.local/state/lcode/outputs/` (for a week)
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.26.0 — local coding agent │
15
+ │ lcode v0.28.0 — 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
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.26.0"
6
+ __version__ = "0.28.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -3,6 +3,7 @@
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
8
9
  import platform
@@ -23,7 +24,20 @@ from rich.markup import escape
23
24
  from rich.panel import Panel
24
25
  from rich.text import Text
25
26
 
26
- from lcode import catalog, codesearch, extensions, limits, notify, planning, repomap, sessions, subagents, vision, web
27
+ from lcode import (
28
+ catalog,
29
+ codesearch,
30
+ extensions,
31
+ limits,
32
+ notify,
33
+ planning,
34
+ repomap,
35
+ residency,
36
+ sessions,
37
+ subagents,
38
+ vision,
39
+ web,
40
+ )
27
41
  from lcode import context as context_tools
28
42
  from lcode import memory as memory_notes
29
43
  from lcode.checkpoints import Checkpoints
@@ -53,7 +67,12 @@ from lcode.tools import (
53
67
  truncate,
54
68
  )
55
69
 
56
- GPU_MEMORY_ERRORS = ("out of memory", "illegal memory access", "cudamalloc failed")
70
+ GPU_MEMORY_ERRORS = (
71
+ "out of memory",
72
+ "illegal memory access",
73
+ "cudamalloc failed",
74
+ "requires more system memory", # Ollama: the model and its context don't fit in RAM either
75
+ )
57
76
  SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
58
77
  MAX_STEPS_PER_TURN = 150
59
78
  EVENT_OUTPUT = 4000 # characters of a tool's output in an event
@@ -62,6 +81,7 @@ REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's pe
62
81
  MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
63
82
  # How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
64
83
  MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
84
+ READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
65
85
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
66
86
  PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
67
87
  # @path, @"path with spaces" or @'path'
@@ -171,6 +191,10 @@ def project_root_or_cwd(cwd: Path) -> Path:
171
191
  return work_tree_for(cwd)
172
192
 
173
193
 
194
+ def conversation_chars(messages: list[dict]) -> int:
195
+ return sum(len(str(m.get("content") or "")) + len(str(m.get("thinking") or "")) for m in messages)
196
+
197
+
174
198
  def call_arguments(call: dict) -> dict:
175
199
  """A tool call's arguments as a dict (some models send them as a JSON string)."""
176
200
  args = call.get("function", {}).get("arguments") or {}
@@ -189,17 +213,22 @@ class Progress:
189
213
  arrives, for example while the model writes a long file into a tool call.
190
214
  """
191
215
 
192
- def __init__(self) -> None:
216
+ def __init__(self, phase: str = "Thinking") -> None:
193
217
  self.started = self.last_chunk = time.time()
194
- self.phase = "Thinking"
218
+ self.phase = phase
195
219
  self.chars = 0
196
220
 
197
221
  def update(self, phase: str, text: str) -> None:
198
222
  self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
199
223
 
224
+ def set_phase(self, phase: str) -> None:
225
+ """What happens before the first token: loading the model, reading the prompt."""
226
+ self.phase, self.last_chunk = phase, time.time()
227
+
200
228
  def __rich__(self) -> Text:
201
229
  now = time.time()
202
- phase = self.phase if now - self.last_chunk < 3 else "Working"
230
+ streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
231
+ phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
203
232
  return Text.from_markup(
204
233
  f"[cyan]{phase}…[/] [dim]({self.chars // 4} tokens, {now - self.started:.0f}s · Ctrl+C to stop)[/]"
205
234
  )
@@ -234,6 +263,7 @@ class Settings:
234
263
  notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
235
264
  max_parallel_agents: int = 1
236
265
  secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
266
+ compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
237
267
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
238
268
 
239
269
 
@@ -270,6 +300,7 @@ class Agent:
270
300
  self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
271
301
  self.plan = "" # the plan the user approved; kept through compaction
272
302
  self.pruned = self.compacted = 0 # how often the context was pruned or summarized automatically
303
+ self.summarized_size = 0 # tokens right after the last summary (0: none in this conversation)
273
304
  self._extensions: tuple[Path, extensions.Extensions] | None = None
274
305
  self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation
275
306
  # Set on subagents (see lcode.subagents):
@@ -286,6 +317,10 @@ class Agent:
286
317
  # Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
287
318
  self.plan_review: Callable[[str, str], str] | None = None
288
319
  self.max_steps = MAX_STEPS_PER_TURN
320
+ self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
321
+ self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
322
+ self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
323
+ self.sent_chars = 0 # characters of the conversation the model had already read
289
324
  self.turn_status = "" # how the last request ended: success, max_steps, loop
290
325
  self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
291
326
  self.response = None # the streaming response, so another thread can abort it
@@ -463,12 +498,33 @@ class Agent:
463
498
  image = vision.read_image(path)
464
499
  # The session's own model keeps its settings, so Ollama doesn't reload it.
465
500
  options = self.options() if model == self.settings.model else None
466
- loading = "" if model == self.settings.model else " (loads it; the next request reloads the main model)"
467
- with self.console.status(f"Looking at {path.name} with {model}{loading}…", spinner="dots"):
501
+ with self.vision_swap(model, path.name):
468
502
  return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
469
503
  except vision.VisionError as e:
470
504
  raise ToolError(str(e)) from e
471
505
 
506
+ @contextlib.contextmanager
507
+ def vision_swap(self, model: str, what: str):
508
+ """Look at an image with `model`, saying when it has to load and when the main model made room for it."""
509
+ main = self.settings.model
510
+ own = model == main
511
+ main_was_loaded = not own and residency.placement(self.ollama, main) is not None
512
+ if own or residency.placement(self.ollama, model) is not None:
513
+ text = f"Looking at {what} with {model}…"
514
+ else:
515
+ text = f"Loading the vision model {model} to look at {what}" + (
516
+ f"; {main} makes room and reloads afterwards…" if main_was_loaded else "…"
517
+ )
518
+ with self.console.status(text, spinner="dots"):
519
+ yield
520
+ if main_was_loaded and residency.placement(self.ollama, main) is None:
521
+ self.made_room_for = f"the vision model {model}"
522
+ self.say(f"{main} was unloaded to make room for {model}; it reloads for the next step.")
523
+
524
+ def say(self, text: str, warning: bool = False) -> None:
525
+ """One line about what happens under the hood (loading, memory), not about the work itself."""
526
+ self.console.print(Text(f"{'⚠' if warning else '◆'} {text}", style="yellow" if warning else "dim cyan"))
527
+
472
528
  def describe_image_data(self, data: str, mime: str) -> str:
473
529
  """For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
474
530
  model = self.vision_model()
@@ -476,7 +532,7 @@ class Agent:
476
532
  return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
477
533
  options = self.options() if model == self.settings.model else None
478
534
  try:
479
- with self.console.status(f"Looking at the {mime} image with {model}…", spinner="dots"):
535
+ with self.vision_swap(model, f"the {mime} image"):
480
536
  text = vision.describe(
481
537
  self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
482
538
  )
@@ -514,6 +570,7 @@ class Agent:
514
570
  return WEB_PROMPT.format(tools=tools, date=dt.date.today().isoformat())
515
571
 
516
572
  def reset(self) -> None:
573
+ self.summarized_size = 0
517
574
  self.messages = [{"role": "system", "content": self.system_prompt()}]
518
575
  self._mcp_prompt = ""
519
576
  self.tools.read_mtimes.clear()
@@ -621,9 +678,11 @@ class Agent:
621
678
  if not started and batch and batch > SAFE_NUM_BATCH:
622
679
  # Larger batches read prompts faster but need extra VRAM that isn't always free.
623
680
  self.settings.num_batch = SAFE_NUM_BATCH
624
- self.console.print(
625
- f"[yellow]The GPU ran out of memory with a prompt batch of {batch}; retrying with "
626
- f"{SAFE_NUM_BATCH}.[/] [dim]To skip this retry: lcode config set num_batch {SAFE_NUM_BATCH}[/]"
681
+ self.say(
682
+ f"The GPU ran out of memory reading the prompt in batches of {batch} tokens. Retrying with "
683
+ f"batches of {SAFE_NUM_BATCH}: a little slower to read, but it needs less memory. To start "
684
+ f"there next time: lcode config set num_batch {SAFE_NUM_BATCH}",
685
+ warning=True,
627
686
  )
628
687
  yield from self.chat(messages, tools, think)
629
688
  return
@@ -633,16 +692,19 @@ class Agent:
633
692
  smaller = max(catalog.MIN_USEFUL_CONTEXT, context // 2)
634
693
  self.settings.context = smaller
635
694
  limits.record(self.settings.model, smaller)
636
- self.console.print(
637
- f"[yellow]{self.settings.model} didn't fit in GPU memory with a {format_tokens(context)} "
638
- f"context; retrying with {format_tokens(smaller)}.[/] [dim]lcode will start there next "
639
- "time on this machine.[/]"
695
+ self.say(
696
+ f"{self.settings.model} didn't fit in memory with a {format_tokens(context)} context: the "
697
+ "context needs its own memory next to the model. Reloading it with "
698
+ f"{format_tokens(smaller)}; lcode will start there next time on this machine.",
699
+ warning=True,
640
700
  )
641
701
  yield from self.chat(messages, tools, think)
642
702
  return
643
703
  raise OllamaError(
644
- f"{e}\nThe model ran out of GPU memory. Try a smaller context (/ctx 128k), close other programs "
645
- "using the GPU, or pick a smaller model (/models)."
704
+ f"{e}\n{self.settings.model} ran out of memory. Close other programs that use the GPU (another "
705
+ "model, a game, an image generator), use a smaller context (/ctx "
706
+ f"{format_tokens(max(catalog.MIN_USEFUL_CONTEXT // 2, context // 2)).lower()}), or pick a smaller "
707
+ "model (/models)."
646
708
  ) from e
647
709
  raise
648
710
 
@@ -656,6 +718,7 @@ class Agent:
656
718
  content, thinking, tool_calls, final = "", "", [], {}
657
719
  md = MarkdownStreamer(self.console)
658
720
  progress = Progress()
721
+ watch = self.before_request(progress)
659
722
  status = None
660
723
  printed_thinking = False
661
724
 
@@ -677,6 +740,9 @@ class Agent:
677
740
  if self.cancel is not None and self.cancel.is_set():
678
741
  raise KeyboardInterrupt
679
742
  msg = chunk.get("message", {})
743
+ if watch is not None:
744
+ self.after_load(watch)
745
+ watch = None
680
746
  if msg.get("thinking"):
681
747
  thinking += msg["thinking"]
682
748
  if self.on_delta:
@@ -711,6 +777,8 @@ class Agent:
711
777
  raise
712
778
  finally:
713
779
  stop_spinner()
780
+ if watch is not None:
781
+ watch.stop()
714
782
  if printed_thinking:
715
783
  self.console.print()
716
784
  md.flush()
@@ -729,10 +797,49 @@ class Agent:
729
797
  self.usage["output_tokens"] += final.get("eval_count", 0)
730
798
  self.usage["output_ns"] += final.get("eval_duration", 0)
731
799
  self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
800
+ self.sent_chars = conversation_chars(self.messages)
732
801
  seconds = final.get("eval_duration", 0) / 1e9
733
802
  self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
734
803
  return message
735
804
 
805
+ def before_request(self, progress: Progress) -> residency.LoadWatch | None:
806
+ """Set the status line for what comes before the first token: loading the model if it isn't in memory
807
+ (watched until it is), then reading the prompt. Returns the watch while the model loads."""
808
+ if getattr(self.ollama, "kind", "ollama") != "ollama":
809
+ return None
810
+ model, context = self.settings.model, self.settings.context
811
+ fresh = max(0, conversation_chars(self.messages) - self.sent_chars) // 3
812
+ where = residency.placement(self.ollama, model)
813
+ if where is not None and (not where.context or where.context == context):
814
+ if fresh >= READING_NOTE_TOKENS:
815
+ progress.set_phase(f"Reading {format_tokens(fresh)} new tokens")
816
+ return None
817
+ total = conversation_chars(self.messages) // 3
818
+ if where is not None:
819
+ phase = f"Reloading {model} with a {format_tokens(context)} context"
820
+ elif self.made_room_for:
821
+ phase = f"Reloading {model} into memory (it made room for {self.made_room_for})"
822
+ else:
823
+ phase = f"Loading {model} into memory"
824
+ progress.set_phase(phase)
825
+ watch = residency.LoadWatch(
826
+ self.ollama, model, context, progress, f"Reading the conversation ({format_tokens(total)} tokens)"
827
+ )
828
+ watch.reload = where is not None or bool(self.made_room_for)
829
+ return watch
830
+
831
+ def after_load(self, watch: residency.LoadWatch) -> None:
832
+ """The first token after a load: one line on how long it took and how the model fits."""
833
+ seconds = watch.stop()
834
+ self.made_room_for = ""
835
+ self.load_state = ""
836
+ where = residency.placement(self.ollama, self.settings.model)
837
+ dense = residency.is_moe(self.ollama, self.settings.model) is False
838
+ report = residency.load_report(
839
+ self.settings.model, seconds, where, self.settings.context, self.hardware, dense, watch.reload
840
+ )
841
+ self.say(report, warning=report.endswith(("closed.", "slow.")))
842
+
736
843
  def describe_call(self, name: str, args: dict) -> str:
737
844
  if name in ("read_file", "write_file", "edit_file"):
738
845
  path = self.tools.rel(self.tools.resolve(args.get("path", "")))
@@ -1046,23 +1153,46 @@ class Agent:
1046
1153
  return text + ("\n\n" + "\n\n".join(attached) if attached else "")
1047
1154
 
1048
1155
  # -- context management
1156
+ def compact_limit(self) -> int:
1157
+ """The tokens in use at which lcode makes room: 85% of the window, or `compact_at` when that's lower.
1158
+ Small models lose track in long contexts well before a large window is full."""
1159
+ limit = int(AUTO_COMPACT_RATIO * self.settings.context)
1160
+ if self.settings.compact_at > 0:
1161
+ limit = min(limit, self.settings.compact_at)
1162
+ return limit
1163
+
1049
1164
  def maybe_compact(self) -> None:
1050
- if self.ctx_used <= AUTO_COMPACT_RATIO * self.settings.context:
1165
+ limit = self.compact_limit()
1166
+ if self.ctx_used <= limit:
1051
1167
  return
1168
+ enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
1052
1169
  if self.settings.prune:
1053
- saved = context_tools.prune(self.messages) # tool output from before the last two requests
1054
- if self.ctx_used - saved // 3 > PRUNED_ENOUGH * self.settings.context:
1055
- saved += context_tools.prune(self.messages, keep=1) # not enough: before the last request
1170
+ removed: list[str] = [] # files and pages whose only copy goes
1171
+ saved = context_tools.prune(self.messages, removed=removed) # tool output from before the last two requests
1172
+ if self.ctx_used - saved // 3 > enough:
1173
+ saved += context_tools.prune(
1174
+ self.messages, keep=1, removed=removed
1175
+ ) # not enough: before the last request
1176
+ context_tools.note_removed(self.messages, removed)
1056
1177
  if saved:
1178
+ self.sent_chars = 0 # the conversation changed: the model reads it again
1057
1179
  self.pruned += 1
1058
1180
  self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
1059
1181
  self.console.print(
1060
1182
  f"[dim]Removed old tool output from the conversation (~{format_tokens(saved // 3)} tokens) to "
1061
1183
  "make room.[/]"
1062
1184
  )
1063
- if self.ctx_used <= PRUNED_ENOUGH * self.settings.context:
1185
+ if self.ctx_used <= enough:
1064
1186
  return
1065
- self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
1187
+ window_full = self.ctx_used > AUTO_COMPACT_RATIO * self.settings.context
1188
+ if not window_full and self.summarized_size and self.ctx_used - self.summarized_size < limit // 4:
1189
+ return # summarized not long ago: summarizing again so soon would only lose more
1190
+ if limit < AUTO_COMPACT_RATIO * self.settings.context:
1191
+ self.console.print(
1192
+ f"[yellow]The conversation reached {format_tokens(limit)} tokens (compact_at) — summarizing it…[/]"
1193
+ )
1194
+ else:
1195
+ self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
1066
1196
  self.compacted += 1
1067
1197
  self.compact()
1068
1198
 
@@ -1083,9 +1213,13 @@ class Agent:
1083
1213
  with self.console.status("[cyan]Compacting…[/]"):
1084
1214
  for chunk in self.chat([*self.messages, {"role": "user", "content": instructions}], None, False):
1085
1215
  summary += chunk.get("message", {}).get("content", "")
1216
+ requests = context_tools.user_requests(self.messages)
1086
1217
  self.messages = [
1087
1218
  {"role": "system", "content": self.system_prompt()},
1088
- {"role": "user", "content": f"[Summary of our conversation so far]\n\n{summary}"},
1219
+ {
1220
+ "role": "user",
1221
+ "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}" + context_tools.requests_block(requests),
1222
+ },
1089
1223
  {"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
1090
1224
  ]
1091
1225
  if self.plan: # the approved plan stays word for word
@@ -1097,6 +1231,8 @@ class Agent:
1097
1231
  )
1098
1232
  self.skills_loaded.clear()
1099
1233
  self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
1234
+ self.summarized_size = self.ctx_used
1235
+ self.sent_chars = 0
1100
1236
  self.reflected = len(self.messages)
1101
1237
  self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
1102
1238
 
@@ -196,8 +196,10 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
196
196
  notify_after=cfg["notify_after"],
197
197
  secret_check=cfg["secret_check"],
198
198
  repeat_limit=cfg["repeat_limit"],
199
+ compact_at=cfg["compact_at"],
199
200
  )
200
201
  agent = Agent(ollama, settings, cwd, console=console)
202
+ agent.hardware = hw
201
203
  agent.interactive = options.interactive
202
204
  if options.max_steps:
203
205
  agent.max_steps = options.max_steps
@@ -579,6 +579,7 @@ def cmd_bench(args) -> None:
579
579
  web="off",
580
580
  checkpoints=False,
581
581
  prune=not args.no_prune,
582
+ compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
582
583
  repo_map=args.repo_map,
583
584
  embed_model=cfg["embed_model"] if args.code_search else "off",
584
585
  )
@@ -678,6 +679,14 @@ def cmd_chat(args) -> None:
678
679
  sys.exit(exit_code)
679
680
 
680
681
 
682
+ def compact_tokens(value: str) -> int:
683
+ """`--compact-at`: a token count such as 64k, or 0."""
684
+ try:
685
+ return config.parse_compact_at(value)
686
+ except ConfigError as e:
687
+ raise argparse.ArgumentTypeError(str(e)) from e
688
+
689
+
681
690
  def build_parser() -> argparse.ArgumentParser:
682
691
  parser = argparse.ArgumentParser(
683
692
  prog="lcode",
@@ -765,6 +774,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
765
774
  p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
766
775
  p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
767
776
  p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
777
+ p.add_argument(
778
+ "--compact-at",
779
+ type=compact_tokens,
780
+ metavar="TOKENS",
781
+ help="summarize at this many tokens, e.g. 64k (default: the compact_at setting; 0: only at 85%%)",
782
+ )
768
783
  subs["bench"] = p
769
784
  p = argparse.ArgumentParser(
770
785
  prog="lcode index",
@@ -61,6 +61,7 @@ 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
  "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
63
63
  "secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
64
+ "compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),
64
65
  "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
65
66
  }
66
67
  ENV_OVERRIDES = {
@@ -81,6 +82,19 @@ class ConfigError(ValueError):
81
82
  pass
82
83
 
83
84
 
85
+ MIN_COMPACT_AT = 16384 # below this, the system prompt and a summary alone come close to the ceiling
86
+
87
+
88
+ def parse_compact_at(value: str | int) -> int:
89
+ """compact_at: a token count like parse_context's, at least 16K, or 0 (off)."""
90
+ if str(value).strip().lower() in ("0", "off", "false", "none"):
91
+ return 0
92
+ tokens = parse_context(value)
93
+ if tokens < MIN_COMPACT_AT:
94
+ raise ConfigError(f"compact_at {tokens} is too small: use at least 16k, or 0 to turn it off")
95
+ return tokens
96
+
97
+
84
98
  def parse_context(value: str | int) -> int:
85
99
  """Parse '131072', '128k', '128K' or '1m' into a token count (k = 1024)."""
86
100
  if isinstance(value, int):
@@ -118,6 +132,8 @@ def coerce(key: str, value: object) -> object:
118
132
  _, typ, _ = SETTINGS[key]
119
133
  if key == "context":
120
134
  return parse_context(value) # type: ignore[arg-type]
135
+ if key == "compact_at":
136
+ return parse_compact_at(value) # type: ignore[arg-type]
121
137
  if key in ("ollama_host", "searxng_url"):
122
138
  return normalize_host(str(value))
123
139
  if key == "base_url":