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.
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/CHANGELOG.md +49 -1
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/PKG-INFO +1 -1
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/bench.md +12 -1
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/configuration.md +1 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/how-it-works.md +17 -6
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/usage.md +27 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/agent.py +160 -24
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/api.py +2 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/cli.py +15 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/config.py +16 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/context.py +81 -4
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/repl.py +26 -5
- lcode_cli-0.28.0/src/lcode/residency.py +167 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/tools.py +2 -3
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/conftest.py +6 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_agent.py +4 -3
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_context.py +86 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_mcp.py +2 -1
- lcode_cli-0.28.0/tests/test_residency.py +164 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_vision.py +5 -4
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.editorconfig +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.gitignore +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/AGENTS.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/LICENSE +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/README.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/SECURITY.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/action.yml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/agents.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/automation.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/changelog.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/commands.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/contributing.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/editors.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/git.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/hooks.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/index.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/installation.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/lsp.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/mcp.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/memory.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/models.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/search.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/servers.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/examples/README.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/install.sh +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/mkdocs.yml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/overrides/main.html +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/pyproject.toml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/acp.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/repeats.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_action.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_api.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_config.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_repeats.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_update.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_web.py +0 -0
- {lcode_cli-0.26.0 → lcode_cli-0.28.0}/tests/test_xai.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
77
|
-
|
|
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;
|
|
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
|
-
|
|
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)
|
|
@@ -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,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
|
|
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 = (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
625
|
-
f"
|
|
626
|
-
f"{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.
|
|
637
|
-
f"
|
|
638
|
-
|
|
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}\
|
|
645
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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 <=
|
|
1185
|
+
if self.ctx_used <= enough:
|
|
1064
1186
|
return
|
|
1065
|
-
self.
|
|
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
|
-
{
|
|
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":
|