lcode-cli 0.36.0__tar.gz → 0.38.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.36.0 → lcode_cli-0.38.0}/CHANGELOG.md +48 -1
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/PKG-INFO +1 -1
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/configuration.md +1 -1
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/how-it-works.md +4 -2
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/models.md +98 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/usage.md +29 -3
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/agent.py +35 -6
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/checks.py +95 -17
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/cli.py +8 -0
- lcode_cli-0.38.0/src/lcode/cloud.py +46 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/escalate.py +14 -2
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/ollama.py +6 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/repeats.py +11 -2
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/repl.py +8 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/residency.py +3 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/screenshots.py +237 -10
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/tools.py +15 -1
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_checks.py +30 -0
- lcode_cli-0.38.0/tests/test_cloud.py +86 -0
- lcode_cli-0.38.0/tests/test_look.py +227 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_stuck.py +13 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.editorconfig +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.gitignore +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/AGENTS.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/LICENSE +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/README.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/SECURITY.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/action.yml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/agents.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/automation.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/bench.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/changelog.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/commands.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/contributing.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/editors.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/git.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/hooks.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/index.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/installation.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/lsp.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/mcp.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/memory.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/search.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/servers.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/examples/README.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/install.sh +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/mkdocs.yml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/overrides/main.html +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/pyproject.toml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/scripts/make_logos.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/acp.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/api.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/config.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/matching.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/review.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/steer.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/unreachable.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/conftest.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_action.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_api.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_config.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_context.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_escalate.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_matching.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_model_picker.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_repeats.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_residency.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_review.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_robustness.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_screenshots.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_steer.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_update.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_web.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.36.0 → lcode_cli-0.38.0}/uv.lock +0 -0
|
@@ -6,6 +6,51 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.38.0] - 2026-10-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- The screenshot tool can click first. For a Python Qt program, `actions` run before the picture,
|
|
14
|
+
the way a user would: `double_click Camera 1` (a widget by its text) or `double_click 300,200`,
|
|
15
|
+
`click`, `right_click`, `key Escape`, `key ctrl+s`, `type …`, `wait 2`. The result says what each
|
|
16
|
+
step hit. Bugs that only show after a click were out of reach before.
|
|
17
|
+
- Qt's warnings are listed at the top of a screenshot's report, taken from everything the program
|
|
18
|
+
printed. In a real run the cause (`setLayout: … which already has a layout`) was there, among 30
|
|
19
|
+
lines of the program's logs, and the model looked past it.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
From a real session that "fixed" a double-click feature four times without once looking at the
|
|
24
|
+
window, while the user saw a blank screen each time:
|
|
25
|
+
|
|
26
|
+
- **Windows it never looked at** are sent back once before the answer counts. When a request
|
|
27
|
+
changed a Python file that draws a window (PyQt, PySide, tkinter, Kivy, wx, GTK) and the model
|
|
28
|
+
never took a screenshot or looked at an image, it's told to look (with clicks first, if that's
|
|
29
|
+
where the change shows) or to say plainly that it's unchecked. The real cause was in what Qt
|
|
30
|
+
printed: `setLayout: … which already has a layout`.
|
|
31
|
+
- **A rerun that only took a different time** counts as a repeat: the model ran the same tests four
|
|
32
|
+
times in a row (`75 passed in 0.14s`, then `0.12s`…) without the repeat check noticing.
|
|
33
|
+
- **A reply lcode drops** for going round in circles is saved, and the message says where, so you
|
|
34
|
+
can read what the model was thinking instead of taking the stop on trust.
|
|
35
|
+
- **The checks use the project's own Python.** They ran `python -m …` from PATH, which doesn't have
|
|
36
|
+
the project's dependencies, so a project with a `.venv` "failed" its checks on imports. Now
|
|
37
|
+
`.venv/bin/python` (or `venv`, `env`) runs them.
|
|
38
|
+
- **Tests written for pytest get pytest** when the project has no pytest configuration: the tests
|
|
39
|
+
import it, or a requirements file lists it. They used to get `unittest`, which can't run them.
|
|
40
|
+
|
|
41
|
+
## [0.37.0] - 2026-10-20
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Ollama's cloud models (tags ending in `cloud`, such as `minimax-m3.1-flash:cloud`) work as the
|
|
46
|
+
main model or as the stronger model when stuck, and lcode says what they are: what it sends goes
|
|
47
|
+
to ollama.com and is billed per token. The stronger-model panel said "free, on this machine" for
|
|
48
|
+
them; it now says ollama.com and shows the cost. A request Ollama refuses because it isn't signed
|
|
49
|
+
in says to run `ollama signin` (in Docker, with your container's name), and lcode skips the
|
|
50
|
+
local loading and GPU checks for them. The docs explain the setup and list the cloud models that
|
|
51
|
+
call tools, with prices and published coding scores: see
|
|
52
|
+
[Cloud models through Ollama](https://nasser1941.github.io/lcode/models/#cloud-models-through-ollama).
|
|
53
|
+
|
|
9
54
|
## [0.36.0] - 2026-10-18
|
|
10
55
|
|
|
11
56
|
### Changed
|
|
@@ -810,7 +855,9 @@ First public release.
|
|
|
810
855
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
811
856
|
- One-line installer for Linux and macOS.
|
|
812
857
|
|
|
813
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
858
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.38.0...HEAD
|
|
859
|
+
[0.38.0]: https://github.com/nasser1941/lcode/compare/v0.37.0...v0.38.0
|
|
860
|
+
[0.37.0]: https://github.com/nasser1941/lcode/compare/v0.36.0...v0.37.0
|
|
814
861
|
[0.36.0]: https://github.com/nasser1941/lcode/compare/v0.35.1...v0.36.0
|
|
815
862
|
[0.35.1]: https://github.com/nasser1941/lcode/compare/v0.35.0...v0.35.1
|
|
816
863
|
[0.35.0]: https://github.com/nasser1941/lcode/compare/v0.34.0...v0.35.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.38.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/
|
|
@@ -45,7 +45,7 @@ lcode config path # print the file location
|
|
|
45
45
|
| `notify_after` | `30` | Seconds a request runs before it notifies |
|
|
46
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` |
|
|
47
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 |
|
|
48
|
-
| `escalate_model` | — | A [stronger model](usage.md#a-stronger-model-when-stuck) to ask, with your OK, when the model is stuck: `anthropic/…`, `openai/…`, `xai/…` (keys from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`), `ollama/<model
|
|
48
|
+
| `escalate_model` | — | A [stronger model](usage.md#a-stronger-model-when-stuck) to ask, with your OK, when the model is stuck: `anthropic/…`, `openai/…`, `xai/…` (keys from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`), `ollama/<model>` (a local model, or an [Ollama cloud model](models.md#cloud-models-through-ollama) like `ollama/glm-5.3-flash:cloud`), or `custom/<model>` with `escalate_url` |
|
|
49
49
|
| `escalate_url` | — | Its server, when it isn't the provider's usual one: another Ollama, or any OpenAI-compatible API |
|
|
50
50
|
| `escalate_price` | — | Its price in dollars per million tokens, input/output (e.g. `3/15`), for the cost shown before each use |
|
|
51
51
|
| `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, along with the checks for [unreachable sites](how-it-works.md#when-a-site-cant-be-reached) and [thinking in circles](how-it-works.md#when-a-reply-goes-nowhere) |
|
|
@@ -56,7 +56,8 @@ same tool, the same arguments and the same result.
|
|
|
56
56
|
`loop`.
|
|
57
57
|
|
|
58
58
|
A result that changes isn't a repeat, so a test that's run again after a fix, or a background job
|
|
59
|
-
that's checked while it runs, never adds up.
|
|
59
|
+
that's checked while it runs, never adds up. Timings don't count as a change: `75 passed in 0.12s`
|
|
60
|
+
and then `in 0.14s` is the same result. A call that may have changed something, like an edit or
|
|
60
61
|
a shell command that isn't read-only, starts the count again for the other calls: checking the build
|
|
61
62
|
after each edit is fine. Change the threshold with `lcode config set repeat_limit 5`, or turn it off
|
|
62
63
|
with `0` (that also turns off the checks for unreachable sites and for thinking in circles, below).
|
|
@@ -85,7 +86,8 @@ aren't tracked.
|
|
|
85
86
|
Small models sometimes think in circles: a few paragraphs, over and over, until the context is full.
|
|
86
87
|
That takes minutes behind the spinner and ends without an answer. lcode watches the thinking as it
|
|
87
88
|
streams, and when its sentences keep repeating, it stops the model, drops that reply and asks for a
|
|
88
|
-
short one: the next tool call, or what's blocking it.
|
|
89
|
+
short one: the next tool call, or what's blocking it. The dropped reply is saved, and the message
|
|
90
|
+
says where, so you can read what went round in circles. An empty reply, or one that filled the context
|
|
89
91
|
before it said anything, gets the same second chance. The second time in a request, lcode stops it
|
|
90
92
|
(status `loop`), offering the [stronger model](usage.md#a-stronger-model-when-stuck) first when one
|
|
91
93
|
is set. An answer the context cut off midway is kept, with a warning to `/compact` or use a larger
|
|
@@ -85,6 +85,104 @@ context; set `--context` yourself. If a model prints tool calls as text instead
|
|
|
85
85
|
lcode tries to recover them, but results are usually worse. To propose a model for the catalog, see
|
|
86
86
|
[Contributing](contributing.md#adding-a-model-to-the-catalog).
|
|
87
87
|
|
|
88
|
+
## Cloud models through Ollama
|
|
89
|
+
|
|
90
|
+
Ollama also offers models that run on its own servers, with tags that end in `cloud`, such as
|
|
91
|
+
`minimax-m3.1-flash:cloud`. Your Ollama passes the requests on, so lcode uses them like an installed
|
|
92
|
+
model: as the main model, or as the [stronger model to ask when stuck](usage.md#a-stronger-model-when-stuck).
|
|
93
|
+
What changes:
|
|
94
|
+
|
|
95
|
+
- **Your code leaves the machine.** What lcode sends (your requests and the files it reads) goes to
|
|
96
|
+
ollama.com. lcode says so when a session starts, when you switch to one, and in `lcode doctor`.
|
|
97
|
+
- **It's billed per token** to your ollama.com account (some models are free for a while).
|
|
98
|
+
- **It needs an ollama.com account** and a connection to ollama.com.
|
|
99
|
+
|
|
100
|
+
### Setting one up
|
|
101
|
+
|
|
102
|
+
1. **Sign your Ollama in** to your ollama.com account, once. It prints a link to open in the
|
|
103
|
+
browser:
|
|
104
|
+
|
|
105
|
+
=== "Ollama installed normally"
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
ollama signin
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
=== "Ollama in Docker"
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
docker exec -it <container> ollama signin
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`lcode doctor` shows this command with your container's name once a cloud model is set.
|
|
118
|
+
|
|
119
|
+
2. **Add the model.** This stores a small reference, not the weights:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
ollama pull minimax-m3.1-flash:cloud # in Docker: docker exec <container> ollama pull …
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
3. **Use it** as the main model, or as the stronger model when stuck:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
lcode --model minimax-m3.1-flash:cloud --context 256k # or pick it by number in /models
|
|
129
|
+
|
|
130
|
+
lcode config set escalate_model ollama/minimax-m3.1-flash:cloud
|
|
131
|
+
lcode config set escalate_price 0.15/0.60 # its price in/out, for the cost shown
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
As the main model, give the context yourself: lcode defaults to 32K for models outside its
|
|
135
|
+
catalog. A larger window costs more, since every step sends the conversation again (the
|
|
136
|
+
repeated part at the lower *cached* price). As the stronger model, only a short brief goes out,
|
|
137
|
+
and only when you say yes: the panel shows `Where: … at ollama.com` and the cost.
|
|
138
|
+
|
|
139
|
+
If a request fails with *this Ollama isn't signed in to an ollama.com account*, run the sign-in
|
|
140
|
+
command the message shows.
|
|
141
|
+
|
|
142
|
+
### Cheap coding models in Ollama's cloud
|
|
143
|
+
|
|
144
|
+
The cloud models that call tools, cheapest first. Prices are in dollars per million tokens, from
|
|
145
|
+
[ollama.com](https://ollama.com/search?c=cloud) on 2026-10-20. Check a model's page before you
|
|
146
|
+
rely on a price: they change.
|
|
147
|
+
|
|
148
|
+
- **TB 2.1** is the Terminal-Bench 2.1 score as published on the model's page. The makers measured
|
|
149
|
+
it themselves, in different setups, so it's a rough guide, not a ranking. A dash means the page
|
|
150
|
+
gives none.
|
|
151
|
+
- **One question** is the most a question to the stronger model costs: a 15K-token brief, plus
|
|
152
|
+
lcode's 4K-token cap on the answer.
|
|
153
|
+
- **DeepSeek** charges less off-peak. Its prices read off-peak / peak.
|
|
154
|
+
|
|
155
|
+
| Model | Input | Cached | Output | Context | TB 2.1 | One question |
|
|
156
|
+
|---|---|---|---|---|---|---|
|
|
157
|
+
| `glm-5.3-flash:cloud` | $0.15 | $0.03 | $0.50 | 1M | 84.3 | $0.004 |
|
|
158
|
+
| `minimax-m3.1-flash:cloud` | $0.15 | $0.015 | $0.60 | 976K | — | $0.005 |
|
|
159
|
+
| `deepseek-v4.1-flash:cloud` | $0.15 / $0.30 | $0.003 / $0.006 | $0.60 / $1.20 | 1M | 90.6 | $0.005–$0.009 |
|
|
160
|
+
| `minimax-m2.7:cloud` | $0.30 | $0.06 | $1.20 | 200K | — | $0.009 |
|
|
161
|
+
| `nemotron-3-ultra:cloud` | $0.10 | $0.10 | $3.00 | 256K | — | $0.014 |
|
|
162
|
+
| `mistral-large-3:675b-cloud` | $0.50 | — | $1.50 | 256K | — | $0.014 |
|
|
163
|
+
| `minimax-m3:cloud` | $0.60 | $0.12 | $2.40 | 512K | — | $0.019 |
|
|
164
|
+
| `mistral-large-4:cloud` | $0.68 | $0.07 | $2.09 | 1M | — | $0.019 |
|
|
165
|
+
| `deepseek-v4-pro:cloud` | $0.66 / $1.32 | $0.022 / $0.044 | $1.98 / $3.96 | 1M | 87.9 | $0.018–$0.036 |
|
|
166
|
+
| `kimi-k2.6:cloud` | $0.95 | $0.16 | $4.00 | 256K | — | $0.031 |
|
|
167
|
+
| `kimi-k2.7-code:cloud` | $0.95 | $0.19 | $4.00 | 256K | — | $0.031 |
|
|
168
|
+
| `muse-spark-open:cloud` | $1.25 | $0.15 | $4.25 | 1M | — | $0.036 |
|
|
169
|
+
| `glm-5.2:cloud` | $1.40 | $0.26 | $4.40 | 976K | 81.0 | $0.039 |
|
|
170
|
+
| `glm-5.3:cloud` | $1.40 | $0.26 | $4.40 | 1M | 88.2 | $0.039 |
|
|
171
|
+
| `kimi-k3:cloud` | $3.00 | $0.30 | $15.00 | 1M | 88.3 | $0.106 |
|
|
172
|
+
|
|
173
|
+
- **For the stronger model, the first three cost under a cent a question.**
|
|
174
|
+
- `deepseek-v4.1-flash` has the highest published score (90.6, DeepSeek's own measurement).
|
|
175
|
+
- `glm-5.3-flash` is the cheapest.
|
|
176
|
+
- `minimax-m3.1-flash` is free until 22 October 2026.
|
|
177
|
+
- **GLM-5.3, DeepSeek V4 Pro and Kimi K3** publish similar scores (88.2, 87.9, 88.3) for 4 to 25
|
|
178
|
+
times the price.
|
|
179
|
+
- **Not in the table:** `beam`, `gemma4:31b-cloud`, `nemotron-3-super`, `nemotron-3-nano` and
|
|
180
|
+
`clef` have cloud versions too, but list no price on their pages. Clef is an image model, not a
|
|
181
|
+
coding one.
|
|
182
|
+
|
|
183
|
+
To set a price for the cost shown, use the input/output pair from the table, e.g.
|
|
184
|
+
`lcode config set escalate_price 0.15/0.50` for `glm-5.3-flash:cloud`.
|
|
185
|
+
|
|
88
186
|
## Choosing a context window
|
|
89
187
|
|
|
90
188
|
The **context window** is how much text the model can see at once: the conversation, files it has
|
|
@@ -170,7 +170,9 @@ runs the project's checks itself before the answer is final:
|
|
|
170
170
|
- **Programs** using `lcode -p --output json` see the runs in the result's `checks`.
|
|
171
171
|
|
|
172
172
|
**Which checks.** lcode looks at the project: pytest when the configuration mentions it (`uv run
|
|
173
|
-
pytest -q` with a `uv.lock`),
|
|
173
|
+
pytest -q` with a `uv.lock`), when the tests import it, or when a requirements file lists it;
|
|
174
|
+
otherwise `python -m unittest` for `test_*.py` files. Python checks run with the project's own
|
|
175
|
+
environment when it has one (`.venv/bin/python`, `venv`, `env`), where its dependencies are. Then
|
|
174
176
|
`npm test` (or pnpm, yarn, bun) when `package.json` has a test script, `cargo test`, `go test ./...`
|
|
175
177
|
and `make test`. To choose the command yourself, set it for the repository in
|
|
176
178
|
`.lcode/settings.toml`. That file is used once you trust the repository, like its hooks:
|
|
@@ -183,6 +185,13 @@ command = "uv run pytest -q && uv run ruff check ."
|
|
|
183
185
|
Or set it for yourself with `lcode config set checks "make check"`; `lcode config set checks off`
|
|
184
186
|
turns checks off.
|
|
185
187
|
|
|
188
|
+
**Windows it never looked at.** A model that changes a window checks it with an import and the
|
|
189
|
+
project's tests, which don't show what's on the screen, and calls it done: you find a blank
|
|
190
|
+
window. When a request changed a Python file that draws one (it imports PyQt, PySide, tkinter,
|
|
191
|
+
Kivy, wx or GTK) and the model never took a [screenshot](#screenshots) or looked at an image,
|
|
192
|
+
lcode sends it back once: look at it, with clicks first if that's where the change shows, or say
|
|
193
|
+
plainly that it's unchecked. `checks = "off"` turns this off.
|
|
194
|
+
|
|
186
195
|
**Scripts it never ran.** A model that writes a build or install script tends to call it done
|
|
187
196
|
without running it once, and the first thing you see is its first error. When a request wrote a
|
|
188
197
|
shell script (a `.sh` file, or a file with a shell shebang) and no command in it ran the script,
|
|
@@ -219,7 +228,8 @@ would have to read the whole conversation again. On a small GPU that's tens of s
|
|
|
219
228
|
## A stronger model when stuck
|
|
220
229
|
|
|
221
230
|
Some problems are beyond a local model. When it's stuck, lcode can ask a stronger one for
|
|
222
|
-
advice: a cloud model with your API key,
|
|
231
|
+
advice: a cloud model with your API key, one of [Ollama's cloud models](models.md#cloud-models-through-ollama)
|
|
232
|
+
(less than a cent a question for some), or a bigger local model. The local model gets the advice
|
|
223
233
|
and carries on with its own tools. It's off until you choose the model:
|
|
224
234
|
|
|
225
235
|
```bash
|
|
@@ -227,6 +237,7 @@ lcode config set escalate_model anthropic/<model> # with ANTHROPIC_API_KEY in
|
|
|
227
237
|
lcode config set escalate_model openai/<model> # OPENAI_API_KEY
|
|
228
238
|
lcode config set escalate_model xai/<model> # XAI_API_KEY
|
|
229
239
|
lcode config set escalate_model ollama/qwen3.8-27b # a bigger local model: free, and nothing leaves
|
|
240
|
+
lcode config set escalate_model ollama/glm-5.3-flash:cloud # Ollama's cloud, after `ollama signin`
|
|
230
241
|
lcode config set escalate_price 3/15 # dollars per million tokens in/out, for the cost
|
|
231
242
|
```
|
|
232
243
|
|
|
@@ -257,7 +268,8 @@ a stronger model can't reach it either. You can also ask at any time: `/escalate
|
|
|
257
268
|
- **Keys** come from environment variables, are read when needed, and go only to their own
|
|
258
269
|
provider. lcode never stores them.
|
|
259
270
|
- **Cost.** The token count is always shown; the price only when you set `escalate_price`, since
|
|
260
|
-
prices change. A local model is free
|
|
271
|
+
prices change. A local model is free; an Ollama cloud model isn't, and the panel says it goes to
|
|
272
|
+
ollama.com.
|
|
261
273
|
- **Another server:** `escalate_url` points at another Ollama (`ollama/<model>`) or any OpenAI
|
|
262
274
|
API (`custom/<model>`, key in `LCODE_ESCALATE_API_KEY`).
|
|
263
275
|
- `lcode -p` can't ask, so it doesn't escalate unless you add `--escalate`, which is your OK for
|
|
@@ -424,6 +436,20 @@ a screen and sees what you would, measured:
|
|
|
424
436
|
|
|
425
437
|
- For a web app, the model gives the command that starts the server and the page's URL: lcode
|
|
426
438
|
starts the server, waits for the page to answer, takes the shot and stops the server.
|
|
439
|
+
- **Clicks and keys first.** For something that only shows after a click, a Python Qt program can
|
|
440
|
+
take `actions` before the picture, the way you would: `double_click 300,200` (a point in the
|
|
441
|
+
window), `double_click Camera 1` (the widget showing that text), `click Save`, `right_click …`,
|
|
442
|
+
`key Escape`, `key ctrl+s`, `type some text`, `wait 2`. The result says what each step hit.
|
|
443
|
+
- **Qt's warnings come first.** Qt names many layout mistakes in what the program prints, often
|
|
444
|
+
among the program's own logs. lcode lists them at the top of the report:
|
|
445
|
+
|
|
446
|
+
```text
|
|
447
|
+
● screenshot(python camera_wall.py) actions: double_click Camera 1
|
|
448
|
+
Qt printed warnings (they often name the cause):
|
|
449
|
+
- QWidget::setLayout: Attempting to set QLayout "" on FocusedCamera "", which already has a layout
|
|
450
|
+
Steps, before the picture:
|
|
451
|
+
- double_click Camera 1: on QLabel "Camera 1 — Connected" at (207, 317)
|
|
452
|
+
```
|
|
427
453
|
- A model that can see describes the picture with the model's question in mind (see
|
|
428
454
|
[Which model looks](#images)). Without one, the measurements still come back.
|
|
429
455
|
- Shots are saved in `.lcode/screenshots/` in the project, which keeps itself out of git, so you
|
|
@@ -45,6 +45,7 @@ from lcode import (
|
|
|
45
45
|
from lcode import context as context_tools
|
|
46
46
|
from lcode import memory as memory_notes
|
|
47
47
|
from lcode.checkpoints import Checkpoints
|
|
48
|
+
from lcode.cloud import is_cloud
|
|
48
49
|
from lcode.config import format_tokens
|
|
49
50
|
from lcode.jobs import Jobs
|
|
50
51
|
from lcode.mcp import McpManager
|
|
@@ -98,7 +99,9 @@ def stall_note(trouble: str) -> str:
|
|
|
98
99
|
}[trouble]
|
|
99
100
|
return (
|
|
100
101
|
f"[lcode] {first} Don't deliberate at length now. Either make your next tool call, or, if you're stuck, "
|
|
101
|
-
"tell the user in a few lines what you did, what fails, and what they could do."
|
|
102
|
+
"tell the user in a few lines what you did, what fails, and what they could do. If you were about to check "
|
|
103
|
+
"something by running it, run it now instead of reasoning about it: for a window, take a screenshot (with "
|
|
104
|
+
"actions for clicks or keys)."
|
|
102
105
|
)
|
|
103
106
|
|
|
104
107
|
|
|
@@ -960,6 +963,12 @@ class Agent:
|
|
|
960
963
|
return []
|
|
961
964
|
return checks.unrun_scripts(project_root_or_cwd(self.cwd), list(self.tools.before), commands)
|
|
962
965
|
|
|
966
|
+
def unseen_windows(self, tools_used: set[str]) -> dict[str, str]:
|
|
967
|
+
"""Window code the model changed in this request and never looked at (only where the checks would run)."""
|
|
968
|
+
if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
|
|
969
|
+
return {}
|
|
970
|
+
return checks.unseen_windows(project_root_or_cwd(self.cwd), list(self.tools.before), tools_used)
|
|
971
|
+
|
|
963
972
|
def project_check(self) -> checks.Check | None:
|
|
964
973
|
"""The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
|
|
965
974
|
if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
|
|
@@ -1011,8 +1020,8 @@ class Agent:
|
|
|
1011
1020
|
def before_request(self, progress: Progress) -> residency.LoadWatch | None:
|
|
1012
1021
|
"""Set the status line for what comes before the first token: loading the model if it isn't in memory
|
|
1013
1022
|
(watched until it is), then reading the prompt. Returns the watch while the model loads."""
|
|
1014
|
-
if getattr(self.ollama, "kind", "ollama") != "ollama":
|
|
1015
|
-
return None
|
|
1023
|
+
if getattr(self.ollama, "kind", "ollama") != "ollama" or is_cloud(self.settings.model):
|
|
1024
|
+
return None # another server loads models itself; an Ollama cloud model isn't loaded here at all
|
|
1016
1025
|
model, context = self.settings.model, self.settings.context
|
|
1017
1026
|
fresh = max(0, conversation_chars(self.messages) - self.sent_chars) // 3
|
|
1018
1027
|
where = residency.placement(self.ollama, model)
|
|
@@ -1163,6 +1172,8 @@ class Agent:
|
|
|
1163
1172
|
stalls = 0 # replies that went round in circles or ended without a word
|
|
1164
1173
|
commands: list[str] = [] # shell commands run in this request
|
|
1165
1174
|
scripts_noted = False # the model was told about scripts it wrote and never ran
|
|
1175
|
+
tools_used: set[str] = set() # tools called in this request
|
|
1176
|
+
windows_noted = False # the model was told about windows it changed and never looked at
|
|
1166
1177
|
for step in range(self.max_steps):
|
|
1167
1178
|
if self.cancel is not None and self.cancel.is_set():
|
|
1168
1179
|
raise KeyboardInterrupt
|
|
@@ -1214,10 +1225,11 @@ class Agent:
|
|
|
1214
1225
|
continue
|
|
1215
1226
|
if not calls and (self.reply_trouble == "circles" or not (reply.get("content") or "").strip()):
|
|
1216
1227
|
stalls += 1
|
|
1217
|
-
self.messages.pop() #
|
|
1228
|
+
dropped = self.messages.pop() # no answer, and circles would only fill the context
|
|
1229
|
+
kept = self.keep_dropped(dropped)
|
|
1218
1230
|
if stalls < MAX_STALLS:
|
|
1219
1231
|
self.console.print(
|
|
1220
|
-
Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer", style="yellow")
|
|
1232
|
+
Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer{kept}", style="yellow")
|
|
1221
1233
|
)
|
|
1222
1234
|
self.messages.append({"role": "user", "content": stall_note(self.reply_trouble)})
|
|
1223
1235
|
continue
|
|
@@ -1233,7 +1245,7 @@ class Agent:
|
|
|
1233
1245
|
self.console.print(
|
|
1234
1246
|
Text(
|
|
1235
1247
|
"Stopped: the model's replies went round in circles or ended without an answer, twice in "
|
|
1236
|
-
"this request. Try again with a smaller step, or tell it what to do differently.",
|
|
1248
|
+
f"this request{kept}. Try again with a smaller step, or tell it what to do differently.",
|
|
1237
1249
|
style="yellow",
|
|
1238
1250
|
)
|
|
1239
1251
|
)
|
|
@@ -1252,6 +1264,14 @@ class Agent:
|
|
|
1252
1264
|
self.console.print(Text(f" ⎿ never ran {', '.join(unrun)}: asking the model to", style="yellow"))
|
|
1253
1265
|
self.messages.append({"role": "user", "content": checks.unrun_note(unrun)})
|
|
1254
1266
|
continue
|
|
1267
|
+
unseen = {} if windows_noted else self.unseen_windows(tools_used)
|
|
1268
|
+
if unseen:
|
|
1269
|
+
windows_noted = True
|
|
1270
|
+
self.console.print(
|
|
1271
|
+
Text(f" ⎿ never looked at {', '.join(unseen)}: asking the model to", style="yellow")
|
|
1272
|
+
)
|
|
1273
|
+
self.messages.append({"role": "user", "content": checks.unseen_note(unseen)})
|
|
1274
|
+
continue
|
|
1255
1275
|
check = self.project_check() if changed and not verified else None
|
|
1256
1276
|
if check is not None and check_rounds < checks.MAX_ROUNDS:
|
|
1257
1277
|
check_rounds += 1
|
|
@@ -1344,6 +1364,7 @@ class Agent:
|
|
|
1344
1364
|
)
|
|
1345
1365
|
if out_of_reach.stop:
|
|
1346
1366
|
blocked = out_of_reach
|
|
1367
|
+
tools_used.add(name)
|
|
1347
1368
|
if name == "bash":
|
|
1348
1369
|
commands.append(str(args.get("command", "")))
|
|
1349
1370
|
if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
|
|
@@ -1411,6 +1432,14 @@ class Agent:
|
|
|
1411
1432
|
self.turn_status = "success"
|
|
1412
1433
|
self.print_stats()
|
|
1413
1434
|
|
|
1435
|
+
def keep_dropped(self, reply: dict) -> str:
|
|
1436
|
+
"""Save a reply lcode dropped (its thinking too), so the user can see what went round in circles."""
|
|
1437
|
+
text = "\n\n".join(part for part in (reply.get("thinking"), reply.get("content")) if part)
|
|
1438
|
+
if not text.strip():
|
|
1439
|
+
return ""
|
|
1440
|
+
path = context_tools.save_output(text, "dropped reply")
|
|
1441
|
+
return f" (the dropped reply is in {path})" if path else ""
|
|
1442
|
+
|
|
1414
1443
|
def can_escalate(self) -> bool:
|
|
1415
1444
|
"""Whether a stronger model may be asked here: one is set, and this isn't a subagent or plan mode."""
|
|
1416
1445
|
model = (self.settings.escalate_model or "").strip().lower()
|
|
@@ -50,10 +50,28 @@ def find(root: Path, setting: str, trusted: bool) -> Check | None:
|
|
|
50
50
|
return detect(root)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
VENVS = (".venv", "venv", "env")
|
|
54
|
+
PYTEST_USE = re.compile(r"^\s*(?:import pytest\b|from pytest\b)|^\s*@pytest\.", re.M)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def python(root: Path | None = None) -> str:
|
|
58
|
+
"""The project's own Python (its .venv, venv or env), where its dependencies are; else the one on PATH."""
|
|
59
|
+
if root is not None:
|
|
60
|
+
for env in VENVS:
|
|
61
|
+
for exe in ("bin/python", "Scripts/python.exe"):
|
|
62
|
+
if (root / env / exe).is_file():
|
|
63
|
+
return f"{env}/{exe}" # the checks run in the project's root
|
|
54
64
|
return "python" if shutil.which("python") else "python3"
|
|
55
65
|
|
|
56
66
|
|
|
67
|
+
def uses_pytest(root: Path) -> bool:
|
|
68
|
+
"""Whether the tests are written for pytest (they import it), or the requirements list it, without a config."""
|
|
69
|
+
for name in ("requirements.txt", "requirements-dev.txt", "requirements_dev.txt", "dev-requirements.txt"):
|
|
70
|
+
if re.search(r"^\s*pytest\b", text(root / name), re.M | re.I):
|
|
71
|
+
return True
|
|
72
|
+
return any(PYTEST_USE.search(text(path)[:20000]) for path in test_files(root)[:50])
|
|
73
|
+
|
|
74
|
+
|
|
57
75
|
def text(path: Path) -> str:
|
|
58
76
|
try:
|
|
59
77
|
return path.read_text(errors="replace") if path.is_file() else ""
|
|
@@ -61,38 +79,49 @@ def text(path: Path) -> str:
|
|
|
61
79
|
return ""
|
|
62
80
|
|
|
63
81
|
|
|
64
|
-
def
|
|
65
|
-
"""
|
|
82
|
+
def test_files(root: Path, limit: int = 200) -> list[Path]:
|
|
83
|
+
"""The project's Python test files, a few levels deep, outside environments and caches."""
|
|
66
84
|
from lcode.tools import IGNORE_DIRS
|
|
67
85
|
|
|
68
|
-
|
|
86
|
+
found: list[Path] = []
|
|
87
|
+
|
|
88
|
+
def walk(folder: Path, depth: int) -> None:
|
|
69
89
|
try:
|
|
70
|
-
entries =
|
|
90
|
+
entries = sorted(folder.iterdir())
|
|
71
91
|
except OSError:
|
|
72
|
-
return
|
|
73
|
-
if
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
92
|
+
return
|
|
93
|
+
found.extend(e for e in entries if e.is_file() and re.fullmatch(r"test_.*\.py|.*_test\.py", e.name))
|
|
94
|
+
if depth > 0:
|
|
95
|
+
for e in entries:
|
|
96
|
+
if len(found) >= limit:
|
|
97
|
+
return
|
|
98
|
+
if e.is_dir() and e.name not in IGNORE_DIRS and e.name not in VENVS and not e.name.startswith("."):
|
|
99
|
+
walk(e, depth - 1)
|
|
80
100
|
|
|
81
|
-
|
|
101
|
+
walk(root, 3)
|
|
102
|
+
return found[:limit]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def python_tests(root: Path) -> bool:
|
|
106
|
+
"""Whether the project has Python test files (a few levels deep, outside environments and caches)."""
|
|
107
|
+
return bool(test_files(root, limit=1))
|
|
82
108
|
|
|
83
109
|
|
|
84
110
|
def detect(root: Path) -> Check | None:
|
|
85
111
|
"""A check command from what the project looks like. Python checks need test files: pytest configured in a
|
|
86
112
|
project without tests "fails" with nothing to run."""
|
|
87
113
|
if python_tests(root):
|
|
114
|
+
py = python(root)
|
|
88
115
|
configs = text(root / "pyproject.toml") + text(root / "setup.cfg") + text(root / "tox.ini")
|
|
89
116
|
if "pytest" in configs or any((root / name).is_file() for name in ("pytest.ini", "conftest.py")):
|
|
90
|
-
runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{
|
|
117
|
+
runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{py} -m pytest -q"
|
|
91
118
|
return Check(runner, "pytest in the project's configuration")
|
|
119
|
+
if uses_pytest(root):
|
|
120
|
+
return Check(f"{py} -m pytest -q", "tests written for pytest")
|
|
92
121
|
if list(root.glob("test_*.py")) or list(root.glob("*_test.py")):
|
|
93
|
-
return Check(f"{
|
|
122
|
+
return Check(f"{py} -m unittest", "test files")
|
|
94
123
|
if (root / "tests").is_dir():
|
|
95
|
-
return Check(f"{
|
|
124
|
+
return Check(f"{py} -m unittest discover -s tests -t .", "tests/")
|
|
96
125
|
package = text(root / "package.json")
|
|
97
126
|
if package:
|
|
98
127
|
try:
|
|
@@ -229,3 +258,52 @@ def unrun_note(scripts: list[str]) -> str:
|
|
|
229
258
|
f"deploy, delete data) or needs something you don't have, don't: tell the user plainly that {it} is untested "
|
|
230
259
|
"and why."
|
|
231
260
|
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ----------------------------------------------------------------------------- windows it never looked at
|
|
264
|
+
|
|
265
|
+
GUI_IMPORT = re.compile(r"^\s*(?:from|import)\s+(PyQt[56]|PySide[26]|tkinter|kivy|wx|gi\.repository)\b", re.M)
|
|
266
|
+
LOOKED = frozenset({"screenshot", "view_image"})
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def unseen_windows(root: Path, changed: list[str], tools_used: set[str]) -> dict[str, str]:
|
|
270
|
+
"""Python files that draw a window (they import a GUI toolkit), changed in this request when the model never
|
|
271
|
+
looked at one: file -> toolkit.
|
|
272
|
+
|
|
273
|
+
A small model checks a window change by importing the module and running unrelated tests, then calls it done;
|
|
274
|
+
the user finds a blank window. Tests and imports don't show what's on the screen; a screenshot does.
|
|
275
|
+
"""
|
|
276
|
+
if tools_used & LOOKED:
|
|
277
|
+
return {}
|
|
278
|
+
found = {}
|
|
279
|
+
for rel in changed:
|
|
280
|
+
path = root / rel
|
|
281
|
+
if path.suffix != ".py" or not path.is_file():
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
text = path.read_text(errors="replace")[:200_000]
|
|
285
|
+
except OSError:
|
|
286
|
+
continue
|
|
287
|
+
match = GUI_IMPORT.search(text)
|
|
288
|
+
if match:
|
|
289
|
+
found[rel] = match.group(1)
|
|
290
|
+
return found
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def unseen_note(found: dict[str, str]) -> str:
|
|
294
|
+
names = ", ".join(found)
|
|
295
|
+
kits = sorted(set(found.values()))
|
|
296
|
+
qt = any(kit.startswith(("PyQt", "PySide")) for kit in kits)
|
|
297
|
+
how = (
|
|
298
|
+
"take a screenshot with the screenshot tool: it runs the program without a screen and shows what it draws, "
|
|
299
|
+
"and what the program printed (Qt names layout mistakes there). For something that only shows after a click "
|
|
300
|
+
'or a key, give actions, e.g. actions=["double_click Camera 1"]'
|
|
301
|
+
if qt
|
|
302
|
+
else "take a screenshot with the screenshot tool: it runs the program on a virtual display and shows what it "
|
|
303
|
+
"draws"
|
|
304
|
+
)
|
|
305
|
+
return (
|
|
306
|
+
f"[lcode] You changed {names}, which draws a window ({', '.join(kits)}), but never looked at it: tests and "
|
|
307
|
+
f"imports don't show what's on the screen. Before you say it works, {how}. Check the picture against what the "
|
|
308
|
+
"user asked for. If the program can't run here, tell the user plainly that you haven't seen it working."
|
|
309
|
+
)
|
|
@@ -21,6 +21,7 @@ from rich.table import Table
|
|
|
21
21
|
from lcode import __version__, backends, catalog, config, limits, web
|
|
22
22
|
from lcode.agent import Settings
|
|
23
23
|
from lcode.catalog import ModelSpec
|
|
24
|
+
from lcode.cloud import is_cloud, signin_command
|
|
24
25
|
from lcode.config import ConfigError, format_tokens, parse_context
|
|
25
26
|
from lcode.hardware import Hardware, detect
|
|
26
27
|
from lcode.ollama import MIN_VERSION, Ollama, OllamaError, redact, version_tuple
|
|
@@ -384,6 +385,13 @@ def cmd_doctor(args) -> None:
|
|
|
384
385
|
try:
|
|
385
386
|
model, spec = resolve_model(ollama, cfg["model"])
|
|
386
387
|
line("Model", f"{cfg['model']} → {model}", True)
|
|
388
|
+
if is_cloud(model):
|
|
389
|
+
line(
|
|
390
|
+
"",
|
|
391
|
+
"runs in Ollama's cloud: what lcode sends goes to ollama.com, billed per token, and this Ollama "
|
|
392
|
+
f"must be signed in once: {signin_command(ollama.host)}",
|
|
393
|
+
None,
|
|
394
|
+
)
|
|
387
395
|
ctx, note = choose_context(ollama, model, spec, cfg["context"], hw)
|
|
388
396
|
if spec:
|
|
389
397
|
from lcode.subagents import parallel_slots
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Ollama's cloud models: tags like `minimax-m3.1-flash:cloud` run on ollama.com, through the local Ollama.
|
|
2
|
+
|
|
3
|
+
They look like any installed model to lcode (Ollama lists them, and they chat and call tools), but nothing about
|
|
4
|
+
them is local: what lcode sends goes to ollama.com, it's billed per token, and it needs the Ollama signed in to an
|
|
5
|
+
ollama.com account (`ollama signin`). So lcode says so where it matters (`lcode doctor`, the start of a session, the
|
|
6
|
+
stronger-model panel), skips what only makes sense for a local model (loading it into memory, GPU fit), and turns
|
|
7
|
+
Ollama's bare `Unauthorized` into the command that fixes it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_cloud(model: str | None) -> bool:
|
|
14
|
+
"""Whether an Ollama model name is one of Ollama's cloud models: its tag is `cloud` or ends with `-cloud`."""
|
|
15
|
+
name = (model or "").strip().lower()
|
|
16
|
+
if ":" not in name:
|
|
17
|
+
return False
|
|
18
|
+
tag = name.rsplit(":", 1)[1]
|
|
19
|
+
return tag == "cloud" or tag.endswith("-cloud")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def signin_command(host: str) -> str:
|
|
23
|
+
"""How to sign the Ollama at `host` in to ollama.com: inside its container when it runs in Docker."""
|
|
24
|
+
try:
|
|
25
|
+
from lcode import update
|
|
26
|
+
|
|
27
|
+
container = update.find_container(host) if update.docker_available() else None
|
|
28
|
+
except Exception: # Docker missing, or not answering: the plain command is still right for most installs
|
|
29
|
+
container = None
|
|
30
|
+
return f"docker exec -it {container.name} ollama signin" if container else "ollama signin"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def unauthorized(model: str, host: str) -> str:
|
|
34
|
+
"""The error for a cloud model that Ollama refused: what it means and the command to run."""
|
|
35
|
+
return (
|
|
36
|
+
f"{model} runs in Ollama's cloud (ollama.com), and this Ollama isn't signed in to an ollama.com account. "
|
|
37
|
+
f"Sign it in with `{signin_command(host)}`, then try again."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def notice(model: str) -> str:
|
|
42
|
+
"""What to know when a cloud model is lcode's main model."""
|
|
43
|
+
return (
|
|
44
|
+
f"{model} runs in Ollama's cloud: what lcode sends (your requests and the files it reads) goes to "
|
|
45
|
+
"ollama.com, and Ollama bills it per token."
|
|
46
|
+
)
|