lcode-cli 0.11.1__tar.gz → 0.12.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 (115) hide show
  1. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/CHANGELOG.md +24 -1
  2. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/PKG-INFO +1 -2
  3. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/README.md +0 -1
  4. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/bench.md +29 -0
  5. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/configuration.md +2 -0
  6. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/how-it-works.md +21 -5
  7. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/quickstart.md +1 -1
  8. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/usage.md +1 -1
  9. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/pyproject.toml +1 -0
  10. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/__init__.py +1 -1
  11. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/agent.py +22 -3
  12. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/bench.py +44 -11
  13. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/cli.py +15 -2
  14. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/config.py +1 -0
  15. lcode_cli-0.12.0/src/lcode/context.py +243 -0
  16. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/repl.py +24 -0
  17. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/tools.py +8 -2
  18. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_bench.py +25 -0
  19. lcode_cli-0.12.0/tests/test_context.py +161 -0
  20. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.editorconfig +0 -0
  21. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.github/CODEOWNERS +0 -0
  22. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  23. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  24. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  25. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  26. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.github/dependabot.yml +0 -0
  28. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.github/workflows/ci.yml +0 -0
  29. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.github/workflows/docs.yml +0 -0
  30. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.github/workflows/release.yml +0 -0
  31. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.gitignore +0 -0
  32. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/.pre-commit-config.yaml +0 -0
  33. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/AGENTS.md +0 -0
  34. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/CODE_OF_CONDUCT.md +0 -0
  35. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/CONTRIBUTING.md +0 -0
  36. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/LICENSE +0 -0
  37. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/SECURITY.md +0 -0
  38. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/agents.md +0 -0
  39. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/assets/demo.svg +0 -0
  40. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/assets/extra.css +0 -0
  41. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/assets/logo.svg +0 -0
  42. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/assets/social-preview.png +0 -0
  43. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/changelog.md +0 -0
  44. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/commands.md +0 -0
  45. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/contributing.md +0 -0
  46. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/index.md +0 -0
  47. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/installation.md +0 -0
  48. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/mcp.md +0 -0
  49. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/memory.md +0 -0
  50. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/models.md +0 -0
  51. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/sandbox.md +0 -0
  52. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/docs/troubleshooting.md +0 -0
  53. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/examples/README.md +0 -0
  54. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/examples/commands/review.md +0 -0
  55. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/examples/skills/commit-message/SKILL.md +0 -0
  56. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  57. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/install.sh +0 -0
  58. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/mkdocs.yml +0 -0
  59. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/overrides/main.html +0 -0
  60. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/scripts/record_demo.py +0 -0
  61. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/scripts/social-preview.html +0 -0
  62. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/__main__.py +0 -0
  63. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/catalog.py +0 -0
  64. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/checkpoints.py +0 -0
  65. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/extensions.py +0 -0
  66. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/frontmatter.py +0 -0
  67. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/hardware.py +0 -0
  68. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/limits.py +0 -0
  69. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/__init__.py +0 -0
  70. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/auth.py +0 -0
  71. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/catalog.py +0 -0
  72. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/catalog.toml +0 -0
  73. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/client.py +0 -0
  74. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/commands.py +0 -0
  75. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/config.py +0 -0
  76. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/http.py +0 -0
  77. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/manager.py +0 -0
  78. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/protocol.py +0 -0
  79. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/server.py +0 -0
  80. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/mcp/stdio.py +0 -0
  81. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/memory.py +0 -0
  82. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/models.toml +0 -0
  83. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/ollama.py +0 -0
  84. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/permissions.py +0 -0
  85. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/planning.py +0 -0
  86. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/render.py +0 -0
  87. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/sandbox.py +0 -0
  88. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/servers/__init__.py +0 -0
  89. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/servers/encord.py +0 -0
  90. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/servers/valohai.py +0 -0
  91. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/sessions.py +0 -0
  92. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/subagents.py +0 -0
  93. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/vision.py +0 -0
  94. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/src/lcode/web.py +0 -0
  95. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/conftest.py +0 -0
  96. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/fake_mcp_server.py +0 -0
  97. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_agent.py +0 -0
  98. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_catalog.py +0 -0
  99. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_checkpoints.py +0 -0
  100. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_cli.py +0 -0
  101. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_config.py +0 -0
  102. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_context_picker.py +0 -0
  103. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_extensions.py +0 -0
  104. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_mcp.py +0 -0
  105. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_memory.py +0 -0
  106. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_permissions.py +0 -0
  107. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_plan_mode.py +0 -0
  108. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_sandbox.py +0 -0
  109. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_servers.py +0 -0
  110. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_sessions.py +0 -0
  111. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_subagents.py +0 -0
  112. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_tools.py +0 -0
  113. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_vision.py +0 -0
  114. {lcode_cli-0.11.1 → lcode_cli-0.12.0}/tests/test_web.py +0 -0
  115. {lcode_cli-0.11.1 → lcode_cli-0.12.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.12.0] - 2026-10-05
