lcode-cli 0.26.0__tar.gz → 0.27.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 (158) hide show
  1. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/CHANGELOG.md +24 -1
  2. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/bench.md +12 -1
  4. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/configuration.md +1 -0
  5. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/how-it-works.md +17 -6
  6. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/quickstart.md +1 -1
  7. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/__init__.py +1 -1
  8. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/agent.py +37 -7
  9. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/api.py +1 -0
  10. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/cli.py +15 -0
  11. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/config.py +16 -0
  12. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/context.py +81 -4
  13. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/repl.py +2 -1
  14. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_context.py +86 -0
  15. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.editorconfig +0 -0
  16. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.github/CODEOWNERS +0 -0
  17. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  21. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  22. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.github/dependabot.yml +0 -0
  23. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.github/workflows/ci.yml +0 -0
  24. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.github/workflows/docs.yml +0 -0
  25. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.github/workflows/release.yml +0 -0
  26. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.gitignore +0 -0
  27. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/.pre-commit-config.yaml +0 -0
  28. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/AGENTS.md +0 -0
  29. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/CODE_OF_CONDUCT.md +0 -0
  30. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/CONTRIBUTING.md +0 -0
  31. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/LICENSE +0 -0
  32. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/README.md +0 -0
  33. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/SECURITY.md +0 -0
  34. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/action.yml +0 -0
  35. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/agents.md +0 -0
  36. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/assets/demo.svg +0 -0
  37. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/assets/extra.css +0 -0
  38. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/assets/logo.svg +0 -0
  39. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/assets/social-preview.png +0 -0
  40. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/automation.md +0 -0
  41. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/changelog.md +0 -0
  42. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/commands.md +0 -0
  43. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/contributing.md +0 -0
  44. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/editors.md +0 -0
  45. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/git.md +0 -0
  46. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/hooks.md +0 -0
  47. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/index.md +0 -0
  48. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/installation.md +0 -0
  49. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/lsp.md +0 -0
  50. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/mcp.md +0 -0
  51. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/memory.md +0 -0
  52. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/models.md +0 -0
  53. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/sandbox.md +0 -0
  54. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/search.md +0 -0
  55. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/servers.md +0 -0
  56. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/troubleshooting.md +0 -0
  57. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/docs/usage.md +0 -0
  58. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/examples/README.md +0 -0
  59. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/examples/api/fix_tests.py +0 -0
  60. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/examples/commands/review.md +0 -0
  61. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/examples/skills/commit-message/SKILL.md +0 -0
  62. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  63. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/install.sh +0 -0
  64. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/mkdocs.yml +0 -0
  65. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/overrides/main.html +0 -0
  66. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/pyproject.toml +0 -0
  67. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/scripts/record_demo.py +0 -0
  68. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/scripts/social-preview.html +0 -0
  69. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/__main__.py +0 -0
  70. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/acp.py +0 -0
  71. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/action.py +0 -0
  72. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/backends.py +0 -0
  73. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/bench.py +0 -0
  74. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/bench_backend.py +0 -0
  75. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/bench_runtime.py +0 -0
  76. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/catalog.py +0 -0
  77. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/checkpoints.py +0 -0
  78. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/clipboard.py +0 -0
  79. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/codesearch.py +0 -0
  80. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/extensions.py +0 -0
  81. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/frontmatter.py +0 -0
  82. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/gitflow.py +0 -0
  83. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/hardware.py +0 -0
  84. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/hooks.py +0 -0
  85. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/jobs.py +0 -0
  86. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/limits.py +0 -0
  87. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/lsp.py +0 -0
  88. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/__init__.py +0 -0
  89. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/auth.py +0 -0
  90. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/catalog.py +0 -0
  91. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/catalog.toml +0 -0
  92. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/client.py +0 -0
  93. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/commands.py +0 -0
  94. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/config.py +0 -0
  95. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/http.py +0 -0
  96. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/manager.py +0 -0
  97. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/protocol.py +0 -0
  98. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/server.py +0 -0
  99. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/mcp/stdio.py +0 -0
  100. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/memory.py +0 -0
  101. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/models.toml +0 -0
  102. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/notify.py +0 -0
  103. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/ollama.py +0 -0
  104. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/permissions.py +0 -0
  105. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/planning.py +0 -0
  106. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/render.py +0 -0
  107. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/repeats.py +0 -0
  108. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/repomap.py +0 -0
  109. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/sandbox.py +0 -0
  110. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/secretscan.py +0 -0
  111. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/servers/__init__.py +0 -0
  112. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/servers/encord.py +0 -0
  113. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/servers/valohai.py +0 -0
  114. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/servers/xai.py +0 -0
  115. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/sessions.py +0 -0
  116. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/subagents.py +0 -0
  117. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/tools.py +0 -0
  118. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/update.py +0 -0
  119. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/vision.py +0 -0
  120. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/src/lcode/web.py +0 -0
  121. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/conftest.py +0 -0
  122. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/fake_lsp_server.py +0 -0
  123. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/fake_mcp_server.py +0 -0
  124. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_acp.py +0 -0
  125. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_action.py +0 -0
  126. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_agent.py +0 -0
  127. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_api.py +0 -0
  128. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_backends.py +0 -0
  129. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_bench.py +0 -0
  130. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_catalog.py +0 -0
  131. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_checkpoints.py +0 -0
  132. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_cli.py +0 -0
  133. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_clipboard.py +0 -0
  134. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_codesearch.py +0 -0
  135. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_config.py +0 -0
  136. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_context_picker.py +0 -0
  137. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_extensions.py +0 -0
  138. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_gitflow.py +0 -0
  139. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_hooks.py +0 -0
  140. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_jobs.py +0 -0
  141. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_lsp.py +0 -0
  142. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_mcp.py +0 -0
  143. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_memory.py +0 -0
  144. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_permissions.py +0 -0
  145. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_plan_mode.py +0 -0
  146. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_repeats.py +0 -0
  147. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_repomap.py +0 -0
  148. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_sandbox.py +0 -0
  149. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_secretscan.py +0 -0
  150. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_servers.py +0 -0
  151. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_sessions.py +0 -0
  152. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_subagents.py +0 -0
  153. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_tools.py +0 -0
  154. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_update.py +0 -0
  155. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_vision.py +0 -0
  156. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_web.py +0 -0
  157. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/tests/test_xai.py +0 -0
  158. {lcode_cli-0.26.0 → lcode_cli-0.27.0}/uv.lock +0 -0
