lcode-cli 0.31.0__tar.gz → 0.33.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.31.0 → lcode_cli-0.33.0}/CHANGELOG.md +32 -1
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/PKG-INFO +1 -1
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/automation.md +1 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/configuration.md +1 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/usage.md +68 -3
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/pyproject.toml +1 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/agent.py +95 -5
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/api.py +3 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/cli.py +4 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/config.py +1 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/permissions.py +3 -1
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/repl.py +30 -4
- lcode_cli-0.33.0/src/lcode/review.py +103 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/sessions.py +3 -1
- lcode_cli-0.33.0/src/lcode/steer.py +287 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/tools.py +7 -0
- lcode_cli-0.33.0/tests/test_review.py +94 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_sessions.py +2 -0
- lcode_cli-0.33.0/tests/test_steer.py +372 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.editorconfig +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.gitignore +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/AGENTS.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/LICENSE +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/README.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/SECURITY.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/action.yml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/agents.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/bench.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/changelog.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/commands.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/contributing.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/editors.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/git.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/hooks.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/how-it-works.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/index.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/installation.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/lsp.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/mcp.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/memory.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/models.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/search.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/servers.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/examples/README.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/install.sh +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/mkdocs.yml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/overrides/main.html +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/acp.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/checks.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/matching.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/repeats.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/residency.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/conftest.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_action.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_api.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_checks.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_config.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_context.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_matching.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_repeats.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_residency.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_robustness.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_update.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_web.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.31.0 → lcode_cli-0.33.0}/uv.lock +0 -0
|
@@ -6,6 +6,35 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.33.0] - 2026-10-14
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Steer while it works. Type while a request runs and press Enter: the line goes to the model at
|
|
14
|
+
its next step, as a note from you, without interrupting anything. The spinner shows what you're
|
|
15
|
+
typing and the notes waiting. Esc pauses after the current step, so you can look, then go on with
|
|
16
|
+
or without a note. Notes count as your requests for summaries and the review, and anything that
|
|
17
|
+
never reached the model goes back into the prompt. A command that runs for a while now shows a
|
|
18
|
+
spinner with its elapsed time. See
|
|
19
|
+
[Steer while it works](https://nasser1941.github.io/lcode/usage/#steer-while-it-works).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- "Where you left off" (`lcode -c`) and session titles showed lcode's own messages to the model,
|
|
24
|
+
such as a failed check, as if you had written them. They show your last request now.
|
|
25
|
+
|
|
26
|
+
## [0.32.0] - 2026-10-14
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- A review before the answer. After a request that changed files, the model checks its change
|
|
31
|
+
against your request, from the diff and its own answer, and real problems (part of the request
|
|
32
|
+
not done, an untested claim, a leftover debug print) go back to it once. One line says what the
|
|
33
|
+
review found. It's off by default: measured with qwen3.6-35b on a 12 GB laptop, it made requests
|
|
34
|
+
73% slower for one real catch. Turn it on with `lcode config set review auto`. `lcode -p --output
|
|
35
|
+
json` reports it in `review`, and `lcode bench --review` compares. See
|
|
36
|
+
[A review before the answer](https://nasser1941.github.io/lcode/usage/#a-review-before-the-answer).
|
|
37
|
+
|
|
9
38
|
## [0.31.0] - 2026-10-14
|
|
10
39
|
|
|
11
40
|
### Added
|
|
@@ -679,7 +708,9 @@ First public release.
|
|
|
679
708
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
680
709
|
- One-line installer for Linux and macOS.
|
|
681
710
|
|
|
682
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
711
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.33.0...HEAD
|
|
712
|
+
[0.33.0]: https://github.com/nasser1941/lcode/compare/v0.32.0...v0.33.0
|
|
713
|
+
[0.32.0]: https://github.com/nasser1941/lcode/compare/v0.31.0...v0.32.0
|
|
683
714
|
[0.31.0]: https://github.com/nasser1941/lcode/compare/v0.30.0...v0.31.0
|
|
684
715
|
[0.30.0]: https://github.com/nasser1941/lcode/compare/v0.29.0...v0.30.0
|
|
685
716
|
[0.29.0]: https://github.com/nasser1941/lcode/compare/v0.28.1...v0.29.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.33.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/
|
|
@@ -55,6 +55,7 @@ went: `0` success, `1` an error, `3` stopped at `--max-steps`, `4` stopped becau
|
|
|
55
55
|
| `tool_calls` | Every tool call, with its arguments and output (the first 4,000 characters). `error` is true when the call failed or wasn't allowed |
|
|
56
56
|
| `files_changed` | Files the request added (`A`), modified (`M`) or deleted (`D`), shell commands included |
|
|
57
57
|
| `usage` | Model requests and tokens; `prompt_tokens` includes tokens served from the prompt cache |
|
|
58
|
+
| `review` | What the [review](usage.md#a-review-before-the-answer) sent back to the model (`[]` when it found nothing; `null` when no review ran) |
|
|
58
59
|
| `checks` | The [checks](usage.md#checks-before-done) that ran after the change: `command`, `exit_code` (`null` when they weren't allowed to run) and `by` (`lcode`, or `model` when the model ran them itself) |
|
|
59
60
|
| `error` | What went wrong, when `status` is `error` |
|
|
60
61
|
| `session_id` | Continue the conversation later with `lcode --resume <id>` |
|
|
@@ -43,6 +43,7 @@ lcode config path # print the file location
|
|
|
43
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)) |
|
|
44
44
|
| `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
|
|
45
45
|
| `notify_after` | `30` | Seconds a request runs before it notifies |
|
|
46
|
+
| `review` | `off` | Before the answer, [review a change](usage.md#a-review-before-the-answer) against the request and send problems back to the model: `auto` or `off` |
|
|
46
47
|
| `checks` | `auto` | Run the project's [checks](usage.md#checks-before-done) after a request changed files: `auto` (found from the project), `off`, or a command |
|
|
47
48
|
| `repeat_limit` | `3` | Identical tool calls with identical results before lcode [steps in](how-it-works.md#when-the-model-repeats-itself); `0` turns it off |
|
|
48
49
|
| `secret_check` | `true` | Before `git commit` and `git push`, look for passwords, keys and tokens they'd add ([details](usage.md#secrets-in-commits)) |
|
|
@@ -44,6 +44,8 @@ lcode update [--check] # update lcode, and Ollama when it runs in Docker (
|
|
|
44
44
|
| ++enter++ | Send |
|
|
45
45
|
| ++esc++ ++enter++ or a trailing `\` | New line |
|
|
46
46
|
| ++ctrl+c++ | Interrupt the model or a running command |
|
|
47
|
+
| Typing while it works | A note for the model, read at its next step ([Steer while it works](#steer-while-it-works)) |
|
|
48
|
+
| ++esc++ while it works | Pause after the current step |
|
|
47
49
|
| ++ctrl+d++ | Quit |
|
|
48
50
|
| ++shift+tab++ | Cycle permission mode: ask → plan → auto-edit → yolo |
|
|
49
51
|
| ++tab++ | Complete slash commands and `@` paths |
|
|
@@ -179,6 +181,32 @@ command = "uv run pytest -q && uv run ruff check ."
|
|
|
179
181
|
Or set it for yourself with `lcode config set checks "make check"`; `lcode config set checks off`
|
|
180
182
|
turns checks off.
|
|
181
183
|
|
|
184
|
+
## A review before the answer
|
|
185
|
+
|
|
186
|
+
Models claim more than they did: half of a two-part request done, "it works now" without having
|
|
187
|
+
checked, a debug print left behind. So after a request that changed files (and after the
|
|
188
|
+
[checks](#checks-before-done)), lcode asks the model to step back and review its change against your
|
|
189
|
+
request. It's told to judge only from what you asked, the diff, and its own answer:
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
◆ Review: the change does what was asked.
|
|
193
|
+
⚠ Review: 1 point(s) sent back to the model. First: README.md isn't changed, though …
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Problems it can point to go back to the model once; it fixes them, or says why a point is wrong.
|
|
197
|
+
`lcode -p --output json` reports what the review found in `review`.
|
|
198
|
+
|
|
199
|
+
**It's off by default,** because it's an extra model answer after every change. On the 12 GB laptop
|
|
200
|
+
with qwen3.6-35b, the 8 `lcode bench` tasks took 416 s with the review against 240 s without
|
|
201
|
+
(8/8 against 7/8, within run-to-run noise). It found a real gap in one run of `runtime-bug` ("it can't
|
|
202
|
+
be made smaller" left undone), which the model then argued away. Without reasoning, the review was
|
|
203
|
+
no faster and missed that gap every time, so it reasons like the model does. Turn it on with
|
|
204
|
+
`lcode config set review auto` when a stronger or faster model makes the extra look worth it.
|
|
205
|
+
|
|
206
|
+
The review is asked on top of the conversation rather than in a fresh one. The model server keeps
|
|
207
|
+
the processed conversation cached, and a separate review would push it out, so the next request
|
|
208
|
+
would have to read the whole conversation again. On a small GPU that's tens of seconds.
|
|
209
|
+
|
|
182
210
|
## Background commands
|
|
183
211
|
|
|
184
212
|
Some commands keep running: a dev server, a test watcher, a build in watch mode. The model starts
|
|
@@ -205,9 +233,10 @@ Much of the waiting with a local model isn't the model thinking: it's loading th
|
|
|
205
233
|
a long conversation before the first word. lcode says which, in the status line under a request:
|
|
206
234
|
|
|
207
235
|
```text
|
|
208
|
-
⠋ Loading qwen3.6-35b into memory… (0 tokens, 9s · Ctrl+C
|
|
209
|
-
⠋ Reading the conversation (18K tokens)… (0 tokens, 21s · Ctrl+C
|
|
210
|
-
⠋ Thinking… (312 tokens, 27s · Ctrl+C
|
|
236
|
+
⠋ Loading qwen3.6-35b into memory… (0 tokens, 9s · type a note · Esc pauses · Ctrl+C stops)
|
|
237
|
+
⠋ Reading the conversation (18K tokens)… (0 tokens, 21s · type a note · Esc pauses · Ctrl+C stops)
|
|
238
|
+
⠋ Thinking… (312 tokens, 27s · type a note · Esc pauses · Ctrl+C stops)
|
|
239
|
+
⠋ Running the command… (48s · type a note · Esc pauses · Ctrl+C stops)
|
|
211
240
|
```
|
|
212
241
|
|
|
213
242
|
It prints a line only when something worth knowing happens, and nothing while the model simply
|
|
@@ -226,6 +255,42 @@ While the model loads in the background at startup, the status bar says
|
|
|
226
255
|
part of them in system RAM. Dense ones slow down a lot, to a few tokens a second, which is why they
|
|
227
256
|
get the warning.
|
|
228
257
|
|
|
258
|
+
## Steer while it works
|
|
259
|
+
|
|
260
|
+
A request can take minutes with a local model. You don't have to stop it to change course: type
|
|
261
|
+
while it works, and press ++enter++. The line goes to the model at its next step, after the step it's
|
|
262
|
+
on finishes, as a note from you. Nothing is interrupted or thrown away.
|
|
263
|
+
|
|
264
|
+
```text
|
|
265
|
+
● edit_file(tally.py)
|
|
266
|
+
✓ Edited tally.py
|
|
267
|
+
✎ Note for the model: also add a --min-count N option that skips rare words
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
What you type shows under the spinner, since the terminal doesn't echo it while lcode works:
|
|
271
|
+
|
|
272
|
+
```text
|
|
273
|
+
⠋ Thinking… (212 tokens, 14s · type a note · Esc pauses · Ctrl+C stops)
|
|
274
|
+
✎ also add a --min-count N opt▏ Enter adds it as a note
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
- A note typed while the model writes its final answer still counts: the model reads it before the
|
|
278
|
+
request ends.
|
|
279
|
+
- Press ++esc++ to pause after the current step, to look at what it did. Then press ++enter++ to go
|
|
280
|
+
on, type a note to go on with it, or press ++ctrl+c++ to stop the request. Press ++esc++ again
|
|
281
|
+
before the step ends if you change your mind.
|
|
282
|
+
- Notes count as your requests: they're kept when old messages are
|
|
283
|
+
[summarized](how-it-works.md#context-management), and the [review](#a-review-before-the-answer)
|
|
284
|
+
checks the change against them too.
|
|
285
|
+
- When lcode asks you something, such as a permission, the keyboard is yours again for the answer.
|
|
286
|
+
Keys you typed just before the question appeared are dropped, so a note you were finishing can't
|
|
287
|
+
answer it by accident.
|
|
288
|
+
- What never reached the model, such as a note typed as the request ended or after ++ctrl+c++, is
|
|
289
|
+
put back in the prompt.
|
|
290
|
+
|
|
291
|
+
Steering works in the interactive session on macOS and Linux. `lcode -p` and the editor
|
|
292
|
+
integration don't read the keyboard during a request.
|
|
293
|
+
|
|
229
294
|
## Notifications
|
|
230
295
|
|
|
231
296
|
Local models take a while, so you'll often switch to something else during a request. lcode shows
|
|
@@ -78,6 +78,7 @@ ignore = [
|
|
|
78
78
|
"src/lcode/memory.py" = ["E501"]
|
|
79
79
|
"src/lcode/subagents.py" = ["E501"]
|
|
80
80
|
"src/lcode/planning.py" = ["E501"]
|
|
81
|
+
"src/lcode/review.py" = ["E501"] # the review prompt is prose
|
|
81
82
|
"src/lcode/extensions.py" = ["E501"]
|
|
82
83
|
"src/lcode/context.py" = ["E501"]
|
|
83
84
|
"src/lcode/lsp.py" = ["E501"]
|
|
@@ -35,6 +35,7 @@ from lcode import (
|
|
|
35
35
|
planning,
|
|
36
36
|
repomap,
|
|
37
37
|
residency,
|
|
38
|
+
review,
|
|
38
39
|
sessions,
|
|
39
40
|
subagents,
|
|
40
41
|
vision,
|
|
@@ -51,6 +52,7 @@ from lcode.permissions import Permissions
|
|
|
51
52
|
from lcode.render import MarkdownStreamer
|
|
52
53
|
from lcode.repeats import Repeats
|
|
53
54
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
55
|
+
from lcode.steer import NOTE_INTRO, Steering
|
|
54
56
|
from lcode.tools import (
|
|
55
57
|
LSP_SCHEMA,
|
|
56
58
|
MEMORY_SCHEMA,
|
|
@@ -223,10 +225,12 @@ class Progress:
|
|
|
223
225
|
arrives, for example while the model writes a long file into a tool call.
|
|
224
226
|
"""
|
|
225
227
|
|
|
226
|
-
def __init__(self, phase: str = "Thinking") -> None:
|
|
228
|
+
def __init__(self, phase: str = "Thinking", steering: Steering | None = None, tokens: bool = True) -> None:
|
|
227
229
|
self.started = self.last_chunk = time.time()
|
|
228
230
|
self.phase = phase
|
|
229
231
|
self.chars = 0
|
|
232
|
+
self.steering = steering # the note being typed and the notes waiting, shown on a second line
|
|
233
|
+
self.tokens = tokens # False for a command running: no tokens to count
|
|
230
234
|
|
|
231
235
|
def update(self, phase: str, text: str) -> None:
|
|
232
236
|
self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
|
|
@@ -239,9 +243,11 @@ class Progress:
|
|
|
239
243
|
now = time.time()
|
|
240
244
|
streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
|
|
241
245
|
phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
)
|
|
246
|
+
tokens = f"{self.chars // 4} tokens, " if self.tokens else ""
|
|
247
|
+
keys = "Ctrl+C to stop" if self.steering is None else "type a note · Esc pauses · Ctrl+C stops"
|
|
248
|
+
line = Text.from_markup(f"[cyan]{phase}…[/] [dim]({tokens}{now - self.started:.0f}s · {keys})[/]")
|
|
249
|
+
below = self.steering.status() if self.steering is not None else None
|
|
250
|
+
return line if below is None else Text("\n").join([line, below])
|
|
245
251
|
|
|
246
252
|
|
|
247
253
|
@dataclass
|
|
@@ -276,6 +282,7 @@ class Settings:
|
|
|
276
282
|
compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
|
|
277
283
|
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
278
284
|
checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
|
|
285
|
+
review: str = "off" # check a change against the request before the answer (lcode.review): auto or off
|
|
279
286
|
|
|
280
287
|
|
|
281
288
|
class Agent:
|
|
@@ -319,6 +326,9 @@ class Agent:
|
|
|
319
326
|
self.read_only = False # only read-only shell commands
|
|
320
327
|
self.no_changes = "" # set during a review: why nothing may change (read-only tools only)
|
|
321
328
|
self.cancel: threading.Event | None = None # set from another thread to stop
|
|
329
|
+
self.steering: Steering | None = None # notes typed and pauses asked for during a request (lcode.repl)
|
|
330
|
+
self.turn_requests: list[str] = [] # the request, then the notes added to it while it ran
|
|
331
|
+
self.unsent = "" # typed during the last request but never sent to the model: back in the prompt (lcode.repl)
|
|
322
332
|
self.on_tool = None # called with (name, arguments) before each tool runs
|
|
323
333
|
self.jobs = Jobs() # background commands (shared with subagents)
|
|
324
334
|
self.turn_started = 0.0 # when the running request started (time.monotonic)
|
|
@@ -334,6 +344,7 @@ class Agent:
|
|
|
334
344
|
self.autosave = False # save the session after every step (sessions people resume; not the bench's)
|
|
335
345
|
self._check: tuple[Path, checks.Check | None] | None = None
|
|
336
346
|
self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
|
|
347
|
+
self.review_results: list[str] | None = None # what the last request's review found (None: no review)
|
|
337
348
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
338
349
|
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
339
350
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
@@ -746,7 +757,7 @@ class Agent:
|
|
|
746
757
|
"""Stream one model response, rendering thinking/content live. Returns the assistant message."""
|
|
747
758
|
content, thinking, tool_calls, final = "", "", [], {}
|
|
748
759
|
md = MarkdownStreamer(self.console)
|
|
749
|
-
progress = Progress()
|
|
760
|
+
progress = Progress(steering=self.steering)
|
|
750
761
|
watch = self.before_request(progress)
|
|
751
762
|
status = None
|
|
752
763
|
printed_thinking = False
|
|
@@ -831,6 +842,43 @@ class Agent:
|
|
|
831
842
|
self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
|
|
832
843
|
return message
|
|
833
844
|
|
|
845
|
+
def reviewing(self) -> bool:
|
|
846
|
+
return (
|
|
847
|
+
self.settings.review != "off" and self.allowed_tools is None and not self.planning() and not self.no_changes
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
def self_review(self, answer: str) -> list[str]:
|
|
851
|
+
"""Check the request's change against the request before the answer counts; the problems found."""
|
|
852
|
+
budget = int(self.settings.context * 3 * 0.25)
|
|
853
|
+
diff, changed = review.diff_of(self.tools.before, self.current_text, budget)
|
|
854
|
+
if changed < review.MIN_CHANGED_LINES:
|
|
855
|
+
return []
|
|
856
|
+
request = self.turn_requests[0] if self.turn_requests else ""
|
|
857
|
+
if len(self.turn_requests) > 1:
|
|
858
|
+
added = "\n".join(f"- {note}" for note in self.turn_requests[1:])
|
|
859
|
+
request += f"\n\nWhat the user added while you worked:\n{added}"
|
|
860
|
+
try:
|
|
861
|
+
with self.console.status("Reviewing the change against your request…", spinner="dots"):
|
|
862
|
+
problems = review.run(self, request, answer, diff)
|
|
863
|
+
except OllamaError as e:
|
|
864
|
+
self.say(f"The review didn't run: {e}", warning=True)
|
|
865
|
+
return []
|
|
866
|
+
self.review_results = problems
|
|
867
|
+
if problems:
|
|
868
|
+
self.say(
|
|
869
|
+
f"Review: {len(problems)} point(s) sent back to the model. First: {problems[0][:160]}", warning=True
|
|
870
|
+
)
|
|
871
|
+
else:
|
|
872
|
+
self.say("Review: the change does what was asked.")
|
|
873
|
+
return problems
|
|
874
|
+
|
|
875
|
+
def current_text(self, path: str) -> str | None:
|
|
876
|
+
p = self.tools.resolve(path)
|
|
877
|
+
try:
|
|
878
|
+
return self.tools.read_text(p) if p.is_file() else None
|
|
879
|
+
except OSError:
|
|
880
|
+
return None
|
|
881
|
+
|
|
834
882
|
def checks_prompt(self) -> str:
|
|
835
883
|
check = self.project_check()
|
|
836
884
|
if check is None:
|
|
@@ -1024,8 +1072,12 @@ class Agent:
|
|
|
1024
1072
|
self.turn_status = "running"
|
|
1025
1073
|
note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
|
|
1026
1074
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
1075
|
+
self.turn_requests = [user_text.strip()]
|
|
1027
1076
|
self.repeats.reset()
|
|
1028
1077
|
self.check_results = []
|
|
1078
|
+
self.review_results = None
|
|
1079
|
+
self.tools.before = {}
|
|
1080
|
+
reviewed = False
|
|
1029
1081
|
changed = verified = False # files changed in this request; the checks passed since
|
|
1030
1082
|
check_rounds = 0
|
|
1031
1083
|
malformed = 0
|
|
@@ -1033,6 +1085,8 @@ class Agent:
|
|
|
1033
1085
|
for step in range(self.max_steps):
|
|
1034
1086
|
if self.cancel is not None and self.cancel.is_set():
|
|
1035
1087
|
raise KeyboardInterrupt
|
|
1088
|
+
if step and self.steering is not None:
|
|
1089
|
+
self.steer()
|
|
1036
1090
|
if step and self.autosave:
|
|
1037
1091
|
self.save() # a crash or a closed terminal loses at most this step
|
|
1038
1092
|
self.maybe_compact()
|
|
@@ -1078,6 +1132,8 @@ class Agent:
|
|
|
1078
1132
|
)
|
|
1079
1133
|
continue
|
|
1080
1134
|
if not calls:
|
|
1135
|
+
if self.steering is not None and self.steering.waiting():
|
|
1136
|
+
continue # the user added a note while the model wrote its answer: it reads the note first
|
|
1081
1137
|
check = self.project_check() if changed and not verified else None
|
|
1082
1138
|
if check is not None and check_rounds < checks.MAX_ROUNDS:
|
|
1083
1139
|
check_rounds += 1
|
|
@@ -1087,6 +1143,12 @@ class Agent:
|
|
|
1087
1143
|
changed = False # run them again only if the model changes something more
|
|
1088
1144
|
continue
|
|
1089
1145
|
verified = True
|
|
1146
|
+
if not reviewed and self.tools.before and self.reviewing():
|
|
1147
|
+
reviewed = True
|
|
1148
|
+
problems = self.self_review(reply.get("content") or "")
|
|
1149
|
+
if problems:
|
|
1150
|
+
self.messages.append({"role": "user", "content": review.feedback(problems)})
|
|
1151
|
+
continue
|
|
1090
1152
|
break
|
|
1091
1153
|
done: dict[int, str] = {} # results of subagents that ran together
|
|
1092
1154
|
together = [
|
|
@@ -1187,6 +1249,34 @@ class Agent:
|
|
|
1187
1249
|
self.turn_status = "success"
|
|
1188
1250
|
self.print_stats()
|
|
1189
1251
|
|
|
1252
|
+
def steer(self) -> None:
|
|
1253
|
+
"""Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
|
|
1254
|
+
steering = self.steering
|
|
1255
|
+
if steering is None:
|
|
1256
|
+
return
|
|
1257
|
+
if steering.take_pause():
|
|
1258
|
+
self.console.print(
|
|
1259
|
+
Text(
|
|
1260
|
+
"⏸ Paused. Press Enter to go on, type a note to go on with it, or press Ctrl+C to stop the request.",
|
|
1261
|
+
style="yellow",
|
|
1262
|
+
)
|
|
1263
|
+
)
|
|
1264
|
+
note = steering.ask(" ⏸ ").strip()
|
|
1265
|
+
if note:
|
|
1266
|
+
steering.add(note)
|
|
1267
|
+
notes = steering.take()
|
|
1268
|
+
if not notes:
|
|
1269
|
+
return
|
|
1270
|
+
self.turn_requests += notes
|
|
1271
|
+
self.messages.append({"role": "user", "content": "\n\n".join(notes) + "\n\n" + NOTE_INTRO})
|
|
1272
|
+
for note in notes:
|
|
1273
|
+
shown = note if len(note) <= 200 else note[:200] + "…"
|
|
1274
|
+
self.console.print(Text(f"✎ Note for the model: {shown}", style="cyan"))
|
|
1275
|
+
|
|
1276
|
+
def activity(self, phase: str):
|
|
1277
|
+
"""A spinner with the elapsed time while something runs (a command), with the note being typed under it."""
|
|
1278
|
+
return self.console.status(Progress(phase, self.steering, tokens=False), spinner="dots")
|
|
1279
|
+
|
|
1190
1280
|
def print_stats(self) -> None:
|
|
1191
1281
|
pct = 100 * self.ctx_used / self.settings.context
|
|
1192
1282
|
self.console.print(
|
|
@@ -77,6 +77,7 @@ class Result:
|
|
|
77
77
|
files_changed: list[dict] = field(default_factory=list) # {"status": "A" | "M" | "D", "path"}
|
|
78
78
|
usage: dict = field(default_factory=dict) # requests, prompt_tokens, output_tokens
|
|
79
79
|
checks: list[dict] = field(default_factory=list) # {"command", "exit_code", "by": "lcode" | "model"}
|
|
80
|
+
review: list[str] | None = None # problems the review sent back ([] when it found none; None: no review)
|
|
80
81
|
seconds: float = 0.0
|
|
81
82
|
error: str = ""
|
|
82
83
|
|
|
@@ -198,6 +199,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
198
199
|
secret_check=cfg["secret_check"],
|
|
199
200
|
repeat_limit=cfg["repeat_limit"],
|
|
200
201
|
checks=cfg["checks"],
|
|
202
|
+
review=cfg["review"],
|
|
201
203
|
compact_at=cfg["compact_at"],
|
|
202
204
|
)
|
|
203
205
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
@@ -306,6 +308,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
306
308
|
files_changed=files,
|
|
307
309
|
usage=usage,
|
|
308
310
|
checks=list(agent.check_results),
|
|
311
|
+
review=agent.review_results,
|
|
309
312
|
seconds=round(time.monotonic() - started, 2),
|
|
310
313
|
error=error,
|
|
311
314
|
)
|
|
@@ -581,6 +581,7 @@ def cmd_bench(args) -> None:
|
|
|
581
581
|
prune=not args.no_prune,
|
|
582
582
|
compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
|
|
583
583
|
checks=cfg["checks"] if args.checks is None else args.checks,
|
|
584
|
+
review=cfg["review"] if args.review is None else args.review,
|
|
584
585
|
repo_map=args.repo_map,
|
|
585
586
|
embed_model=cfg["embed_model"] if args.code_search else "off",
|
|
586
587
|
)
|
|
@@ -775,6 +776,9 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
775
776
|
p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
|
|
776
777
|
p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
|
|
777
778
|
p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
|
|
779
|
+
p.add_argument(
|
|
780
|
+
"--review", choices=["auto", "off"], help="review each change before it counts (default: the setting)"
|
|
781
|
+
)
|
|
778
782
|
p.add_argument(
|
|
779
783
|
"--checks",
|
|
780
784
|
metavar="auto|off|COMMAND",
|
|
@@ -59,6 +59,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
59
59
|
"subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
|
|
60
60
|
"notify": (True, bool, "desktop notification when a long request is done or waits for you"),
|
|
61
61
|
"notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
|
|
62
|
+
"review": ("off", str, "before the answer, check a change against the request and send problems back: auto | off"),
|
|
62
63
|
"checks": ("auto", str, "run the project's checks before calling work done: auto | off | a command"),
|
|
63
64
|
"repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
|
|
64
65
|
"secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
|
|
@@ -198,6 +198,7 @@ class Permissions:
|
|
|
198
198
|
self.always: set[str] = set()
|
|
199
199
|
self.rules = Rules() # allow and deny lists from the settings (lcode.hooks)
|
|
200
200
|
self.on_prompt = None # called with the title before lcode asks the user (notification hooks)
|
|
201
|
+
self.read_line: Callable[[str], str] | None = None # asks instead of input() (lcode.steer, during a request)
|
|
201
202
|
# Decides instead of asking (lcode.api, lcode acp): True, False, or a message saying why not.
|
|
202
203
|
self.approve: Callable[[dict], bool | str] | None = None
|
|
203
204
|
|
|
@@ -243,7 +244,8 @@ class Permissions:
|
|
|
243
244
|
if kind == "mcp":
|
|
244
245
|
scope = scope.replace(":", " › ", 1)
|
|
245
246
|
try:
|
|
246
|
-
|
|
247
|
+
question = f" Allow? [y]es / [a]lways for '{scope}' this session / [n]o (+ optional reason): "
|
|
248
|
+
answer = (self.read_line or input)(question)
|
|
247
249
|
except EOFError:
|
|
248
250
|
return False, "The user denied this action (no input available)."
|
|
249
251
|
answer = answer.strip()
|
|
@@ -36,6 +36,7 @@ from lcode.checkpoints import Checkpoint, CheckpointError, Restore
|
|
|
36
36
|
from lcode.config import PERMISSION_MODES, STATE_DIR, ConfigError, format_tokens, parse_context
|
|
37
37
|
from lcode.hardware import Hardware
|
|
38
38
|
from lcode.ollama import OllamaError
|
|
39
|
+
from lcode.steer import KeyReader, Steering
|
|
39
40
|
from lcode.tools import IGNORE_DIRS
|
|
40
41
|
|
|
41
42
|
COMMANDS = {
|
|
@@ -874,6 +875,7 @@ def git_command(agent: Agent, cmd: str, arg: str) -> None:
|
|
|
874
875
|
|
|
875
876
|
def run_safely(agent: Agent, text: str) -> None:
|
|
876
877
|
started = time.monotonic()
|
|
878
|
+
reader = start_steering(agent) if agent.interactive else None
|
|
877
879
|
try:
|
|
878
880
|
agent.run_turn(text)
|
|
879
881
|
except KeyboardInterrupt:
|
|
@@ -881,10 +883,33 @@ def run_safely(agent: Agent, text: str) -> None:
|
|
|
881
883
|
except OllamaError as e:
|
|
882
884
|
agent.console.print(f"[red]{e}[/]")
|
|
883
885
|
finally:
|
|
886
|
+
if reader is not None:
|
|
887
|
+
stop_steering(agent, reader)
|
|
884
888
|
agent.save()
|
|
885
889
|
agent.finished(text, time.monotonic() - started)
|
|
886
890
|
|
|
887
891
|
|
|
892
|
+
def start_steering(agent: Agent) -> KeyReader | None:
|
|
893
|
+
"""Read the keyboard while the request runs: a typed line becomes a note for the model, Esc pauses."""
|
|
894
|
+
steering = Steering()
|
|
895
|
+
reader = KeyReader(steering)
|
|
896
|
+
if not reader.start():
|
|
897
|
+
return None # not a terminal lcode can read keys from (or Windows): the request runs as before
|
|
898
|
+
agent.steering = steering
|
|
899
|
+
agent.perms.read_line = steering.ask # questions take the keyboard back meanwhile
|
|
900
|
+
return reader
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def stop_steering(agent: Agent, reader: KeyReader) -> None:
|
|
904
|
+
reader.stop()
|
|
905
|
+
agent.steering = None
|
|
906
|
+
agent.perms.read_line = None
|
|
907
|
+
unsent = reader.steering.leftover()
|
|
908
|
+
if unsent:
|
|
909
|
+
agent.unsent = unsent
|
|
910
|
+
agent.console.print(Text("✎ What you typed didn't reach the model; it's in the prompt below.", style="cyan"))
|
|
911
|
+
|
|
912
|
+
|
|
888
913
|
def session_start(agent: Agent) -> None:
|
|
889
914
|
"""session_start hooks: their output is shown, and with feedback = true also given to the model."""
|
|
890
915
|
for outcome in agent.hooks.run("session_start", {"cwd": str(agent.cwd), "session": agent.session_id}, agent.cwd):
|
|
@@ -1115,13 +1140,13 @@ def resume_session(agent: Agent, info: sessions.SessionInfo) -> None:
|
|
|
1115
1140
|
|
|
1116
1141
|
def print_recap(agent: Agent) -> None:
|
|
1117
1142
|
"""Show the last request and the start of the last answer, so it's clear where things left off."""
|
|
1118
|
-
|
|
1143
|
+
requests = context_tools.user_requests(agent.messages) # the user's own words, not lcode's notes to the model
|
|
1119
1144
|
last_answer = next(
|
|
1120
1145
|
(m for m in reversed(agent.messages) if m.get("role") == "assistant" and m.get("content", "").strip()), None
|
|
1121
1146
|
)
|
|
1122
|
-
if not
|
|
1147
|
+
if not requests:
|
|
1123
1148
|
return
|
|
1124
|
-
lines = [f"[bold]You:[/] {escape(sessions.title_from([
|
|
1149
|
+
lines = [f"[bold]You:[/] {escape(sessions.title_from([{'role': 'user', 'content': requests[-1]}]))}"]
|
|
1125
1150
|
if last_answer:
|
|
1126
1151
|
answer = " ".join(last_answer["content"].split())
|
|
1127
1152
|
answer = answer if len(answer) <= 300 else answer[:299].rstrip() + "…"
|
|
@@ -1153,7 +1178,8 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
|
|
|
1153
1178
|
while True:
|
|
1154
1179
|
try:
|
|
1155
1180
|
agent.console.print()
|
|
1156
|
-
|
|
1181
|
+
unsent, agent.unsent = agent.unsent, ""
|
|
1182
|
+
line = session.prompt(HTML("<ansicyan><b>❯ </b></ansicyan>"), default=unsent).strip()
|
|
1157
1183
|
except KeyboardInterrupt:
|
|
1158
1184
|
continue
|
|
1159
1185
|
except EOFError:
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""A second look at a change before the answer goes to the user.
|
|
2
|
+
|
|
3
|
+
Models claim more than they did: "users can now shrink the window" without having checked, half of a
|
|
4
|
+
two-part request done, a debug print left behind. So when a request changed files, lcode asks the model to
|
|
5
|
+
step back and check its change against the request (what was asked, what the diff shows, what the answer
|
|
6
|
+
claims) before the answer counts. Real problems go back to the model once; the user sees a line about it.
|
|
7
|
+
|
|
8
|
+
The review is asked on top of the conversation, not in a fresh one: the model server keeps the processed
|
|
9
|
+
conversation cached, and a separate review prompt would push it out, so the next step would have to read the
|
|
10
|
+
whole conversation again, tens of seconds on a small GPU. The question tells the model to judge only from the
|
|
11
|
+
request and the diff.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from lcode.gitflow import parse_json
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from lcode.agent import Agent
|
|
23
|
+
|
|
24
|
+
MIN_CHANGED_LINES = 1 # even one line: a one-line bug fix is where a wrong fix hides
|
|
25
|
+
MAX_PROBLEMS = 5
|
|
26
|
+
SCHEMA = {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {"problems": {"type": "array", "items": {"type": "string"}}},
|
|
29
|
+
"required": ["problems"],
|
|
30
|
+
}
|
|
31
|
+
PROMPT = """[lcode] Before your answer goes to the user, review your change as a strict reviewer would: judge it only by what the user asked and what the diff shows, not by what you meant to do.
|
|
32
|
+
|
|
33
|
+
The user's request:
|
|
34
|
+
<request>
|
|
35
|
+
{request}
|
|
36
|
+
</request>
|
|
37
|
+
|
|
38
|
+
Your answer to them:
|
|
39
|
+
<answer>
|
|
40
|
+
{answer}
|
|
41
|
+
</answer>
|
|
42
|
+
|
|
43
|
+
{checks}The change (unified diff of the files changed in this request):
|
|
44
|
+
<diff>
|
|
45
|
+
{diff}
|
|
46
|
+
</diff>
|
|
47
|
+
|
|
48
|
+
Find real problems only, each one you can point to in the request or the diff:
|
|
49
|
+
1. Something the user asked for that the change doesn't do, or only partly does.
|
|
50
|
+
2. A claim in your answer that the diff doesn't support, or something called tested or working that nothing shows was run.
|
|
51
|
+
3. A mistake in the diff: a bug, a broken edge case the request mentions, leftover debugging (prints, commented-out code), an unfinished TODO.
|
|
52
|
+
No style preferences, no suggestions beyond the request. Answer in JSON: {{"problems": ["…"]}}, with an empty list when the change does what was asked."""
|
|
53
|
+
|
|
54
|
+
FEEDBACK = """[lcode] A review of your change against the user's request found:
|
|
55
|
+
{problems}
|
|
56
|
+
|
|
57
|
+
Fix what's real; if a point is wrong, say why in your answer. Then finish."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def diff_of(before: dict[str, str | None], read, budget: int) -> tuple[str, int]:
|
|
61
|
+
"""The unified diff of the files changed in the request, and how many lines it changes."""
|
|
62
|
+
parts, changed = [], 0
|
|
63
|
+
for path, old in before.items():
|
|
64
|
+
new = read(path)
|
|
65
|
+
if new is None or new == old:
|
|
66
|
+
continue
|
|
67
|
+
lines = list(
|
|
68
|
+
difflib.unified_diff((old or "").splitlines(True), new.splitlines(True), f"a/{path}", f"b/{path}", n=2)
|
|
69
|
+
)
|
|
70
|
+
changed += sum(1 for line in lines if line[:1] in "+-" and not line.startswith(("+++", "---")))
|
|
71
|
+
parts.append("".join(lines))
|
|
72
|
+
text = "".join(parts)
|
|
73
|
+
if len(text) > budget:
|
|
74
|
+
text = text[:budget] + f"\n[… the rest of the diff ({len(text) - budget} characters) is cut]"
|
|
75
|
+
return text, changed
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run(agent: Agent, request: str, answer: str, diff: str) -> list[str]:
|
|
79
|
+
"""Ask for the review; the problems it found (empty when none, or when the answer can't be read)."""
|
|
80
|
+
checks = ""
|
|
81
|
+
if agent.check_results:
|
|
82
|
+
last = agent.check_results[-1]
|
|
83
|
+
code = last.get("exit_code")
|
|
84
|
+
state = "passed" if code == 0 else "didn't run" if code is None else f"failed (exit code {code})"
|
|
85
|
+
checks = f"The project's checks (`{last.get('command')}`) {state} after the change.\n\n"
|
|
86
|
+
prompt = PROMPT.format(request=request[:4000], answer=answer[:3000] or "(none)", checks=checks, diff=diff)
|
|
87
|
+
payload = {
|
|
88
|
+
"model": agent.settings.model,
|
|
89
|
+
"messages": [*agent.messages, {"role": "user", "content": prompt}],
|
|
90
|
+
"tools": agent.tool_schemas(), # as in the session, so the cached conversation is reused
|
|
91
|
+
"think": agent.settings.think, # without reasoning it missed every gap it caught with it, and wasn't faster
|
|
92
|
+
"format": SCHEMA,
|
|
93
|
+
"keep_alive": agent.settings.keep_alive,
|
|
94
|
+
"options": agent.options(),
|
|
95
|
+
}
|
|
96
|
+
reply = agent.ollama.chat(payload)
|
|
97
|
+
data = parse_json((reply.get("message") or {}).get("content") or "")
|
|
98
|
+
problems = data.get("problems") if isinstance(data.get("problems"), list) else []
|
|
99
|
+
return [str(p).strip()[:500] for p in problems if str(p).strip()][:MAX_PROBLEMS]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def feedback(problems: list[str]) -> str:
|
|
103
|
+
return FEEDBACK.format(problems="\n".join(f"- {p}" for p in problems))
|