10
+
11
+ ### Added
12
+
13
+ - `/context` shows what uses the context window, by category: the system prompt and its parts
14
+ (`AGENTS.md`, memory notes, skills, MCP servers), tool definitions, your messages, attached files,
15
+ the model's replies and each tool's results.
16
+ - `lcode bench --session` runs all tasks in one conversation, to test long sessions and context
17
+ management; `--rounds N` repeats the tasks and `--no-prune` turns the removal of old tool output
18
+ off, to compare.
19
+
20
+ ### Changed
21
+
22
+ - Smarter context management, which matters most at 32K. When the window is 85% full, lcode first
23
+ replaces old tool output (file contents, command output, search results from before your last
24
+ two requests, and earlier copies of files that were read again) with one-line notes saying what
25
+ was there, then the output of your previous request if needed; it summarizes the conversation
26
+ only if that doesn't free enough room. Measured on 24-task sessions at 32K: fewer summaries, with
27
+ about as many tasks solved. The `prune` setting turns it off.
28
+ - Long command and MCP output keeps its beginning, its end and the lines that look like errors;
29
+ the full output is saved to a file the model can read.
30
+
9
31
  ## [0.11.1] - 2026-10-05
10
32
 
11
33
  ### Fixed
@@ -311,7 +333,8 @@ First public release.
311
333
  - `AGENTS.md` project instructions and `/init` to generate them.
312
334
  - One-line installer for Linux and macOS.
313
335
 
314
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.11.1...HEAD
336
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.12.0...HEAD
337
+ [0.12.0]: https://github.com/nasser1941/lcode/compare/v0.11.1...v0.12.0
315
338
  [0.11.1]: https://github.com/nasser1941/lcode/compare/v0.11.0...v0.11.1
316
339
  [0.11.0]: https://github.com/nasser1941/lcode/compare/v0.10.0...v0.11.0
317
340
  [0.10.0]: https://github.com/nasser1941/lcode/compare/v0.9.0...v0.10.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.11.1
3
+ Version: 0.12.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/
@@ -201,7 +201,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
201
201
 
202
202
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
203
203
 