@@ -6,6 +6,28 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.27.0] - 2026-10-10
10
+
11
+ ### Changed
12
+
13
+ - Long sessions are summarized earlier: once the conversation reaches 64K tokens (`compact_at`),
14
+ even when the window is larger. Before, a 256K window was summarized only at about 220K, far past
15
+ where small models work well. lcode still removes old tool output first, and summarizes only if
16
+ that's not enough. `lcode config set compact_at 0` keeps only the 85% rule, and
17
+ `lcode bench --compact-at` compares ceilings. Measured with qwen3.6-35b: making room earlier
18
+ doesn't cost tasks (21 of 24 either way) and saved 17% of the time. See
19
+ [Context management](https://nasser1941.github.io/lcode/how-it-works/#context-management).
20
+ - Summaries keep your latest requests (up to eight) word for word, so the goal survives the
21
+ retelling. `/context` shows where the session makes room.
22
+
23
+ ### Fixed
24
+
25
+ - After lcode removed an earlier file read to make room, the model sometimes answered questions
26
+ about that file from memory, and got them wrong, instead of reading it again. The files whose
27
+ only copy went are now named next to the request it's working on, and the note in their place
28
+ says plainly that the content is gone. A conversation that was just summarized isn't summarized
29
+ again until it has grown by a quarter of `compact_at`, which must be at least 16K (or 0).
30
+
9
31
  ## [0.26.0] - 2026-10-10
10
32
 
11
33
  ### Added
@@ -553,7 +575,8 @@ First public release.
553
575
  - `AGENTS.md` project instructions and `/init` to generate them.
554
576
  - One-line installer for Linux and macOS.
555
577
 
556
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.26.0...HEAD
578
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.27.0...HEAD
579
+ [0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
557
580
  [0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
558
581
  [0.25.0]: https://github.com/nasser1941/lcode/compare/v0.24.0...v0.25.0
559
582
  [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.27.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.27.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -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.27.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -234,6 +234,7 @@ class Settings:
234
234
  notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
235
235
  max_parallel_agents: int = 1
236
236
  secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
237
+ compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
237
238
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
238
239
 
239
240
 
@@ -270,6 +271,7 @@ class Agent:
270
271
  self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
271
272
  self.plan = "" # the plan the user approved; kept through compaction
272
273
  self.pruned = self.compacted = 0 # how often the context was pruned or summarized automatically
274
+ self.summarized_size = 0 # tokens right after the last summary (0: none in this conversation)
273
275
  self._extensions: tuple[Path, extensions.Extensions] | None = None
274
276
  self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation
275
277
  # Set on subagents (see lcode.subagents):
@@ -514,6 +516,7 @@ class Agent:
514
516
  return WEB_PROMPT.format(tools=tools, date=dt.date.today().isoformat())
515
517
 
516
518
  def reset(self) -> None:
519
+ self.summarized_size = 0
517
520
  self.messages = [{"role": "system", "content": self.system_prompt()}]
518
521
  self._mcp_prompt = ""
519
522
  self.tools.read_mtimes.clear()
@@ -1046,13 +1049,27 @@ class Agent:
1046
1049
  return text + ("\n\n" + "\n\n".join(attached) if attached else "")
1047
1050
 
1048
1051
  # -- context management
1052
+ def compact_limit(self) -> int:
1053
+ """The tokens in use at which lcode makes room: 85% of the window, or `compact_at` when that's lower.
1054
+ Small models lose track in long contexts well before a large window is full."""
1055
+ limit = int(AUTO_COMPACT_RATIO * self.settings.context)
1056
+ if self.settings.compact_at > 0:
1057
+ limit = min(limit, self.settings.compact_at)
1058
+ return limit
1059
+
1049
1060
  def maybe_compact(self) -> None:
1050
- if self.ctx_used <= AUTO_COMPACT_RATIO * self.settings.context:
1061
+ limit = self.compact_limit()
1062
+ if self.ctx_used <= limit:
1051
1063
  return
1064
+ enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
1052
1065
  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
1066
+ removed: list[str] = [] # files and pages whose only copy goes
1067
+ saved = context_tools.prune(self.messages, removed=removed) # tool output from before the last two requests
1068
+ if self.ctx_used - saved // 3 > enough:
1069
+ saved += context_tools.prune(
1070
+ self.messages, keep=1, removed=removed
1071
+ ) # not enough: before the last request
1072
+ context_tools.note_removed(self.messages, removed)
1056
1073
  if saved:
1057
1074
  self.pruned += 1
1058
1075
  self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
@@ -1060,9 +1077,17 @@ class Agent:
1060
1077
  f"[dim]Removed old tool output from the conversation (~{format_tokens(saved // 3)} tokens) to "
1061
1078
  "make room.[/]"
1062
1079
  )
1063
- if self.ctx_used <= PRUNED_ENOUGH * self.settings.context:
1080
+ if self.ctx_used <= enough:
1064
1081
  return
1065
- self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
1082
+ window_full = self.ctx_used > AUTO_COMPACT_RATIO * self.settings.context
1083
+ if not window_full and self.summarized_size and self.ctx_used - self.summarized_size < limit // 4:
1084
+ return # summarized not long ago: summarizing again so soon would only lose more
1085
+ if limit < AUTO_COMPACT_RATIO * self.settings.context:
1086
+ self.console.print(
1087
+ f"[yellow]The conversation reached {format_tokens(limit)} tokens (compact_at) — summarizing it…[/]"
1088
+ )
1089
+ else:
1090
+ self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
1066
1091
  self.compacted += 1
1067
1092
  self.compact()
1068
1093
 
@@ -1083,9 +1108,13 @@ class Agent:
1083
1108
  with self.console.status("[cyan]Compacting…[/]"):
1084
1109
  for chunk in self.chat([*self.messages, {"role": "user", "content": instructions}], None, False):
1085
1110
  summary += chunk.get("message", {}).get("content", "")
1111
+ requests = context_tools.user_requests(self.messages)
1086
1112
  self.messages = [
1087
1113
  {"role": "system", "content": self.system_prompt()},
1088
- {"role": "user", "content": f"[Summary of our conversation so far]\n\n{summary}"},
1114
+ {
1115
+ "role": "user",
1116
+ "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}" + context_tools.requests_block(requests),
1117
+ },
1089
1118
  {"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
1090
1119
  ]
1091
1120
  if self.plan: # the approved plan stays word for word
@@ -1097,6 +1126,7 @@ class Agent:
1097
1126
  )
1098
1127
  self.skills_loaded.clear()
1099
1128
  self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
1129
+ self.summarized_size = self.ctx_used
1100
1130
  self.reflected = len(self.messages)
1101
1131
  self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
1102
1132
 
@@ -196,6 +196,7 @@ 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)
201
202
  agent.interactive = options.interactive
@@ -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":
@@ -29,6 +29,10 @@ if TYPE_CHECKING:
29
29
  KEEP_RECENT_REQUESTS = 2 # tool results of the last requests stay as they are
30
30
  MIN_PRUNE_CHARS = 400 # shorter results aren't worth replacing
31
31
  PRUNED = "[lcode: " # how stubs start, so they're never pruned twice
32
+ GONE = ( # a removed file read: firm, because models answer from the stub instead of reading it again
33
+ "earlier; its content was removed to save context and isn't in this conversation any more. Read it again "
34
+ "before answering anything about it.]"
35
+ )
32
36
  KEEP_TOOLS = {"agent", "skill", "memory", "todo_write", "present_plan"} # short, or still guiding the work
33
37
  ERROR_LINE = re.compile(
34
38
  r"error|exception|traceback|failed|failure|fatal|panic|assert|denied|not found|cannot|can't|undefined", re.I
@@ -37,6 +41,56 @@ MAX_ERROR_LINES = 40
37
41
  OUTPUT_DAYS = 7 # saved full outputs are deleted after this
38
42
 
39
43
 
44
+ # ----------------------------------------------------------------------------- the user's requests
45
+
46
+ REQUESTS_HEADER = "[The user's requests in this conversation, word for word, oldest first]"
47
+ MAX_REQUESTS = 8 # the most recent ones are kept through a summary
48
+ MAX_REQUEST_CHARS = 1500
49
+ # What lcode appends to the user's words: attached files, images and folders, and its own notes.
50
+ ATTACHED = re.compile(r"\n\n(?:<file path=|<image path=|<directory path=|\[lcode[:\]] |\[The user wants the )")
51
+
52
+
53
+ def request_text(content: str) -> str:
54
+ """The user's own words in a message, without what lcode attached to it."""
55
+ cut = ATTACHED.search(content)
56
+ return (content[: cut.start()] if cut else content).strip()
57
+
58
+
59
+ def user_requests(messages: list[dict]) -> list[str]:
60
+ """What the user asked in the conversation, oldest first, including the requests an earlier summary kept."""
61
+ requests: list[str] = []
62
+ for message in messages:
63
+ content = message.get("content") or ""
64
+ if message.get("role") != "user":
65
+ continue
66
+ if content.startswith(SUMMARY_PREFIX):
67
+ start = content.find(REQUESTS_HEADER)
68
+ if start >= 0:
69
+ rest = content[start + len(REQUESTS_HEADER) :]
70
+ try:
71
+ kept, _ = json.JSONDecoder().raw_decode(rest[rest.index("[") :])
72
+ requests += [str(r) for r in kept]
73
+ except ValueError:
74
+ pass
75
+ continue
76
+ if content.startswith("[lcode]"):
77
+ continue # lcode's own nudges
78
+ text = request_text(content)
79
+ if text:
80
+ requests.append(text)
81
+ return requests
82
+
83
+
84
+ def requests_block(requests: list[str]) -> str:
85
+ """The user's latest requests, word for word, to keep the goal through a summary."""
86
+ if not requests:
87
+ return ""
88
+ kept = [r if len(r) <= MAX_REQUEST_CHARS else f"{r[: MAX_REQUEST_CHARS - 200]} […] {r[-150:]}" for r in requests]
89
+ older = len(kept) - MAX_REQUESTS
90
+ note = f" (and {older} earlier, in the summary)" if older > 0 else ""
91
+ return f"\n\n{REQUESTS_HEADER}{note}\n{json.dumps(kept[-MAX_REQUESTS:], ensure_ascii=False, indent=1)}"
92
+
93
+
40
94
  # ----------------------------------------------------------------------------- pruning
41
95
 
42
96
 
@@ -63,7 +117,7 @@ def stub(name: str, args: dict, content: str) -> str:
63
117
  if args.get("offset") or args.get("limit"):
64
118
  start = int(args.get("offset") or 1)
65
119
  span = f" from line {start}"
66
- return f"{PRUNED}read {args.get('path', 'a file')}{span} earlier; removed to save context. Read it again if you need it.]"
120
+ return f"{PRUNED}read {args.get('path', 'a file')}{span} {GONE}"
67
121
  if name == "bash":
68
122
  command = " ".join(str(args.get("command", "")).split())
69
123
  command = command if len(command) <= 80 else command[:79] + "…"
@@ -92,8 +146,9 @@ def boundary(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
92
146
  return requests[-keep] if len(requests) >= keep else 0
93
147
 
94
148
 
95
- def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
96
- """Replace stale tool results with stubs, in place. Returns the number of characters saved."""
149
+ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS, removed: list[str] | None = None) -> int:
150
+ """Replace stale tool results with stubs, in place. Returns the number of characters saved; the files and
151
+ pages whose only copy went are added to `removed`."""
97
152
  calls = calls_for_results(messages)
98
153
  edge = boundary(messages, keep)
99
154
  # A read is superseded by a later read of the whole file, or of the same lines: not by a later
@@ -118,12 +173,34 @@ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
118
173
  continue
119
174
  new = stub(name, args, content)
120
175
  if reread:
121
- new = new.replace("earlier; removed", "earlier (read again later); removed")
176
+ new = new.replace(
177
+ GONE, "earlier (read again later); removed to save context. Read it again if you need it.]"
178
+ )
179
+ elif removed is not None and name in ("read_file", "web_fetch"):
180
+ what = str(args.get("path") or args.get("url") or "")
181
+ if what and what not in removed:
182
+ removed.append(what)
122
183
  messages[i]["content"] = new
123
184
  saved += len(content) - len(new)
124
185
  return saved
125
186
 
126
187
 
188
+ def note_removed(messages: list[dict], removed: list[str]) -> None:
189
+ """Tell the model, next to the request it's working on, which files it no longer has: a stub deep in the
190
+ conversation is easy to overlook, and models then answer from memory instead of reading again."""
191
+ if not removed:
192
+ return
193
+ for message in reversed(messages):
194
+ content = str(message.get("content", ""))
195
+ if message.get("role") == "user" and not content.startswith(("[lcode]", SUMMARY_PREFIX)):
196
+ names = ", ".join(removed[:10]) + (f" and {len(removed) - 10} more" if len(removed) > 10 else "")
197
+ message["content"] = (
198
+ f"{content}\n\n[lcode] To make room, lcode removed what you read earlier from: {names}. It isn't in "
199
+ "this conversation any more: read it again before answering anything about it, don't answer from memory."
200
+ )
201
+ return
202
+
203
+
127
204
  # ----------------------------------------------------------------------------- long output
128
205
 
129
206
 
@@ -242,7 +242,8 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
242
242
  c.print(
243
243
  f"In use: {format_tokens(agent.ctx_used)} of {format_tokens(s.context)} tokens "
244
244
  f"({100 * agent.ctx_used / s.context:.0f}%) in {len(agent.messages)} message"
245
- f"{'' if len(agent.messages) == 1 else 's'}; auto-compacts at 85%."
245
+ f"{'' if len(agent.messages) == 1 else 's'}; makes room at {format_tokens(agent.compact_limit())} "
246
+ + ("(compact_at)." if agent.compact_limit() < AUTO_COMPACT_RATIO * s.context else "(85%).")
246
247
  )
247
248
  if agent.mcp and agent.mcp.ready():
248
249
  how = "found on demand" if agent.mcp.searching(s.context) else "sent with every request"
@@ -1,3 +1,5 @@
1
+ import pytest
2
+
1
3
  from conftest import output, reply
2
4
  from lcode import context
3
5
  from lcode.hardware import Hardware
@@ -159,3 +161,87 @@ def test_context_shows_what_uses_it(make_agent, repo, monkeypatch):
159
161
  monkeypatch.setattr("builtins.input", lambda prompt="": "") # keep the window size
160
162
  handle_command(agent, "/context", HW)
161
163
  assert "What uses the context" in output(agent) and "tool results: read_file" in output(agent)
164
+
165
+
166
+ def test_a_large_window_is_summarized_at_the_ceiling(make_agent):
167
+ assert make_agent(context=262144, compact_at=65536).compact_limit() == 65536
168
+ assert make_agent(context=262144, compact_at=0).compact_limit() == int(0.85 * 262144)
169
+ assert make_agent(context=32768, compact_at=65536).compact_limit() == int(0.85 * 32768)
170
+ agent = make_agent([reply("Summary.")], context=262144, compact_at=8192)
171
+ agent.messages = conversation()
172
+ agent.ctx_used = 9000 # 3% of the window, but past the ceiling
173
+ agent.maybe_compact()
174
+ assert agent.pruned == 1 and agent.compacted == 1
175
+ assert "The conversation reached 8K tokens (compact_at)" in output(agent)
176
+ agent.ctx_used = 5000
177
+ agent.maybe_compact() # under the ceiling again: nothing to do
178
+ assert agent.compacted == 1
179
+
180
+
181
+ def test_summaries_keep_the_users_requests_word_for_word(make_agent):
182
+ agent = make_agent([reply("Summary one."), reply("ok"), reply("Summary two.")], context=16384)
183
+ agent.messages = conversation()
184
+ agent.messages[1]["content"] = 'make it 2x3\n\n<file path="config.json">\n{"grid": "3x3"}\n</file>'
185
+ agent.compact()
186
+ summary = agent.messages[1]["content"]
187
+ assert summary.startswith("[Summary of our conversation so far]\n\nSummary one.")
188
+ assert context.REQUESTS_HEADER in summary and '"make it 2x3"' in summary and "config.json" not in summary
189
+ agent.run_turn("now commit it")
190
+ agent.compact()
191
+ assert context.user_requests(agent.messages)[:1] == ["make it 2x3"]
192
+ assert context.user_requests(agent.messages)[-1] == "now commit it"
193
+ assert agent.messages[1]["content"].count(context.REQUESTS_HEADER) == 1
194
+
195
+
196
+ def test_only_the_latest_requests_are_kept(make_agent):
197
+ requests = [f"request {n}" for n in range(12)] + ["x" * 5000]
198
+ block = context.requests_block(requests)
199
+ assert "(and 5 earlier, in the summary)" in block and '"request 4"' not in block and '"request 5"' in block
200
+ assert "[…]" in block and len(block) < 3000
201
+
202
+
203
+ def test_context_shows_when_it_makes_room(make_agent):
204
+ agent = make_agent(context=262144, compact_at=65536)
205
+ handle_command(agent, "/context 262144", HW)
206
+ assert "makes room at 64K (compact_at)" in output(agent)
207
+
208
+
209
+ def test_a_fresh_summary_is_not_summarized_again_right_away(make_agent):
210
+ agent = make_agent([reply("Summary."), reply("Summary again.")], context=262144, compact_at=16384)
211
+ agent.messages = conversation()
212
+ agent.ctx_used = 17000
213
+ agent.maybe_compact()
214
+ assert agent.compacted == 1
215
+ agent.summarized_size = 15000 # the summary and the requests it keeps are large
216
+ agent.ctx_used = 17000 # past the ceiling, but it grew only 2K since
217
+ agent.maybe_compact()
218
+ assert agent.compacted == 1
219
+ agent.ctx_used = 15000 + 16384 // 4 + 1 # it grew by a quarter of the ceiling: summarize again
220
+ agent.maybe_compact()
221
+ assert agent.compacted == 2
222
+ agent.summarized_size, agent.ctx_used = 200000, 230000 # the window itself is full: always
223
+ agent.ollama.scripts.append(reply("Summary three."))
224
+ agent.maybe_compact()
225
+ assert agent.compacted == 3
226
+
227
+
228
+ def test_compact_at_has_a_floor():
229
+ from lcode import config
230
+ from lcode.config import ConfigError
231
+
232
+ assert config.coerce("compact_at", "64k") == 65536 and config.coerce("compact_at", "off") == 0
233
+ with pytest.raises(ConfigError, match="at least 16k"):
234
+ config.coerce("compact_at", "8k")
235
+
236
+
237
+ def test_the_model_is_told_next_to_its_request_what_it_no_longer_has(make_agent):
238
+ agent = make_agent([reply("ok")], context=16384)
239
+ agent.messages = conversation(BIG * 10)
240
+ agent.messages[12]["tool_calls"] = [tool("read_file", path="c.py"), tool("read_file", path="b.py")]
241
+ agent.ctx_used = int(0.9 * 16384)
242
+ agent.maybe_compact()
243
+ assert agent.messages[11]["content"].startswith(
244
+ "third request\n\n[lcode] To make room, lcode removed what you read earlier from: a.py. It isn't in this "
245
+ "conversation any more: read it again"
246
+ )
247
+ assert context.user_requests(agent.messages)[-1] == "third request"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes