lcode-cli 0.28.0__tar.gz → 0.28.1__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.28.0 → lcode_cli-0.28.1}/CHANGELOG.md +35 -1
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/PKG-INFO +1 -1
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/quickstart.md +1 -1
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/usage.md +6 -3
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/agent.py +31 -2
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/api.py +1 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/context.py +68 -0
- lcode_cli-0.28.1/src/lcode/permissions.py +260 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/repl.py +1 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/tools.py +160 -33
- lcode_cli-0.28.1/tests/test_robustness.py +221 -0
- lcode_cli-0.28.0/src/lcode/permissions.py +0 -138
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.editorconfig +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/dependabot.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.gitignore +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/AGENTS.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/LICENSE +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/README.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/SECURITY.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/action.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/agents.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/assets/extra.css +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/automation.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/bench.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/changelog.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/commands.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/configuration.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/contributing.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/editors.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/git.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/hooks.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/how-it-works.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/index.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/installation.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/lsp.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/mcp.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/memory.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/models.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/sandbox.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/search.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/servers.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/examples/README.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/examples/commands/review.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/install.sh +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/mkdocs.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/overrides/main.html +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/pyproject.toml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/scripts/record_demo.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/scripts/social-preview.html +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/acp.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/action.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/backends.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/bench.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/cli.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/config.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/limits.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/memory.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/models.toml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/notify.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/planning.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/render.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/repeats.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/residency.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/update.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/vision.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/src/lcode/web.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/conftest.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_acp.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_action.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_agent.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_api.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_backends.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_bench.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_catalog.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_cli.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_config.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_context.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_extensions.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_hooks.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_jobs.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_lsp.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_mcp.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_memory.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_permissions.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_repeats.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_repomap.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_residency.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_servers.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_sessions.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_subagents.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_tools.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_update.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_vision.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_web.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/tests/test_xai.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.28.1}/uv.lock +0 -0
|
@@ -6,6 +6,39 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.28.1] - 2026-10-13
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
Found by testing lcode's tools on real files and commands (#118):
|
|
14
|
+
|
|
15
|
+
- Some commands that change things ran without asking, because they were taken for read-only:
|
|
16
|
+
`sort -o`, `uniq in out`, `tree -o`, `git branch -D` and other branch changes, `git remote
|
|
17
|
+
add/remove`, `git log/diff --output`, `rg --pre` (which runs a program), `find -fls`, `date -s`,
|
|
18
|
+
and `nvidia-smi` settings. Arguments are now checked. `2>&1` and `>/dev/null` no longer make a
|
|
19
|
+
read-only command ask.
|
|
20
|
+
- "Always allow" for a command also covered chains that started with it: allowing `npm test` let
|
|
21
|
+
`npm test && rm -rf build` run without asking. Now a chain needs its own "always".
|
|
22
|
+
- A JSON tool call that the model only showed as an example in its answer was run. Text is now
|
|
23
|
+
taken as a call only when the whole message is one.
|
|
24
|
+
- A change you made to a file while lcode asked to edit it was overwritten. lcode now notices and
|
|
25
|
+
doesn't apply the edit, and the model redoes it on the current content.
|
|
26
|
+
- `replace_all: "false"`, and other values sent as text, were taken as true. They now get the
|
|
27
|
+
type the tool declares.
|
|
28
|
+
- The shell timeout wasn't enforced while a command kept printing, so a watcher started without
|
|
29
|
+
`background` hung the request. Long output is no longer held in memory either: 400 MB of output
|
|
30
|
+
used to cost 2.5 GB, and now it goes to a file, keeping its start, end and error lines.
|
|
31
|
+
- One tool result could overflow the context window: `read_file` returned up to ~40K tokens,
|
|
32
|
+
however small the window, and compaction only looked at the previous request. A result now takes
|
|
33
|
+
at most a quarter of the window (`read_file` says where to read on), and lcode makes room based on
|
|
34
|
+
the size of the next request.
|
|
35
|
+
- Sessions were saved only when a request ended, so a crash lost the whole request. They're now
|
|
36
|
+
saved after every step, and atomically, so a crash while writing can't corrupt them.
|
|
37
|
+
- Editing a file with Windows line endings turned the whole file to Unix line endings, and editing a
|
|
38
|
+
file that isn't UTF-8 (Windows-1252 or Latin-1) replaced every accented letter in it with `�`,
|
|
39
|
+
not only in the edited line. Files now keep their encoding, line endings and byte order mark. A
|
|
40
|
+
character the encoding can't hold is refused instead of written wrong.
|
|
41
|
+
|
|
9
42
|
## [0.28.0] - 2026-10-13
|
|
10
43
|
|
|
11
44
|
### Added
|
|
@@ -599,7 +632,8 @@ First public release.
|
|
|
599
632
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
600
633
|
- One-line installer for Linux and macOS.
|
|
601
634
|
|
|
602
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.
|
|
635
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.1...HEAD
|
|
636
|
+
[0.28.1]: https://github.com/nasser1941/lcode/compare/v0.28.0...v0.28.1
|
|
603
637
|
[0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
|
|
604
638
|
[0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
|
|
605
639
|
[0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.28.
|
|
3
|
+
Version: 0.28.1
|
|
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/
|
|
@@ -224,11 +224,14 @@ image support off with `lcode config set vision_model off`.
|
|
|
224
224
|
`--yolo` start in one.
|
|
225
225
|
|
|
226
226
|
Read-only commands run without asking: `ls`, `cat`, `head`, `tail`, `grep`, `rg`, `find`, `tree`,
|
|
227
|
-
`wc`, `diff`, `git status/log/diff/show/branch/blame` and similar
|
|
228
|
-
|
|
227
|
+
`wc`, `diff`, `git status/log/diff/show/branch/blame` and similar. Their arguments count too:
|
|
228
|
+
`sort -o file`, `git branch -D`, `git log --output=file`, `rg --pre` or `find -delete` change
|
|
229
|
+
things, so they ask. Pipes between read-only commands are fine, and so are `cd <folder> &&` in front
|
|
230
|
+
and `2>&1` or `>/dev/null`; anything else (`&&`, `;`, redirecting to a file, `$(…)`) asks.
|
|
229
231
|
|
|
230
232
|
When asked, answer ++y++ (once), ++a++ (always, for this command or for all edits, until you quit)
|
|
231
|
-
or ++n++.
|
|
233
|
+
or ++n++. "Always" covers the same program, or for a chain the same programs in the same order:
|
|
234
|
+
allowing `npm test` doesn't allow `npm test && rm -rf build`. For lasting answers, write [permission rules](hooks.md#permission-rules): allow lists and
|
|
232
235
|
deny lists, per user or per repository; deny always wins, even in `yolo`. Text after ++n++ goes to the model as instructions: `n run the tests with -x first`.
|
|
233
236
|
|
|
234
237
|
!!! warning "yolo mode"
|
|
@@ -6,6 +6,7 @@ import base64
|
|
|
6
6
|
import contextlib
|
|
7
7
|
import datetime as dt
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
9
10
|
import platform
|
|
10
11
|
import re
|
|
11
12
|
import shutil
|
|
@@ -81,6 +82,8 @@ REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's pe
|
|
|
81
82
|
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
82
83
|
# How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
|
|
83
84
|
MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
|
|
85
|
+
RESULT_SHARE = 0.25 # of the context window, at most, for one tool result
|
|
86
|
+
MIN_RESULT_CHARS = 8000
|
|
84
87
|
READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
|
|
85
88
|
AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
|
|
86
89
|
PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
|
|
@@ -320,7 +323,9 @@ class Agent:
|
|
|
320
323
|
self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
|
|
321
324
|
self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
|
|
322
325
|
self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
|
|
326
|
+
self.autosave = False # save the session after every step (sessions people resume; not the bench's)
|
|
323
327
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
328
|
+
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
324
329
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
325
330
|
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
326
331
|
self.response = None # the streaming response, so another thread can abort it
|
|
@@ -576,6 +581,7 @@ class Agent:
|
|
|
576
581
|
self.tools.read_mtimes.clear()
|
|
577
582
|
self.reflected = len(self.messages)
|
|
578
583
|
self.ctx_used = len(self.messages[0]["content"]) // 3
|
|
584
|
+
self.counted_chars = None
|
|
579
585
|
|
|
580
586
|
def session_file(self) -> Path:
|
|
581
587
|
return sessions.sessions_dir() / f"{self.session_id}.json"
|
|
@@ -597,7 +603,10 @@ class Agent:
|
|
|
597
603
|
"messages": self.messages,
|
|
598
604
|
"checkpoints": self.checkpoints.to_json(),
|
|
599
605
|
}
|
|
600
|
-
|
|
606
|
+
# Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
|
|
607
|
+
partial = f.with_name(f".{f.name}.partial")
|
|
608
|
+
partial.write_text(json.dumps(data))
|
|
609
|
+
os.replace(partial, f)
|
|
601
610
|
|
|
602
611
|
def new_session(self) -> None:
|
|
603
612
|
self.reset()
|
|
@@ -634,6 +643,7 @@ class Agent:
|
|
|
634
643
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
635
644
|
self.reflected = len(self.messages) # checked for notes to remember when that session ended
|
|
636
645
|
self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
|
|
646
|
+
self.counted_chars = None
|
|
637
647
|
return note
|
|
638
648
|
|
|
639
649
|
def load_latest(self) -> bool:
|
|
@@ -797,7 +807,7 @@ class Agent:
|
|
|
797
807
|
self.usage["output_tokens"] += final.get("eval_count", 0)
|
|
798
808
|
self.usage["output_ns"] += final.get("eval_duration", 0)
|
|
799
809
|
self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
|
|
800
|
-
self.sent_chars = conversation_chars(self.messages)
|
|
810
|
+
self.sent_chars = self.counted_chars = conversation_chars(self.messages)
|
|
801
811
|
seconds = final.get("eval_duration", 0) / 1e9
|
|
802
812
|
self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
|
|
803
813
|
return message
|
|
@@ -943,6 +953,8 @@ class Agent:
|
|
|
943
953
|
for step in range(self.max_steps):
|
|
944
954
|
if self.cancel is not None and self.cancel.is_set():
|
|
945
955
|
raise KeyboardInterrupt
|
|
956
|
+
if step and self.autosave:
|
|
957
|
+
self.save() # a crash or a closed terminal loses at most this step
|
|
946
958
|
self.maybe_compact()
|
|
947
959
|
try:
|
|
948
960
|
reply = self.assistant_step()
|
|
@@ -1027,6 +1039,9 @@ class Agent:
|
|
|
1027
1039
|
self.console.print(Text(f" {result[:300]}", style="red"))
|
|
1028
1040
|
elif name in ("read_file", "grep", "glob", "list_dir"):
|
|
1029
1041
|
self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
|
|
1042
|
+
budget = self.result_budget()
|
|
1043
|
+
if len(result) > budget and name not in ("agent", "skill"):
|
|
1044
|
+
result = context_tools.shorten(result, budget, f"{name} result")
|
|
1030
1045
|
verdict = self.repeats.check(name, args, result)
|
|
1031
1046
|
if verdict.note:
|
|
1032
1047
|
result += verdict.note
|
|
@@ -1161,8 +1176,21 @@ class Agent:
|
|
|
1161
1176
|
limit = min(limit, self.settings.compact_at)
|
|
1162
1177
|
return limit
|
|
1163
1178
|
|
|
1179
|
+
def next_request_tokens(self) -> int:
|
|
1180
|
+
"""The size of the next request: what the model last counted, plus what was added since (tool output,
|
|
1181
|
+
attachments). Deciding on the last count alone let one large tool result overflow the window."""
|
|
1182
|
+
if self.counted_chars is None:
|
|
1183
|
+
return self.ctx_used
|
|
1184
|
+
return self.ctx_used + max(0, conversation_chars(self.messages) - self.counted_chars) // 3
|
|
1185
|
+
|
|
1186
|
+
def result_budget(self) -> int:
|
|
1187
|
+
"""Characters one tool result may take: a share of the window, so one large result can't fill it."""
|
|
1188
|
+
return max(MIN_RESULT_CHARS, int(self.settings.context * 3 * RESULT_SHARE))
|
|
1189
|
+
|
|
1164
1190
|
def maybe_compact(self) -> None:
|
|
1165
1191
|
limit = self.compact_limit()
|
|
1192
|
+
if self.next_request_tokens() > limit:
|
|
1193
|
+
self.ctx_used, self.counted_chars = self.next_request_tokens(), None
|
|
1166
1194
|
if self.ctx_used <= limit:
|
|
1167
1195
|
return
|
|
1168
1196
|
enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
|
|
@@ -1231,6 +1259,7 @@ class Agent:
|
|
|
1231
1259
|
)
|
|
1232
1260
|
self.skills_loaded.clear()
|
|
1233
1261
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
1262
|
+
self.counted_chars = None
|
|
1234
1263
|
self.summarized_size = self.ctx_used
|
|
1235
1264
|
self.sent_chars = 0
|
|
1236
1265
|
self.reflected = len(self.messages)
|
|
@@ -200,6 +200,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
200
200
|
)
|
|
201
201
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
202
202
|
agent.hardware = hw
|
|
203
|
+
agent.autosave = True
|
|
203
204
|
agent.interactive = options.interactive
|
|
204
205
|
if options.max_steps:
|
|
205
206
|
agent.max_steps = options.max_steps
|
|
@@ -14,8 +14,10 @@ full output is saved to a file the model can read if it needs more.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import os
|
|
17
18
|
import re
|
|
18
19
|
import time
|
|
20
|
+
from collections import deque
|
|
19
21
|
from dataclasses import dataclass
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
from typing import TYPE_CHECKING
|
|
@@ -253,6 +255,72 @@ def shorten(text: str, limit: int, label: str = "output") -> str:
|
|
|
253
255
|
return f"{head}{note}{tail}{where}"
|
|
254
256
|
|
|
255
257
|
|
|
258
|
+
class OutputSpool:
|
|
259
|
+
"""Output collected line by line with bounded memory: once it's long, everything goes to a file, and only the
|
|
260
|
+
start, the end and the error-looking lines stay in memory. What `shorten` returns comes out the same."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, limit: int, label: str = "output") -> None:
|
|
263
|
+
self.limit, self.label = limit, label
|
|
264
|
+
self.head: list[str] = [] # everything, until the output gets long; then its start
|
|
265
|
+
self.tail: deque[str] = deque()
|
|
266
|
+
self.tail_chars = 0
|
|
267
|
+
self.errors: list[str] = []
|
|
268
|
+
self.chars = 0
|
|
269
|
+
self.file = None
|
|
270
|
+
self.path: Path | None = None
|
|
271
|
+
|
|
272
|
+
def add(self, line: str) -> None:
|
|
273
|
+
self.chars += len(line)
|
|
274
|
+
if self.file is None:
|
|
275
|
+
self.head.append(line)
|
|
276
|
+
if self.chars > self.limit * 2:
|
|
277
|
+
self._spool()
|
|
278
|
+
return
|
|
279
|
+
try:
|
|
280
|
+
self.file.write(line)
|
|
281
|
+
except OSError:
|
|
282
|
+
pass
|
|
283
|
+
if ERROR_LINE.search(line) and len(self.errors) < MAX_ERROR_LINES:
|
|
284
|
+
self.errors.append(line.strip()[:300])
|
|
285
|
+
self.tail.append(line)
|
|
286
|
+
self.tail_chars += len(line)
|
|
287
|
+
while self.tail_chars > self.limit // 4 and len(self.tail) > 1:
|
|
288
|
+
self.tail_chars -= len(self.tail.popleft())
|
|
289
|
+
|
|
290
|
+
def _spool(self) -> None:
|
|
291
|
+
text = "".join(self.head)
|
|
292
|
+
self.path = save_output(text, self.label)
|
|
293
|
+
if self.path is not None:
|
|
294
|
+
try:
|
|
295
|
+
self.file = self.path.open("a")
|
|
296
|
+
except OSError:
|
|
297
|
+
self.file = None
|
|
298
|
+
if self.file is None: # nowhere to keep it: keep only the start and carry on counting
|
|
299
|
+
self.file = open(os.devnull, "w") # noqa: SIM115
|
|
300
|
+
head = text[: self.limit * 3 // 5]
|
|
301
|
+
self.head = [head]
|
|
302
|
+
|
|
303
|
+
def text(self) -> str:
|
|
304
|
+
if self.file is None:
|
|
305
|
+
return shorten("".join(self.head), self.limit, self.label)
|
|
306
|
+
self.file.close()
|
|
307
|
+
head, tail = "".join(self.head), "".join(self.tail)
|
|
308
|
+
cut = self.chars - len(head) - len(tail)
|
|
309
|
+
note = f"\n\n... [{cut} characters cut"
|
|
310
|
+
if self.errors:
|
|
311
|
+
note += f"; {len(self.errors)} line(s) from them that look like errors:]\n" + "\n".join(self.errors)
|
|
312
|
+
note += "\n[...]\n\n"
|
|
313
|
+
else:
|
|
314
|
+
note += "] ...\n\n"
|
|
315
|
+
where = (
|
|
316
|
+
f"\n[The full {self.label} ({self.chars} characters) is in {self.path}: read parts of it with read_file "
|
|
317
|
+
"if you need them.]"
|
|
318
|
+
if self.path
|
|
319
|
+
else ""
|
|
320
|
+
)
|
|
321
|
+
return f"{head}{note}{tail}{where}"
|
|
322
|
+
|
|
323
|
+
|
|
256
324
|
# ----------------------------------------------------------------------------- what uses the context
|
|
257
325
|
|
|
258
326
|
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Permission prompts for file edits and shell commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
from rich.console import Console, RenderableType
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
|
|
12
|
+
from lcode.config import PERMISSION_MODES
|
|
13
|
+
|
|
14
|
+
# Commands that only read state run without asking (when used without redirection or chaining).
|
|
15
|
+
SAFE_COMMANDS = {
|
|
16
|
+
"ls", "cat", "head", "tail", "wc", "pwd", "grep", "rg", "find", "tree", "file", "stat", "du", "df",
|
|
17
|
+
"echo", "which", "whoami", "uname", "date", "sort", "uniq", "cut", "nl", "diff", "basename", "dirname",
|
|
18
|
+
"realpath", "readlink", "nvidia-smi", "sw_vers",
|
|
19
|
+
} # fmt: skip
|
|
20
|
+
SAFE_GIT = {"status", "log", "diff", "show", "branch", "ls-files", "rev-parse", "remote", "blame", "shortlog"}
|
|
21
|
+
# For these, "always allow" is remembered per subcommand (e.g. `git commit`, not all of `git`).
|
|
22
|
+
MULTI_WORD = {"git", "npm", "pnpm", "yarn", "pip", "uv", "docker", "cargo", "go", "kubectl", "python", "python3", "brew"} # fmt: skip # noqa: E501
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# `cd <plain path> &&` in front of a command: harmless, and models write it all the time.
|
|
26
|
+
NOT_ASKED = (
|
|
27
|
+
"This needs the user's permission, and nobody can be asked in this run, so it was not allowed. Do without it "
|
|
28
|
+
"if you can; otherwise finish and say what permission you need."
|
|
29
|
+
)
|
|
30
|
+
LEADING_CD = re.compile(r"""^\s*(?:cd\s+(?:[\w./~@+,=:-]+|'[^']*'|"[^"$`\\]*")\s*&&\s*)+""")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Redirections that write nothing anyone keeps: stderr into stdout, or output thrown away.
|
|
34
|
+
HARMLESS_REDIRECT = re.compile(r"(?<![\w>&])(?:[12]?>&[12]|[12]?>\s*/dev/null)(?=\s|$)")
|
|
35
|
+
OPERATORS = {"|", "||", "&&", ";", "&", ">", ">>", "<", "<<", "<<<", ">&", "<&", ">|", "(", ")", ";;"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def split_command(command: str) -> tuple[list[list[str]], list[str]] | None:
|
|
39
|
+
"""The command's segments (as words) and the operators between them, quotes respected. None when it can't be
|
|
40
|
+
read: command substitution, process substitution or unbalanced quotes."""
|
|
41
|
+
if any(tok in command for tok in ("`", "$(", "<(", ">(")) or "\n" in command.strip():
|
|
42
|
+
return None
|
|
43
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
44
|
+
lexer.whitespace_split = True
|
|
45
|
+
try:
|
|
46
|
+
tokens = list(lexer)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
segments: list[list[str]] = [[]]
|
|
50
|
+
operators: list[str] = []
|
|
51
|
+
for token in tokens:
|
|
52
|
+
if token in OPERATORS or (token and set(token) <= set("|&;<>()")):
|
|
53
|
+
operators.append(token)
|
|
54
|
+
segments.append([])
|
|
55
|
+
else:
|
|
56
|
+
segments[-1].append(token)
|
|
57
|
+
return [s for s in segments if s], operators
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def segment_key(words: list[str]) -> str:
|
|
61
|
+
"""The program, plus the subcommand for tools like git (`git commit`, `python -m pytest`)."""
|
|
62
|
+
while words and re.fullmatch(r"\w+=\S*", words[0]):
|
|
63
|
+
words = words[1:] # VAR=value in front
|
|
64
|
+
if not words:
|
|
65
|
+
return ""
|
|
66
|
+
if words[0] in MULTI_WORD and len(words) > 1:
|
|
67
|
+
if words[1] in ("-m", "-c") and len(words) > 2: # python -m <module>
|
|
68
|
+
return " ".join(words[:3])
|
|
69
|
+
return f"{words[0]} {words[1]}"
|
|
70
|
+
return words[0]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def bash_key(command: str) -> str:
|
|
74
|
+
"""What "always allow" remembers. For one program, the program (plus subcommand for git and the like); for a
|
|
75
|
+
chain such as `npm test && rm -rf build`, every program in it, so allowing `npm test` doesn't allow the chain.
|
|
76
|
+
`cd dir &&` in front doesn't count."""
|
|
77
|
+
command = HARMLESS_REDIRECT.sub(" ", LEADING_CD.sub("", command)).strip()
|
|
78
|
+
parsed = split_command(command)
|
|
79
|
+
if parsed is None:
|
|
80
|
+
return "bash:" + " ".join(command.split()) # can't be read: only this exact command
|
|
81
|
+
segments, operators = parsed
|
|
82
|
+
keys, redirected = [], False
|
|
83
|
+
for i, words in enumerate(segments):
|
|
84
|
+
before = operators[i - 1] if i else ""
|
|
85
|
+
if before and before not in ("|", "&&", "||", ";", "&"):
|
|
86
|
+
redirected = True # a file name after > or <, not a program
|
|
87
|
+
continue
|
|
88
|
+
keys.append(segment_key(words))
|
|
89
|
+
key = " + ".join(k for k in keys if k) + (" (redirected)" if redirected else "")
|
|
90
|
+
return f"bash:{key}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def option_values(words: list[str], *options: str) -> list[str]:
|
|
94
|
+
"""The values of options such as `-o FILE`, `-oFILE` or `--output=FILE`."""
|
|
95
|
+
found = []
|
|
96
|
+
for i, word in enumerate(words):
|
|
97
|
+
for option in options:
|
|
98
|
+
if word == option and i + 1 < len(words):
|
|
99
|
+
found.append(words[i + 1])
|
|
100
|
+
elif option.startswith("--") and word.startswith(option + "="):
|
|
101
|
+
found.append(word.split("=", 1)[1])
|
|
102
|
+
elif not option.startswith("--") and word.startswith(option) and len(word) > len(option):
|
|
103
|
+
found.append(word[len(option) :])
|
|
104
|
+
return found
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def flags(words: list[str]) -> list[str]:
|
|
108
|
+
return [w for w in words[1:] if w.startswith("-")]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def positional(words: list[str]) -> list[str]:
|
|
112
|
+
return [w for w in words[1:] if not w.startswith("-")]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Branch and remote listing; anything else creates, deletes, renames or reconfigures.
|
|
116
|
+
BRANCH_LISTING = {
|
|
117
|
+
"-a", "--all", "-r", "--remotes", "-l", "--list", "-v", "-vv", "--verbose", "--show-current", "--color",
|
|
118
|
+
"--no-color", "--column", "--no-column", "--abbrev", "--no-abbrev", "-i", "--ignore-case", "--omit-empty",
|
|
119
|
+
} # fmt: skip
|
|
120
|
+
BRANCH_WITH_VALUE = {"--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--sort", "--format"}
|
|
121
|
+
NVIDIA_QUERY = ("-q", "--query", "-L", "--list-gpus", "--format", "-i", "--id", "-d", "--display", "-x", "--xml-format",
|
|
122
|
+
"-h", "--help", "--query-gpu", "--query-compute-apps") # fmt: skip
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def safe_git(words: list[str]) -> bool:
|
|
126
|
+
if len(words) < 2 or words[1] not in SAFE_GIT:
|
|
127
|
+
return False # also `git -c …` and `git -C …`, which can run anything
|
|
128
|
+
sub, args = words[1], words[2:]
|
|
129
|
+
if sub in ("log", "show", "diff", "shortlog") and option_values(words, "--output"):
|
|
130
|
+
return False # writes the output to a file
|
|
131
|
+
if sub == "branch":
|
|
132
|
+
listing = any(a in ("-l", "--list") for a in args)
|
|
133
|
+
i = 0
|
|
134
|
+
while i < len(args):
|
|
135
|
+
arg = args[i]
|
|
136
|
+
name = arg.split("=", 1)[0]
|
|
137
|
+
if name in BRANCH_WITH_VALUE:
|
|
138
|
+
i += 1 if "=" in arg else 2
|
|
139
|
+
continue
|
|
140
|
+
if arg.startswith("-") and name not in BRANCH_LISTING:
|
|
141
|
+
return False # -d, -D, -m, -c, -f, -u, --set-upstream-to, --edit-description, …
|
|
142
|
+
if not arg.startswith("-") and not listing:
|
|
143
|
+
return False # `git branch name` creates a branch
|
|
144
|
+
i += 1
|
|
145
|
+
if sub == "remote":
|
|
146
|
+
return not args or all(a in ("-v", "--verbose") for a in args) or args[0] in ("show", "get-url")
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def safe_segment(words: list[str]) -> bool:
|
|
151
|
+
program = words[0]
|
|
152
|
+
if program == "git":
|
|
153
|
+
return safe_git(words)
|
|
154
|
+
if program not in SAFE_COMMANDS:
|
|
155
|
+
return False
|
|
156
|
+
if program == "sort" and (
|
|
157
|
+
option_values(words, "-o", "--output") or any(f.startswith("--compress-program") for f in flags(words))
|
|
158
|
+
):
|
|
159
|
+
return False
|
|
160
|
+
if program == "uniq" and len(positional(words)) > 1:
|
|
161
|
+
return False # `uniq in out` writes out
|
|
162
|
+
if program == "tree" and option_values(words, "-o"):
|
|
163
|
+
return False
|
|
164
|
+
if program == "rg" and any(f.split("=")[0] in ("--pre", "--pre-glob") for f in flags(words)):
|
|
165
|
+
return False # runs a program on each file
|
|
166
|
+
if program == "find" and any(
|
|
167
|
+
w in ("-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", "-fprintf", "-fls") for w in words
|
|
168
|
+
):
|
|
169
|
+
return False
|
|
170
|
+
if program == "date" and (option_values(words, "-s", "--set") or "-s" in words):
|
|
171
|
+
return False
|
|
172
|
+
if program == "file" and any(f in ("-C", "--compile") for f in flags(words)):
|
|
173
|
+
return False
|
|
174
|
+
if program == "nvidia-smi":
|
|
175
|
+
return all(f.split("=")[0] in NVIDIA_QUERY for f in flags(words))
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def is_read_only(command: str) -> bool:
|
|
180
|
+
"""Whether a command only reads: known read-only programs with arguments that don't write, joined only by
|
|
181
|
+
pipes. `cd dir &&` in front, `2>&1` and `>/dev/null` are fine."""
|
|
182
|
+
command = HARMLESS_REDIRECT.sub(" ", LEADING_CD.sub("", command)).strip()
|
|
183
|
+
parsed = split_command(command)
|
|
184
|
+
if parsed is None:
|
|
185
|
+
return False
|
|
186
|
+
segments, operators = parsed
|
|
187
|
+
if not segments or any(op != "|" for op in operators):
|
|
188
|
+
return False
|
|
189
|
+
return all(safe_segment(words) for words in segments)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Permissions:
|
|
193
|
+
def __init__(self, console: Console, mode: str = "ask"):
|
|
194
|
+
from lcode.hooks import Rules
|
|
195
|
+
|
|
196
|
+
self.console = console
|
|
197
|
+
self.mode = mode
|
|
198
|
+
self.always: set[str] = set()
|
|
199
|
+
self.rules = Rules() # allow and deny lists from the settings (lcode.hooks)
|
|
200
|
+
self.on_prompt = None # called with the title before lcode asks the user (notification hooks)
|
|
201
|
+
# Decides instead of asking (lcode.api, lcode acp): True, False, or a message saying why not.
|
|
202
|
+
self.approve: Callable[[dict], bool | str] | None = None
|
|
203
|
+
|
|
204
|
+
def rule(self, kind: str, target: str) -> tuple[bool, str] | None:
|
|
205
|
+
"""A rule's verdict on an action: (allowed, message for the model), or None when no rule matches."""
|
|
206
|
+
verdict, which = self.rules.check(kind, target) if target else (None, "")
|
|
207
|
+
if verdict == "deny":
|
|
208
|
+
return False, f"A deny rule blocks this: {which}. Don't try it another way; tell the user if it's needed."
|
|
209
|
+
if verdict == "allow":
|
|
210
|
+
return True, ""
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
def cycle(self) -> None:
|
|
214
|
+
self.mode = PERMISSION_MODES[(PERMISSION_MODES.index(self.mode) + 1) % len(PERMISSION_MODES)]
|
|
215
|
+
|
|
216
|
+
def needs_prompt(self, key: str, kind: str) -> bool:
|
|
217
|
+
if key in self.always or (self.mode == "yolo" and kind != "memory"):
|
|
218
|
+
return False
|
|
219
|
+
return not (kind == "edit" and self.mode == "auto-edit")
|
|
220
|
+
|
|
221
|
+
def request(self, key: str, kind: str, title: str, body: RenderableType, target: str = "") -> tuple[bool, str]:
|
|
222
|
+
"""Ask the user. kind is 'edit', 'bash', 'web', 'mcp' or 'memory'. Returns (allowed, message for the model).
|
|
223
|
+
|
|
224
|
+
Memory notes follow the memory setting rather than the mode: with `memory = ask`, even yolo asks.
|
|
225
|
+
"""
|
|
226
|
+
verdict = self.rule(kind, target)
|
|
227
|
+
if verdict is not None:
|
|
228
|
+
return verdict
|
|
229
|
+
if not self.needs_prompt(key, kind):
|
|
230
|
+
return True, ""
|
|
231
|
+
if self.approve is not None: # a program or a non-interactive run decides
|
|
232
|
+
target = target or key.split(":", 1)[-1]
|
|
233
|
+
if kind == "bash":
|
|
234
|
+
target = LEADING_CD.sub("", target) # the command itself, without `cd <folder> &&`
|
|
235
|
+
verdict = self.approve({"kind": kind, "title": title, "target": target, "key": key})
|
|
236
|
+
if verdict is True:
|
|
237
|
+
return True, ""
|
|
238
|
+
return False, verdict if isinstance(verdict, str) and verdict else NOT_ASKED
|
|
239
|
+
if self.on_prompt:
|
|
240
|
+
self.on_prompt(title)
|
|
241
|
+
self.console.print(Panel(body, title=title, title_align="left", border_style="yellow"))
|
|
242
|
+
scope = {"edit": "file edits", "memory": "memory notes"}.get(kind) or key.split(":", 1)[-1]
|
|
243
|
+
if kind == "mcp":
|
|
244
|
+
scope = scope.replace(":", " › ", 1)
|
|
245
|
+
try:
|
|
246
|
+
answer = input(f" Allow? [y]es / [a]lways for '{scope}' this session / [n]o (+ optional reason): ")
|
|
247
|
+
except EOFError:
|
|
248
|
+
return False, "The user denied this action (no input available)."
|
|
249
|
+
answer = answer.strip()
|
|
250
|
+
if answer.lower() in ("y", "yes", ""):
|
|
251
|
+
return True, ""
|
|
252
|
+
if answer.lower() in ("a", "always"):
|
|
253
|
+
self.always.add("edit" if kind == "edit" else key)
|
|
254
|
+
return True, ""
|
|
255
|
+
# "n", "no", "n <reason>" or any other text, which is taken as the reason.
|
|
256
|
+
reason = re.sub(r"^(no|n)\b[\s,:-]*", "", answer, flags=re.I).strip()
|
|
257
|
+
message = "The user denied this action."
|
|
258
|
+
if reason:
|
|
259
|
+
return False, f"{message} User says: {reason}"
|
|
260
|
+
return False, f"{message} Ask the user how to proceed or choose a different approach."
|
|
@@ -754,6 +754,7 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
|
|
|
754
754
|
agent.messages = agent.messages[:index]
|
|
755
755
|
agent.reflected = min(agent.reflected, index)
|
|
756
756
|
agent.ctx_used = sum(len(json.dumps(m)) for m in agent.messages) // 3
|
|
757
|
+
agent.counted_chars = None
|
|
757
758
|
c.print(f"The conversation is back to before request {target.n} as well.")
|
|
758
759
|
elif plan.restore or plan.remove:
|
|
759
760
|
what = "undid the file changes from" if not rewind else "rolled the files back to before"
|