lcode-cli 0.22.0__tar.gz → 0.24.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.22.0 → lcode_cli-0.24.0}/CHANGELOG.md +36 -1
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/PKG-INFO +1 -1
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/automation.md +3 -2
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/configuration.md +1 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/how-it-works.md +19 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/usage.md +13 -2
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/acp.py +6 -1
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/action.py +4 -2
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/agent.py +109 -19
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/api.py +5 -4
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/cli.py +4 -0
- lcode_cli-0.24.0/src/lcode/clipboard.py +139 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/config.py +1 -0
- lcode_cli-0.24.0/src/lcode/repeats.py +104 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/repl.py +22 -1
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/subagents.py +8 -3
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_agent.py +17 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_api.py +8 -0
- lcode_cli-0.24.0/tests/test_clipboard.py +118 -0
- lcode_cli-0.24.0/tests/test_repeats.py +88 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_subagents.py +14 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_vision.py +31 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.editorconfig +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.gitignore +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/AGENTS.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/LICENSE +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/README.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/SECURITY.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/action.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/agents.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/bench.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/changelog.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/commands.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/contributing.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/editors.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/git.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/hooks.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/index.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/installation.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/lsp.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/mcp.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/memory.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/models.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/search.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/servers.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/examples/README.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/install.sh +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/mkdocs.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/overrides/main.html +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/pyproject.toml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/tools.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/conftest.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_action.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_config.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_context.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_update.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_web.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.24.0}/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.24.0] - 2026-10-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Ctrl+V in lcode's prompt pastes a screenshot: lcode reads the clipboard (with `wl-paste` on
|
|
14
|
+
Wayland, `xclip` on X11, `pngpaste` or `osascript` on macOS), saves the image and attaches it as
|
|
15
|
+
`@path`. Other clipboard content is pasted as text. `lcode doctor` shows whether it works here.
|
|
16
|
+
- Image paths in a message are attached without `@`: absolute and `~` paths, quoted paths with
|
|
17
|
+
spaces, and `file://` paths dragged into the terminal. `@"path with spaces"` works for any file.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- An image path that doesn't exist is no longer ignored: lcode says so, and tells the model it can't
|
|
22
|
+
see that image, so it asks instead of guessing what the picture shows. See
|
|
23
|
+
[Images](https://nasser1941.github.io/lcode/usage/#images).
|
|
24
|
+
|
|
25
|
+
## [0.23.0] - 2026-10-10
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- lcode notices when the model goes round in circles, making the same tool call with the same result
|
|
30
|
+
again and again, as small models sometimes do. The third time in a request (the second, for a call
|
|
31
|
+
that failed) the result tells the model so; two more, and lcode ends the request (status `loop`,
|
|
32
|
+
exit code `4` with `lcode -p`). Results that change, and checks run after an edit, don't count.
|
|
33
|
+
`repeat_limit` changes the threshold; `0` turns it off. See
|
|
34
|
+
[When the model repeats itself](https://nasser1941.github.io/lcode/how-it-works/#when-the-model-repeats-itself).
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- A tool call Ollama can't parse because its XML is broken (`XML syntax error … unexpected EOF`, from
|
|
39
|
+
models such as Qwen3.6 that write their calls in XML) is now sent back to the model to make again,
|
|
40
|
+
like a call with broken JSON, instead of ending the request with an error.
|
|
41
|
+
|
|
9
42
|
## [0.22.0] - 2026-10-06
|
|
10
43
|
|
|
11
44
|
### Added
|
|
@@ -491,7 +524,9 @@ First public release.
|
|
|
491
524
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
492
525
|
- One-line installer for Linux and macOS.
|
|
493
526
|
|
|
494
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
527
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.24.0...HEAD
|
|
528
|
+
[0.24.0]: https://github.com/nasser1941/lcode/compare/v0.23.0...v0.24.0
|
|
529
|
+
[0.23.0]: https://github.com/nasser1941/lcode/compare/v0.22.0...v0.23.0
|
|
495
530
|
[0.22.0]: https://github.com/nasser1941/lcode/compare/v0.21.0...v0.22.0
|
|
496
531
|
[0.21.0]: https://github.com/nasser1941/lcode/compare/v0.20.0...v0.21.0
|
|
497
532
|
[0.20.0]: https://github.com/nasser1941/lcode/compare/v0.19.0...v0.20.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.24.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/
|
|
@@ -23,7 +23,8 @@ lcode -p "Explain the retry logic" --output stream-json --max-steps 20
|
|
|
23
23
|
| `--allowed-tools LIST` | Only these tools, e.g. `"read_file,grep,glob,bash"` |
|
|
24
24
|
|
|
25
25
|
lcode's usual output goes to stderr, so stdout holds only the JSON. The exit code says how it
|
|
26
|
-
went: `0` success, `1` an error, `3` stopped at `--max-steps`, `
|
|
26
|
+
went: `0` success, `1` an error, `3` stopped at `--max-steps`, `4` stopped because the model
|
|
27
|
+
[kept repeating itself](how-it-works.md#when-the-model-repeats-itself), `130` interrupted.
|
|
27
28
|
|
|
28
29
|
### The result
|
|
29
30
|
|
|
@@ -49,7 +50,7 @@ went: `0` success, `1` an error, `3` stopped at `--max-steps`, `130` interrupted
|
|
|
49
50
|
|
|
50
51
|
| Field | |
|
|
51
52
|
|---|---|
|
|
52
|
-
| `status` | `success`, `error`, `max_steps` or `interrupted` |
|
|
53
|
+
| `status` | `success`, `error`, `max_steps`, `loop` (the model kept repeating the same call) or `interrupted` |
|
|
53
54
|
| `text` | The model's final answer |
|
|
54
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 |
|
|
55
56
|
| `files_changed` | Files the request added (`A`), modified (`M`) or deleted (`D`), shell commands included |
|
|
@@ -42,6 +42,7 @@ lcode config path # print the file location
|
|
|
42
42
|
| `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)) |
|
|
43
43
|
| `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
|
|
44
44
|
| `notify_after` | `30` | Seconds a request runs before it notifies |
|
|
45
|
+
| `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 |
|
|
45
46
|
| `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
|
|
46
47
|
|
|
47
48
|
Besides these settings, `config.toml` can hold [hooks](hooks.md) (`[[hooks]]`) and
|
|
@@ -29,6 +29,25 @@ All tools return text, and failures come back to the model as `Error: …` messa
|
|
|
29
29
|
crashing the session, so the model can correct itself (for example re-read a file when an edit
|
|
30
30
|
didn't match).
|
|
31
31
|
|
|
32
|
+
### When the model repeats itself
|
|
33
|
+
|
|
34
|
+
Small models sometimes fall into a loop: they make a call, get a result, and make the very same call
|
|
35
|
+
again, and again. lcode counts, within a request, the calls that repeat an earlier one exactly: the
|
|
36
|
+
same tool, the same arguments and the same result.
|
|
37
|
+
|
|
38
|
+
- **At the third one** (the second, when the call failed) the result gets a note: the call returned
|
|
39
|
+
the same thing several times, so the model should use what it got, try something different, or
|
|
40
|
+
tell you what's blocking it.
|
|
41
|
+
- **Two more,** and lcode ends the request with
|
|
42
|
+
`Stopped: the model kept making the same call with the same result`. The JSON output's status is
|
|
43
|
+
`loop`.
|
|
44
|
+
|
|
45
|
+
A result that changes isn't a repeat, so a test that's run again after a fix, or a background job
|
|
46
|
+
that's checked while it runs, never adds up. A call that may have changed something, like an edit or
|
|
47
|
+
a shell command that isn't read-only, starts the count again for the other calls: checking the build
|
|
48
|
+
after each edit is fine. Change the threshold with `lcode config set repeat_limit 5`, or turn it off
|
|
49
|
+
with `0`.
|
|
50
|
+
|
|
32
51
|
## Subagents
|
|
33
52
|
|
|
34
53
|
The `agent` tool starts a [subagent](agents.md): another agent loop with its own conversation, the
|
|
@@ -154,8 +154,19 @@ lcode can look at screenshots, mockups, diagrams and photos:
|
|
|
154
154
|
❯ make the settings page match @design/settings.png
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
-
|
|
158
|
-
|
|
157
|
+
- **Paste a screenshot with ++ctrl+v++.** A terminal can only paste text, so lcode reads the
|
|
158
|
+
clipboard itself, saves the image in its state folder (`~/.local/state/lcode/pasted/`, the last
|
|
159
|
+
50) and puts `@path` in the prompt. Anything else on the clipboard is pasted as text. It needs
|
|
160
|
+
`wl-clipboard` on Wayland or `xclip` on X11 (`sudo apt install wl-clipboard`); on macOS it works
|
|
161
|
+
as is, and faster with `brew install pngpaste`. `lcode doctor` shows whether it works.
|
|
162
|
+
- Attach an image with `@path`, like a file, or just write its path: `/tmp/shot.png`,
|
|
163
|
+
`~/Pictures/bug.png`, `'./shots/My Shot.png'` and `file://` paths dragged into the terminal are
|
|
164
|
+
attached too. lcode describes the image in detail, with all visible text transcribed and with your
|
|
165
|
+
question in mind, and the model works from that description.
|
|
166
|
+
- **A path to an image that doesn't exist** is reported right away
|
|
167
|
+
(`/tmp/pasted-image-.png doesn't exist, so the model can't see it`), and the model is told it
|
|
168
|
+
can't see the image, so it asks instead of guessing what it shows. A bare name such as `logo.png`
|
|
169
|
+
may be a file you want created, so it's left alone.
|
|
159
170
|
- The model can open images itself with the `view_image` tool, for example a screenshot a test wrote.
|
|
160
171
|
- Screenshots that [MCP](mcp.md) tools return, such as the Playwright browser's, are described too.
|
|
161
172
|
|
|
@@ -48,7 +48,12 @@ KINDS = {
|
|
|
48
48
|
"bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
|
|
49
49
|
"present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
|
|
50
50
|
} # fmt: skip
|
|
51
|
-
STOP = {
|
|
51
|
+
STOP = {
|
|
52
|
+
"success": "end_turn",
|
|
53
|
+
"max_steps": "max_turn_requests",
|
|
54
|
+
"loop": "max_turn_requests",
|
|
55
|
+
"interrupted": "cancelled",
|
|
56
|
+
}
|
|
52
57
|
MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
|
|
53
58
|
METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR, NOT_FOUND = -32601, -32602, -32603, -32002
|
|
54
59
|
|
|
@@ -297,7 +297,7 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
|
|
|
297
297
|
)
|
|
298
298
|
with open_session(inputs, cwd) as session:
|
|
299
299
|
result = session.run(prompt)
|
|
300
|
-
if result.status not in ("success", "max_steps"):
|
|
300
|
+
if result.status not in ("success", "max_steps", "loop"):
|
|
301
301
|
raise ActionError(result.error or result.status)
|
|
302
302
|
reply = result.text.strip() or "(no answer)"
|
|
303
303
|
changed = git(cwd, "status", "--porcelain").strip()
|
|
@@ -336,6 +336,8 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
|
|
|
336
336
|
reply += f"\n\n{link}"
|
|
337
337
|
if result.status == "max_steps":
|
|
338
338
|
reply += f"\n\n(Stopped after {inputs.max_steps} steps.)"
|
|
339
|
+
elif result.status == "loop":
|
|
340
|
+
reply += "\n\n(Stopped: the model kept repeating the same call.)"
|
|
339
341
|
hub.comment(task.number, reply[:MAX_COMMENT] + footer(result))
|
|
340
342
|
|
|
341
343
|
|
|
@@ -349,7 +351,7 @@ def review(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
|
|
|
349
351
|
prompt = gitflow.review_prompt(session.agent, base)
|
|
350
352
|
session.agent.no_changes = "This is a review, so nothing can be changed: report what should change instead."
|
|
351
353
|
result = session.run(prompt)
|
|
352
|
-
if result.status not in ("success", "max_steps"):
|
|
354
|
+
if result.status not in ("success", "max_steps", "loop"):
|
|
353
355
|
raise ActionError(result.error or result.status)
|
|
354
356
|
hub.comment(task.number, f"**lcode review**\n\n{result.text.strip()[:MAX_COMMENT]}{footer(result)}")
|
|
355
357
|
|
|
@@ -11,6 +11,7 @@ import shutil
|
|
|
11
11
|
import subprocess
|
|
12
12
|
import threading
|
|
13
13
|
import time
|
|
14
|
+
import urllib.parse
|
|
14
15
|
import uuid
|
|
15
16
|
from collections.abc import Callable
|
|
16
17
|
from dataclasses import dataclass
|
|
@@ -32,6 +33,7 @@ from lcode.mcp import McpManager
|
|
|
32
33
|
from lcode.ollama import Ollama, OllamaError
|
|
33
34
|
from lcode.permissions import Permissions
|
|
34
35
|
from lcode.render import MarkdownStreamer
|
|
36
|
+
from lcode.repeats import Repeats
|
|
35
37
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
36
38
|
from lcode.tools import (
|
|
37
39
|
LSP_SCHEMA,
|
|
@@ -57,9 +59,20 @@ MAX_STEPS_PER_TURN = 150
|
|
|
57
59
|
EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
58
60
|
# How a tool result starts when the call failed or wasn't allowed (an "error" in events and results).
|
|
59
61
|
REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
|
|
60
|
-
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
62
|
+
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
63
|
+
# How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
|
|
64
|
+
MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
|
|
61
65
|
AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
|
|
62
66
|
PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
|
|
67
|
+
# @path, @"path with spaces" or @'path'
|
|
68
|
+
MENTION = re.compile(r"""(?<!\S)@(?:"([^"\n]+)"|'([^'\n]+)'|([\w./~\-]+))""")
|
|
69
|
+
# An image path written in a message: in quotes, or without spaces (a space escaped as "\ " is fine), or as a
|
|
70
|
+
# file:// address, the way terminals paste a dragged file.
|
|
71
|
+
IMAGE_PATH = re.compile(
|
|
72
|
+
r"""(['"])((?:file://)?[^'"\n]+?\.(?:png|jpe?g|webp|gif))\1"""
|
|
73
|
+
r"""|(?<![^\s(])((?:[^\s\\'"(]|\\ )+?\.(?:png|jpe?g|webp|gif))(?=[\s,;:!?)\]]|\.(?:\s|$)|$)""",
|
|
74
|
+
re.IGNORECASE,
|
|
75
|
+
)
|
|
63
76
|
PROJECT_FILES = ("AGENTS.md", "LCODE.md", "CLAUDE.md")
|
|
64
77
|
|
|
65
78
|
SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent running in the user's terminal on their own machine, powered by the local model {model}. You help the user understand codebases, answer questions about code, write scripts (Python by default), fix bugs, refactor, and run commands.
|
|
@@ -216,6 +229,7 @@ class Settings:
|
|
|
216
229
|
notify: bool = False # desktop notifications after long requests (lcode.notify)
|
|
217
230
|
notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
|
|
218
231
|
max_parallel_agents: int = 1
|
|
232
|
+
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
219
233
|
|
|
220
234
|
|
|
221
235
|
class Agent:
|
|
@@ -267,7 +281,8 @@ class Agent:
|
|
|
267
281
|
# Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
|
|
268
282
|
self.plan_review: Callable[[str, str], str] | None = None
|
|
269
283
|
self.max_steps = MAX_STEPS_PER_TURN
|
|
270
|
-
self.turn_status = "" # how the last request ended: success, max_steps
|
|
284
|
+
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
285
|
+
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
271
286
|
self.response = None # the streaming response, so another thread can abort it
|
|
272
287
|
self.ctx_used = 0
|
|
273
288
|
self.last_speed = 0.0
|
|
@@ -809,7 +824,9 @@ class Agent:
|
|
|
809
824
|
self.turn_status = "running"
|
|
810
825
|
note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
|
|
811
826
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
827
|
+
self.repeats.reset()
|
|
812
828
|
malformed = 0
|
|
829
|
+
looping = "" # the call the model kept repeating, when lcode stopped the request for it
|
|
813
830
|
for step in range(self.max_steps):
|
|
814
831
|
if self.cancel is not None and self.cancel.is_set():
|
|
815
832
|
raise KeyboardInterrupt
|
|
@@ -834,17 +851,22 @@ class Agent:
|
|
|
834
851
|
}
|
|
835
852
|
)
|
|
836
853
|
except OllamaError as e:
|
|
837
|
-
if
|
|
854
|
+
if not any(m in str(e) for m in MALFORMED_CALL) or malformed >= MAX_MALFORMED_CALL_RETRIES:
|
|
838
855
|
raise
|
|
839
856
|
malformed += 1
|
|
840
|
-
|
|
857
|
+
if "err=" in str(e):
|
|
858
|
+
reason = str(e).rsplit("err=", 1)[-1].strip()
|
|
859
|
+
elif "XML syntax error" in str(e):
|
|
860
|
+
reason = str(e).split("XML syntax error", 1)[1].strip(" :") or "invalid XML"
|
|
861
|
+
else:
|
|
862
|
+
reason = "invalid JSON"
|
|
841
863
|
self.console.print("[yellow]The model wrote a malformed tool call; asking it to try again.[/]")
|
|
842
864
|
self.messages.append(
|
|
843
865
|
{
|
|
844
866
|
"role": "user",
|
|
845
|
-
"content": f"[lcode] Your last tool call could not be parsed ({reason}):
|
|
846
|
-
"
|
|
847
|
-
"make sure the whole content is included and properly escaped.",
|
|
867
|
+
"content": f"[lcode] Your last tool call could not be parsed ({reason}): it must be "
|
|
868
|
+
"complete and well-formed, with every argument closed. Make the call again. If it writes "
|
|
869
|
+
"a file, make sure the whole content is included and properly escaped.",
|
|
848
870
|
}
|
|
849
871
|
)
|
|
850
872
|
continue
|
|
@@ -892,6 +914,15 @@ class Agent:
|
|
|
892
914
|
self.console.print(Text(f" {result[:300]}", style="red"))
|
|
893
915
|
elif name in ("read_file", "grep", "glob", "list_dir"):
|
|
894
916
|
self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
|
|
917
|
+
verdict = self.repeats.check(name, args, result)
|
|
918
|
+
if verdict.note:
|
|
919
|
+
result += verdict.note
|
|
920
|
+
what = "stopping the request" if verdict.stop else "told the model"
|
|
921
|
+
self.console.print(
|
|
922
|
+
Text(f" ⎿ same call, same result, {verdict.count} times: {what}", style="yellow")
|
|
923
|
+
)
|
|
924
|
+
if verdict.stop:
|
|
925
|
+
looping = self.describe_call(name, args)
|
|
895
926
|
tool_msg = {"role": "tool", "tool_name": name, "content": result}
|
|
896
927
|
if call.get("id"):
|
|
897
928
|
tool_msg["tool_call_id"] = call["id"]
|
|
@@ -905,6 +936,27 @@ class Agent:
|
|
|
905
936
|
"output": result[:EVENT_OUTPUT] + ("…" if len(result) > EVENT_OUTPUT else ""),
|
|
906
937
|
}
|
|
907
938
|
)
|
|
939
|
+
if looping:
|
|
940
|
+
for rest in calls[i + 1 :]:
|
|
941
|
+
skipped = {
|
|
942
|
+
"role": "tool",
|
|
943
|
+
"tool_name": rest.get("function", {}).get("name", ""),
|
|
944
|
+
"content": "Not run: lcode stopped the request before this call.",
|
|
945
|
+
}
|
|
946
|
+
if rest.get("id"):
|
|
947
|
+
skipped["tool_call_id"] = rest["id"]
|
|
948
|
+
self.messages.append(skipped)
|
|
949
|
+
break
|
|
950
|
+
if looping:
|
|
951
|
+
self.turn_status = "loop"
|
|
952
|
+
self.console.print(
|
|
953
|
+
Text(
|
|
954
|
+
f"Stopped: the model kept making the same call with the same result: {looping}. Tell it "
|
|
955
|
+
"what to do differently, or try again.",
|
|
956
|
+
style="yellow",
|
|
957
|
+
)
|
|
958
|
+
)
|
|
959
|
+
return
|
|
908
960
|
else:
|
|
909
961
|
self.turn_status = "max_steps"
|
|
910
962
|
self.console.print(f"[yellow]Stopped after {self.max_steps} steps.[/]")
|
|
@@ -923,30 +975,68 @@ class Agent:
|
|
|
923
975
|
)
|
|
924
976
|
|
|
925
977
|
def expand_mentions(self, text: str) -> str:
|
|
926
|
-
"""
|
|
927
|
-
|
|
928
|
-
|
|
978
|
+
"""Attach what the user's message points to: files and folders written as @path, image paths (with or
|
|
979
|
+
without @), and @name of an agent asks for that agent. An image path that doesn't exist is reported to
|
|
980
|
+
the user and the model, so the model doesn't guess what the image shows."""
|
|
981
|
+
attached: list[str] = []
|
|
982
|
+
seen: set[Path] = set()
|
|
983
|
+
question = MENTION.sub("", text)
|
|
984
|
+
|
|
985
|
+
def attach_image(p: Path) -> None:
|
|
986
|
+
try:
|
|
987
|
+
description = self.look(p, question)
|
|
988
|
+
who = f' described_by="{self.vision_model()}"'
|
|
989
|
+
except ToolError as e:
|
|
990
|
+
description, who = f"(lcode couldn't look at this image: {e})", ""
|
|
991
|
+
self.console.print(Text(f" ⎿ {e}", style="yellow"))
|
|
992
|
+
else:
|
|
993
|
+
self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
|
|
994
|
+
attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
|
|
995
|
+
|
|
996
|
+
def missing_image(shown: str) -> None:
|
|
997
|
+
self.console.print(
|
|
998
|
+
Text(f" ⎿ {shown} doesn't exist, so the model can't see it. Paste images with Ctrl+V.", style="yellow")
|
|
999
|
+
)
|
|
1000
|
+
attached.append(
|
|
1001
|
+
f"[lcode: the user's message mentions the image {shown}, but there's no such file, so it wasn't "
|
|
1002
|
+
"attached and you can't see it. Don't guess what it shows: ask the user to paste it again (Ctrl+V "
|
|
1003
|
+
"in lcode's prompt) or to describe it.]"
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
for match in MENTION.finditer(text):
|
|
1007
|
+
ref = next(g for g in match.groups() if g)
|
|
929
1008
|
p = self.tools.resolve(ref)
|
|
1009
|
+
seen.add(p)
|
|
930
1010
|
name = ref.removeprefix("agent-")
|
|
931
1011
|
if not p.exists() and self.settings.subagents and name in self.agent_types():
|
|
932
1012
|
attached.append(f'[The user wants the {name} agent for this: call the agent tool with type "{name}".]')
|
|
933
1013
|
continue
|
|
934
1014
|
if p.is_file() and vision.is_image(p):
|
|
935
|
-
|
|
936
|
-
description = self.look(p, re.sub(r"(?<!\S)@[\w./~\-]+", "", text))
|
|
937
|
-
who = f' described_by="{self.vision_model()}"'
|
|
938
|
-
except ToolError as e:
|
|
939
|
-
description, who = f"(lcode couldn't look at this image: {e})", ""
|
|
940
|
-
self.console.print(Text(f" ⎿ {e}", style="yellow"))
|
|
941
|
-
else:
|
|
942
|
-
self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
|
|
943
|
-
attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
|
|
1015
|
+
attach_image(p)
|
|
944
1016
|
elif p.is_file() and not is_binary(p) and p.stat().st_size < 200_000:
|
|
945
1017
|
attached.append(f'<file path="{self.tools.rel(p)}">\n{p.read_text(errors="replace")}\n</file>')
|
|
946
1018
|
self.tools.read_mtimes[str(p)] = p.stat().st_mtime
|
|
947
1019
|
self.console.print(Text(f" ⎿ attached {self.tools.rel(p)}", style="dim"))
|
|
948
1020
|
elif p.is_dir():
|
|
949
1021
|
attached.append(f'<directory path="{self.tools.rel(p)}">\n{tree(p, 2)}\n</directory>')
|
|
1022
|
+
elif not p.exists() and vision.is_image(p):
|
|
1023
|
+
missing_image(ref)
|
|
1024
|
+
for match in IMAGE_PATH.finditer(text):
|
|
1025
|
+
quoted, raw = bool(match.group(2)), match.group(2) or match.group(3)
|
|
1026
|
+
if raw.startswith("@") or "://" in raw.removeprefix("file://"):
|
|
1027
|
+
continue # attached above, or a web address
|
|
1028
|
+
if raw.startswith("file://"):
|
|
1029
|
+
shown = urllib.parse.unquote(raw.removeprefix("file://"))
|
|
1030
|
+
else:
|
|
1031
|
+
shown = raw.replace("\\ ", " ")
|
|
1032
|
+
p = self.tools.resolve(shown)
|
|
1033
|
+
if p in seen:
|
|
1034
|
+
continue
|
|
1035
|
+
seen.add(p)
|
|
1036
|
+
if p.is_file():
|
|
1037
|
+
attach_image(p)
|
|
1038
|
+
elif quoted or raw.startswith(("/", "~", "./", "../", "file://")):
|
|
1039
|
+
missing_image(shown) # a name without a folder may be a file to create: no warning
|
|
950
1040
|
return text + ("\n\n" + "\n\n".join(attached) if attached else "")
|
|
951
1041
|
|
|
952
1042
|
# -- context management
|
|
@@ -35,8 +35,8 @@ if TYPE_CHECKING:
|
|
|
35
35
|
from lcode.hardware import Hardware
|
|
36
36
|
|
|
37
37
|
SCHEMA_VERSION = 1
|
|
38
|
-
STATUSES = ("success", "error", "max_steps", "interrupted")
|
|
39
|
-
EXIT_CODES = {"success": 0, "error": 1, "max_steps": 3, "interrupted": 130}
|
|
38
|
+
STATUSES = ("success", "error", "max_steps", "loop", "interrupted")
|
|
39
|
+
EXIT_CODES = {"success": 0, "error": 1, "max_steps": 3, "loop": 4, "interrupted": 130}
|
|
40
40
|
OLLAMA_INSTALL = {
|
|
41
41
|
"linux": "curl -fsSL https://ollama.com/install.sh | sh",
|
|
42
42
|
"darwin": "brew install ollama (or download it from https://ollama.com/download)",
|
|
@@ -69,7 +69,7 @@ class Options:
|
|
|
69
69
|
class Result:
|
|
70
70
|
"""How a request went. `to_json()` is what `lcode -p --output json` prints."""
|
|
71
71
|
|
|
72
|
-
status: str # success, error, max_steps or interrupted
|
|
72
|
+
status: str # success, error, max_steps, loop (stopped: the model kept repeating itself) or interrupted
|
|
73
73
|
text: str # the model's final answer
|
|
74
74
|
session_id: str
|
|
75
75
|
model: str
|
|
@@ -194,6 +194,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
194
194
|
embed_model=cfg["embed_model"],
|
|
195
195
|
notify=cfg["notify"],
|
|
196
196
|
notify_after=cfg["notify_after"],
|
|
197
|
+
repeat_limit=cfg["repeat_limit"],
|
|
197
198
|
)
|
|
198
199
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
199
200
|
agent.interactive = options.interactive
|
|
@@ -272,7 +273,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
272
273
|
|
|
273
274
|
try:
|
|
274
275
|
agent.run_turn(prompt)
|
|
275
|
-
status =
|
|
276
|
+
status = agent.turn_status if agent.turn_status in ("max_steps", "loop") else "success"
|
|
276
277
|
except KeyboardInterrupt:
|
|
277
278
|
status, error = "interrupted", "interrupted before the request was done"
|
|
278
279
|
except ModelError as e:
|
|
@@ -346,6 +346,10 @@ def cmd_doctor(args) -> None:
|
|
|
346
346
|
line("Vision", "off; set vision_model to a model the server serves that can see images", None)
|
|
347
347
|
else:
|
|
348
348
|
line("Vision", "off" if cfg["vision_model"] == "off" else vision.INSTALL_HINT, None)
|
|
349
|
+
if seer:
|
|
350
|
+
from lcode import clipboard
|
|
351
|
+
|
|
352
|
+
line("Clipboard", *clipboard.status())
|
|
349
353
|
if limits.get(model):
|
|
350
354
|
line(
|
|
351
355
|
"Limit",
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Paste from the clipboard with Ctrl+V, images included.
|
|
2
|
+
|
|
3
|
+
A terminal can only paste text, so a screenshot never reaches lcode that way. On Ctrl+V lcode reads the
|
|
4
|
+
clipboard itself: with wl-paste on Wayland, xclip on X11, and pngpaste or osascript on macOS. An image is
|
|
5
|
+
saved in lcode's state folder and goes into the prompt as `@path`, so it's attached like any other image;
|
|
6
|
+
anything else is pasted as text.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from lcode import config
|
|
20
|
+
|
|
21
|
+
TIMEOUT = 5 # seconds for a clipboard tool
|
|
22
|
+
IMAGE_TYPES = ("image/png", "image/jpeg", "image/webp", "image/gif")
|
|
23
|
+
SUFFIXES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
|
|
24
|
+
KEEP = 50 # pasted images kept; older ones are removed
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Paste:
|
|
29
|
+
image: Path | None = None # a saved image
|
|
30
|
+
text: str = "" # or text
|
|
31
|
+
problem: str = "" # why nothing could be pasted
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(argv: list[str]) -> bytes | None:
|
|
35
|
+
try:
|
|
36
|
+
done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT, check=False)
|
|
37
|
+
except (OSError, subprocess.SubprocessError):
|
|
38
|
+
return None
|
|
39
|
+
return done.stdout if done.returncode == 0 else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def folder() -> Path:
|
|
43
|
+
return config.STATE_DIR / "pasted"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save(data: bytes, suffix: str) -> Path:
|
|
47
|
+
where = folder()
|
|
48
|
+
where.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
old = sorted(where.glob("pasted-*")) # the names sort by time
|
|
50
|
+
for path in old[: max(0, len(old) - KEEP + 1)]:
|
|
51
|
+
path.unlink(missing_ok=True)
|
|
52
|
+
now = time.time_ns()
|
|
53
|
+
stamp = f"{time.strftime('%Y%m%d-%H%M%S', time.localtime(now / 1e9))}-{now % 1_000_000_000:09d}"
|
|
54
|
+
path = where / f"pasted-{stamp}{suffix}"
|
|
55
|
+
n = 1
|
|
56
|
+
while path.exists():
|
|
57
|
+
n += 1
|
|
58
|
+
path = where / f"pasted-{stamp}-{n}{suffix}"
|
|
59
|
+
path.write_bytes(data)
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pick(types: list[str]) -> str | None:
|
|
64
|
+
return next((t for t in IMAGE_TYPES if t in types), None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def paste() -> Paste:
|
|
68
|
+
"""Whatever is on the clipboard: an image (saved to a file) or text."""
|
|
69
|
+
if sys.platform == "darwin":
|
|
70
|
+
return paste_mac()
|
|
71
|
+
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-paste"):
|
|
72
|
+
return paste_with(
|
|
73
|
+
["wl-paste", "--list-types"],
|
|
74
|
+
lambda kind: ["wl-paste", "--no-newline", "--type", kind],
|
|
75
|
+
["wl-paste", "--no-newline"],
|
|
76
|
+
)
|
|
77
|
+
if os.environ.get("DISPLAY") and shutil.which("xclip"):
|
|
78
|
+
return paste_with(
|
|
79
|
+
["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
|
|
80
|
+
lambda kind: ["xclip", "-selection", "clipboard", "-t", kind, "-o"],
|
|
81
|
+
["xclip", "-selection", "clipboard", "-o"],
|
|
82
|
+
)
|
|
83
|
+
return Paste(problem=status()[0])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def status() -> tuple[str, bool | None]:
|
|
87
|
+
"""Whether Ctrl+V can paste images here, and if not, what to do (for the paste and `lcode doctor`)."""
|
|
88
|
+
if sys.platform == "darwin":
|
|
89
|
+
return "Ctrl+V pastes images" + (" (with pngpaste)" if shutil.which("pngpaste") else ""), True
|
|
90
|
+
if os.environ.get("WAYLAND_DISPLAY"):
|
|
91
|
+
if shutil.which("wl-paste"):
|
|
92
|
+
return "Ctrl+V pastes images (with wl-paste)", True
|
|
93
|
+
return "To paste images with Ctrl+V, install wl-clipboard (for example `sudo apt install wl-clipboard`).", None
|
|
94
|
+
if os.environ.get("DISPLAY"):
|
|
95
|
+
if shutil.which("xclip"):
|
|
96
|
+
return "Ctrl+V pastes images (with xclip)", True
|
|
97
|
+
return "To paste images with Ctrl+V, install xclip (for example `sudo apt install xclip`).", None
|
|
98
|
+
return "There's no clipboard here (no desktop session), so give an image by its path.", None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def paste_with(list_types: list[str], get, get_text: list[str]) -> Paste:
|
|
102
|
+
types = (run(list_types) or b"").decode(errors="replace").split()
|
|
103
|
+
kind = pick(types)
|
|
104
|
+
if kind:
|
|
105
|
+
data = run(get(kind))
|
|
106
|
+
if data:
|
|
107
|
+
return Paste(image=save(data, SUFFIXES[kind]))
|
|
108
|
+
text = run(get_text)
|
|
109
|
+
if text:
|
|
110
|
+
return Paste(text=text.decode(errors="replace"))
|
|
111
|
+
return Paste(problem="The clipboard is empty.")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def paste_mac() -> Paste:
|
|
115
|
+
if shutil.which("pngpaste"):
|
|
116
|
+
data = run(["pngpaste", "-"])
|
|
117
|
+
if data:
|
|
118
|
+
return Paste(image=save(data, ".png"))
|
|
119
|
+
else:
|
|
120
|
+
target = folder() / ".clipboard.png"
|
|
121
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
target.unlink(missing_ok=True)
|
|
123
|
+
script = [
|
|
124
|
+
"try",
|
|
125
|
+
"set png to (the clipboard as «class PNGf»)",
|
|
126
|
+
f'set f to open for access POSIX file "{target}" with write permission',
|
|
127
|
+
"write png to f",
|
|
128
|
+
"close access f",
|
|
129
|
+
"end try",
|
|
130
|
+
]
|
|
131
|
+
run(["osascript", *[arg for line in script for arg in ("-e", line)]])
|
|
132
|
+
if target.exists() and target.stat().st_size:
|
|
133
|
+
data = target.read_bytes()
|
|
134
|
+
target.unlink(missing_ok=True)
|
|
135
|
+
return Paste(image=save(data, ".png"))
|
|
136
|
+
text = run(["pbpaste"])
|
|
137
|
+
if text:
|
|
138
|
+
return Paste(text=text.decode(errors="replace"))
|
|
139
|
+
return Paste(problem="The clipboard is empty.")
|
|
@@ -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
|
+
"repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
|
|
62
63
|
"max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
|
|
63
64
|
}
|
|
64
65
|
ENV_OVERRIDES = {
|