204
- - [Smarter context for small windows](https://github.com/nasser1941/lcode/issues/46)
205
204
  - [Code intelligence with language servers](https://github.com/nasser1941/lcode/issues/44)
206
205
  - [A repository map and semantic search with local embeddings](https://github.com/nasser1941/lcode/issues/45)
207
206
  - [Hooks and permission rules](https://github.com/nasser1941/lcode/issues/43)
@@ -165,7 +165,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
165
165
 
166
166
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
167
167
 
168
- - [Smarter context for small windows](https://github.com/nasser1941/lcode/issues/46)
169
168
  - [Code intelligence with language servers](https://github.com/nasser1941/lcode/issues/44)
170
169
  - [A repository map and semantic search with local embeddings](https://github.com/nasser1941/lcode/issues/45)
171
170
  - [Hooks and permission rules](https://github.com/nasser1941/lcode/issues/43)
@@ -31,6 +31,35 @@ At the end it prints a table comparing the models (see [results](#results-on-a-1
31
31
 
32
32
  A model usually takes 4 to 12 minutes; each task stops after 5 minutes (`--timeout`).
33
33
 
34
+ ### Long sessions
35
+
36
+ Each task normally starts a new conversation. `--session` runs them all in **one** conversation
37
+ instead, as a long working session would, so the context fills up and lcode's
38
+ [context management](how-it-works.md#context-management) has to work: the summary shows how often
39
+ it removed old tool output and how often it summarized the conversation. `--no-prune` turns the
40
+ removal off, to compare.
41
+
42
+ ```bash
43
+ lcode bench --context 32k --session --rounds 3
44
+ lcode bench --context 32k --session --rounds 3 --no-prune
45
+ ```
46
+
47
+ `--rounds 3` runs the tasks three times, 24 in one conversation, which fills a 32K window several
48
+ times over. With qwen3.6-35b on a 12 GB laptop (two runs each; runs vary a lot from one to the
49
+ next):
50
+
51
+ | | Removing old tool output first | Summarizing only (`--no-prune`) |
52
+ |---|---|---|
53
+ | Conversation summarized | 1 and 2 times | 1 and 3 times |
54
+ | Old tool output removed | 3 times in each run | — |
55
+ | Tasks solved | 21 and 22 of 24 | 23 and 21 of 24 |
56
+ | Time | 26 and 28 min | 21 and 30 min |
57
+
58
+ Removing old tool output means fewer summaries, so more of the conversation stays word for word,
59
+ at no measurable cost in tasks solved or time. The bench tasks are independent of each other,
60
+ which is where summaries hurt least; in a real session, where later requests build on earlier
61
+ ones, keeping the conversation intact matters more.
62
+
34
63
  ## The tasks
35
64
 
36
65
  Every task runs in a new temporary folder, with every permission granted (`yolo` mode) and web
@@ -32,6 +32,7 @@ lcode config path # print the file location
32
32
  | `mcp_tools` | `auto` | How [MCP](mcp.md#context) tool definitions reach the model: `auto`, `direct` or `search` (on demand) |
33
33
  | `memory` | `ask` | [Notes that carry over](memory.md) to later sessions: `ask` (confirm each), `auto` or `off` |
34
34
  | `skills` | `all` | Which [skills](commands.md#skills) the model is offered: `all` (lcode's folders and the ones other agents share), `lcode` (only `.lcode/skills/` and `~/.config/lcode/skills/`) or `off` |
35
+ | `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)) |
35
36
  | `subagents` | `true` | Let the model hand tasks to [subagents](agents.md) with their own context |
36
37
  | `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)) |
37
38
  | `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
@@ -78,6 +79,7 @@ Environment variables override the file, which is useful for one-off runs and CI
78
79
  | `~/.config/lcode/commands/`, `~/.config/lcode/skills/` | Your [commands and skills](commands.md) (a repository's go in `.lcode/`) |
79
80
  | `~/.local/state/lcode/extensions-approved.json` | Repositories whose commands, skills and agents you approved |
80
81
  | `~/.config/lcode/agents/` | Your [custom agents](agents.md#custom-agents) (a repository's go in `.lcode/agents/`) |
82
+ | `~/.local/state/lcode/outputs/` | The full text of long command output that was cut short for the model (kept a week) |
81
83
  | `~/.local/state/lcode/limits.json` | Context sizes that ran out of GPU memory on this machine (safe to delete) |
82
84
 
83
85
  Sessions contain everything the model read, including file contents. Delete the folder to clear them.
@@ -41,11 +41,27 @@ in a temporary git worktree, and their changes come back as a diff.
41
41
  ## Context management
42
42
 
43
43
  Ollama keeps the processed conversation in its KV cache, so each turn only processes the new tokens.
44
- lcode tracks how full the window is from Ollama's token counts. At 85% it asks the model to write a
45
- summary of the conversation (goals, findings, files changed, next steps) and continues from that
46
- summary. Just before that, it asks whether anything from the conversation is worth
47
- [remembering](memory.md), since the summary leaves details out. Tool outputs are capped at 30,000
48
- characters (head and tail kept) so one noisy command can't flood the window.
44
+ lcode tracks how full the window is from Ollama's token counts. Most of a long session's context is
45
+ old tool output, so when the window is 85% full:
46
+
47
+ 1. **lcode removes old tool output first.** File contents, command output and search results from
48
+ before your last two requests become one-line notes that say what was there, such as
49
+ `[lcode: read src/app.py earlier; removed to save context. Read it again if you need it.]`.
50
+ An earlier copy of a file that was read again later goes too, even in recent requests. Subagent
51
+ reports, skill instructions and the approved plan stay.
52
+ 2. **If that brings the conversation under 70%,** the work simply continues: nothing is summarized,
53
+ and the conversation itself stays word for word.
54
+ If it's not, lcode also removes the tool output of your previous request, keeping only the
55
+ latest one's. (`lcode config set prune false` skips this step.)
56
+ 3. **Otherwise lcode summarizes.** It asks whether anything is worth [remembering](memory.md), since
57
+ the summary leaves details out, then has the model write a summary of the conversation (goals,
58
+ findings, files changed, next steps) and continues from it.
59
+
60
+ Long tool output (over 30,000 characters) keeps its beginning, its end and the lines in between
61
+ that look like errors. The full output is saved under `~/.local/state/lcode/outputs/` (for a week)
62
+ and the model is told where, so it can read the rest if it needs to. `/context` shows what's using
63
+ the window, by category: the system prompt and its parts, tool definitions, your messages, attached
64
+ files, the model's replies and each tool's results.
49
65
 
50
66
  ## Memory sizing
51
67
 
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.11.1 — local coding agent │
15
+ │ lcode v0.12.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -67,7 +67,7 @@ reasoning is on.
67
67
  | `/mcp [tools\|login\|restart NAME]` | [MCP servers](mcp.md), their status and tools |
68
68
  | `/plan [request]` | Work out a [plan](#plan-mode) before changing anything, or show the approved plan |
69
69
  | `/compact [focus]` | Summarize the conversation to free context |
70
- | `/context [size]` | Show how full the context window is and change its size: pick from a list with memory estimates, or give a size like `/context 128k` (`/ctx` is a shortcut) |
70
+ | `/context [size]` | Show how full the context window is, what's using it (by category), and change its size: pick from a list with memory estimates, or give a size like `/context 128k` (`/ctx` is a shortcut) |
71
71
  | `/model [name]`, `/models` | Switch model, list models |
72
72
  | `/think [on\|off]` | Toggle reasoning |
73
73
  | `/verbose` | Toggle showing the reasoning text |
@@ -78,6 +78,7 @@ ignore = [
78
78
  "src/lcode/subagents.py" = ["E501"]
79
79
  "src/lcode/planning.py" = ["E501"]
80
80
  "src/lcode/extensions.py" = ["E501"]
81
+ "src/lcode/context.py" = ["E501"]
81
82
 
82
83
  [tool.pytest.ini_options]
83
84
  testpaths = ["tests"]
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.11.1"
3
+ __version__ = "0.12.0"
@@ -22,6 +22,7 @@ from rich.panel import Panel
22
22
  from rich.text import Text
23
23
 
24
24
  from lcode import catalog, extensions, limits, planning, sessions, subagents, vision, web
25
+ from lcode import context as context_tools
25
26
  from lcode import memory as memory_notes
26
27
  from lcode.checkpoints import Checkpoints
27
28
  from lcode.config import format_tokens
@@ -50,6 +51,7 @@ SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
50
51
  MAX_STEPS_PER_TURN = 150
51
52
  MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON # safety cap on tool-call iterations for one request
52
53
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
54
+ PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
53
55
  PROJECT_FILES = ("AGENTS.md", "LCODE.md", "CLAUDE.md")
54
56
 
55
57
  SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent running in the user's terminal on their own machine, powered by the local model {model}. You help the user understand codebases, answer questions about code, write scripts (Python by default), fix bugs, refactor, and run commands.
@@ -173,6 +175,7 @@ class Settings:
173
175
  subagents: bool = False # the agent tool (lcode.subagents)
174
176
  trust_project: bool = False # use the repository's own commands, skills and agents (lcode.extensions)
175
177
  skills: str = "off" # all, lcode (only lcode's own skill folders) or off
178
+ prune: bool = True # remove old tool output before summarizing (lcode.context)
176
179
  max_parallel_agents: int = 1
177
180
 
178
181
 
@@ -202,6 +205,7 @@ class Agent:
202
205
  self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
203
206
  self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
204
207
  self.plan = "" # the plan the user approved; kept through compaction
208
+ self.pruned = self.compacted = 0 # how often the context was pruned or summarized automatically
205
209
  self._extensions: tuple[Path, extensions.Extensions] | None = None
206
210
  self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation
207
211
  # Set on subagents (see lcode.subagents):
@@ -775,9 +779,24 @@ class Agent:
775
779
 
776
780
  # -- context management
777
781
  def maybe_compact(self) -> None:
778
- if self.ctx_used > AUTO_COMPACT_RATIO * self.settings.context:
779
- self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
780
- self.compact()
782
+ if self.ctx_used <= AUTO_COMPACT_RATIO * self.settings.context:
783
+ return
784
+ if self.settings.prune:
785
+ saved = context_tools.prune(self.messages) # tool output from before the last two requests
786
+ if self.ctx_used - saved // 3 > PRUNED_ENOUGH * self.settings.context:
787
+ saved += context_tools.prune(self.messages, keep=1) # not enough: before the last request
788
+ if saved:
789
+ self.pruned += 1
790
+ self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
791
+ self.console.print(
792
+ f"[dim]Removed old tool output from the conversation (~{format_tokens(saved // 3)} tokens) to "
793
+ "make room.[/]"
794
+ )
795
+ if self.ctx_used <= PRUNED_ENOUGH * self.settings.context:
796
+ return
797
+ self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
798
+ self.compacted += 1
799
+ self.compact()
781
800
 
782
801
  def compact(self, focus: str = "") -> None:
783
802
  if len(self.messages) <= 2:
@@ -784,6 +784,9 @@ class ModelRun:
784
784
  tasks: list[TaskResult] = field(default_factory=list)
785
785
  error: str | None = None # the model couldn't run at all
786
786
  interrupted: bool = False
787
+ session: bool = False # all tasks in one conversation (--session)
788
+ pruned: int = 0 # times old tool output was removed to make room
789
+ compacted: int = 0 # times the conversation was summarized
787
790
 
788
791
  @property
789
792
  def passed(self) -> int:
@@ -847,14 +850,28 @@ def count_tool_errors(messages: list[dict]) -> int:
847
850
 
848
851
 
849
852
  def run_task(
850
- ollama: Ollama, settings: Settings, task: Task, timeout: float, log: Console, keep: bool, usage: dict
853
+ ollama: Ollama,
854
+ settings: Settings,
855
+ task: Task,
856
+ timeout: float,
857
+ log: Console,
858
+ keep: bool,
859
+ usage: dict,
860
+ agent: Agent | None = None,
851
861
  ) -> TaskResult:
862
+ """Run one task in a new folder: in a new conversation, or continuing `agent`'s (--session)."""
852
863
  folder = Path(tempfile.mkdtemp(prefix=f"lcode-bench-{task.id}-"))
853
864
  try:
854
865
  for rel, content in task.files.items():
855
866
  (folder / rel).parent.mkdir(parents=True, exist_ok=True)
856
867
  (folder / rel).write_text(content)
857
- agent = Agent(ollama, settings, folder, console=log)
868
+ if agent is None:
869
+ agent = Agent(ollama, settings, folder, console=log)
870
+ else: # the same conversation moves on to this task's folder, as with /cd
871
+ agent.cwd = folder.resolve()
872
+ agent.messages[0]["content"] = agent.system_prompt()
873
+ before = dict(agent.usage)
874
+ first = len(agent.messages)
858
875
  error = None
859
876
  started = time.monotonic()
860
877
  try:
@@ -866,7 +883,7 @@ def run_task(
866
883
  error = str(e).splitlines()[0][:200]
867
884
  seconds = time.monotonic() - started
868
885
  for key, value in agent.usage.items():
869
- usage[key] = usage.get(key, 0) + value
886
+ usage[key] = usage.get(key, 0) + value - before.get(key, 0)
870
887
  answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
871
888
  try:
872
889
  check = task.check(folder, answer)
@@ -882,8 +899,8 @@ def run_task(
882
899
  passed=check.passed and error is None,
883
900
  seconds=round(seconds, 1),
884
901
  detail=error or check.detail,
885
- steps=sum(1 for m in agent.messages if m.get("role") == "assistant"),
886
- tool_errors=count_tool_errors(agent.messages),
902
+ steps=agent.usage["requests"] - before.get("requests", 0),
903
+ tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
887
904
  error=error,
888
905
  )
889
906
 
@@ -940,8 +957,10 @@ def run_model(
940
957
  timeout: float = DEFAULT_TIMEOUT,
941
958
  keep: bool = False,
942
959
  verbose: bool = False,
960
+ session: bool = False,
943
961
  ) -> ModelRun:
944
- run = ModelRun(name, settings.model, settings.context, settings.num_batch, settings.think)
962
+ run = ModelRun(name, settings.model, settings.context, settings.num_batch, settings.think, session=session)
963
+ shared: Agent | None = None # with --session, one conversation for all tasks
945
964
  console.print(
946
965
  f"\n[bold]{escape(settings.model)}[/] · {format_tokens(settings.context)} context · "
947
966
  f"reasoning {'on' if settings.think else 'off'} · {len(tasks)} task{'' if len(tasks) == 1 else 's'}"
@@ -969,13 +988,17 @@ def run_model(
969
988
  console.print(f" [dim]Loaded in {run.load_seconds:.0f}s{where}{reads}[/]")
970
989
  for task in tasks:
971
990
  log = console if verbose else Console(file=io.StringIO(), width=120, force_terminal=False)
991
+ if session and shared is None:
992
+ shared = Agent(ollama, settings, Path(tempfile.gettempdir()), console=log)
993
+ if shared is not None:
994
+ shared.console = log
972
995
  try:
973
996
  if verbose:
974
997
  console.rule(f"{task.id}: {task.title}")
975
- result = run_task(ollama, settings, task, timeout, log, keep, run.usage)
998
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
976
999
  else:
977
1000
  with console.status(Elapsed(f" {task.id:<13} {task.title}")):
978
- result = run_task(ollama, settings, task, timeout, log, keep, run.usage)
1001
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
979
1002
  except KeyboardInterrupt:
980
1003
  console.print(" [yellow]Stopped.[/]")
981
1004
  run.interrupted = True
@@ -985,6 +1008,8 @@ def run_model(
985
1008
  console.print(
986
1009
  f" {mark} {task.id:<13} {task.title:<32} {result.seconds:>5.0f}s [dim]{escape(result.detail)}[/]"
987
1010
  )
1011
+ if shared is not None:
1012
+ run.pruned, run.compacted = shared.pruned, shared.compacted
988
1013
  if run.memory_gb is None:
989
1014
  run.memory_gb, run.gpu_percent = memory_use(ollama, settings.model)
990
1015
  # lcode lowers the batch size or context if the GPU runs out of memory; report what was used.
@@ -1001,11 +1026,12 @@ def duration(seconds: float) -> str:
1001
1026
 
1002
1027
  def summary_rows(runs: list[ModelRun], tasks: list[Task]) -> list[tuple[str, list[str]]]:
1003
1028
  rows = []
1004
- for task in tasks:
1029
+ for task in {t.id: t for t in tasks}.values(): # with --rounds a task runs several times: one row, a mark each
1005
1030
  cells = []
1006
1031
  for run in runs:
1007
- result = next((t for t in run.tasks if t.id == task.id), None)
1008
- cells.append("" if result is None else f"{'✓' if result.passed else '✗'} {duration(result.seconds)}")
1032
+ results = [t for t in run.tasks if t.id == task.id]
1033
+ marks = "".join("✓" if r.passed else "✗" for r in results)
1034
+ cells.append(f"{marks} {duration(sum(r.seconds for r in results))}" if results else "")
1009
1035
  rows.append((f"{task.id}: {task.title}", cells))
1010
1036
 
1011
1037
  def cell(run: ModelRun, value: str) -> str:
@@ -1023,6 +1049,13 @@ def summary_rows(runs: list[ModelRun], tasks: list[Task]) -> list[tuple[str, lis
1023
1049
  ),
1024
1050
  ("Context", [cell(r, format_tokens(r.context)) for r in runs]),
1025
1051
  ]
1052
+ if any(r.session for r in runs):
1053
+ rows.append(
1054
+ (
1055
+ "One conversation",
1056
+ [cell(r, f"pruned {r.pruned}×, summarized {r.compacted}×" if r.session else "no") for r in runs],
1057
+ )
1058
+ )
1026
1059
  return rows
1027
1060
 
1028
1061
 
@@ -436,7 +436,7 @@ def cmd_bench(args) -> None:
436
436
  try:
437
437
  cfg = config.load()
438
438
  context = parse_context(args.context) if args.context else bench.DEFAULT_CONTEXT
439
- tasks = bench.select_tasks(args.tasks)
439
+ tasks = bench.select_tasks(args.tasks) * max(1, args.rounds)
440
440
  except (ConfigError, ValueError) as e:
441
441
  fail(str(e))
442
442
  if args.timeout <= 0:
@@ -483,8 +483,11 @@ def cmd_bench(args) -> None:
483
483
  permission_mode="yolo",
484
484
  web="off",
485
485
  checkpoints=False,
486
+ prune=not args.no_prune,
487
+ )
488
+ run = bench.run_model(
489
+ ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose, args.session
486
490
  )
487
- run = bench.run_model(ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose)
488
491
  runs.append(run)
489
492
  if run.interrupted:
490
493
  break
@@ -566,6 +569,7 @@ def cmd_chat(args) -> None:
566
569
  max_parallel_agents=cfg["max_parallel_agents"],
567
570
  trust_project=trust_project,
568
571
  skills=cfg["skills"],
572
+ prune=cfg["prune"],
569
573
  )
570
574
  agent = Agent(ollama, settings, cwd, console=console)
571
575
  agent.interactive = not args.prompt
@@ -651,6 +655,15 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
651
655
  p.add_argument("--keep", action="store_true", help="keep each task's folder and transcript for inspection")
652
656
  p.add_argument("-v", "--verbose", action="store_true", help="show the model working, like a normal session")
653
657
  p.add_argument("--list", action="store_true", help="list the tasks and exit")
658
+ p.add_argument(
659
+ "--session",
660
+ action="store_true",
661
+ help="run all tasks in one conversation, to test a long session and its context management",
662
+ )
663
+ p.add_argument(
664
+ "--rounds", type=int, default=1, help="run the tasks this many times (with --session: a longer session)"
665
+ )
666
+ p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
654
667
  subs["bench"] = p
655
668
  from lcode.mcp.commands import build_parser as mcp_parser
656
669
 
@@ -49,6 +49,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
49
49
  "mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
50
50
  "memory": ("ask", str, "notes that carry over to later sessions: off | ask (confirm each) | auto"),
51
51
  "skills": ("all", str, "skills to offer the model: all (also other agents' folders) | lcode | off"),
52
+ "prune": (True, bool, "before summarizing a full conversation, first remove old tool output from it"),
52
53
  "subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
53
54
  "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
54
55
  }