lcode-cli 0.29.0__tar.gz → 0.31.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.
Files changed (165) hide show
  1. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/CHANGELOG.md +32 -1
  2. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/how-it-works.md +10 -0
  4. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/quickstart.md +1 -1
  5. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/usage.md +32 -2
  6. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/pyproject.toml +2 -1
  7. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/__init__.py +1 -1
  8. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/acp.py +1 -1
  9. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/agent.py +15 -3
  10. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/bench.py +15 -7
  11. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/context.py +48 -1
  12. lcode_cli-0.31.0/src/lcode/matching.py +287 -0
  13. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/repeats.py +2 -2
  14. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/repl.py +28 -0
  15. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/subagents.py +1 -1
  16. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/tools.py +161 -43
  17. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_bench.py +10 -1
  18. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_context.py +54 -1
  19. lcode_cli-0.31.0/tests/test_matching.py +167 -0
  20. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_tools.py +14 -6
  21. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.editorconfig +0 -0
  22. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.github/CODEOWNERS +0 -0
  23. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  24. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  25. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  26. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  27. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  28. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.github/dependabot.yml +0 -0
  29. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.github/workflows/ci.yml +0 -0
  30. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.github/workflows/docs.yml +0 -0
  31. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.github/workflows/release.yml +0 -0
  32. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.gitignore +0 -0
  33. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/.pre-commit-config.yaml +0 -0
  34. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/AGENTS.md +0 -0
  35. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/CODE_OF_CONDUCT.md +0 -0
  36. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/CONTRIBUTING.md +0 -0
  37. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/LICENSE +0 -0
  38. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/README.md +0 -0
  39. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/SECURITY.md +0 -0
  40. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/action.yml +0 -0
  41. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/agents.md +0 -0
  42. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/assets/demo.svg +0 -0
  43. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/assets/extra.css +0 -0
  44. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/assets/logo.svg +0 -0
  45. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/assets/social-preview.png +0 -0
  46. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/automation.md +0 -0
  47. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/bench.md +0 -0
  48. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/changelog.md +0 -0
  49. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/commands.md +0 -0
  50. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/configuration.md +0 -0
  51. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/contributing.md +0 -0
  52. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/editors.md +0 -0
  53. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/git.md +0 -0
  54. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/hooks.md +0 -0
  55. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/index.md +0 -0
  56. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/installation.md +0 -0
  57. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/lsp.md +0 -0
  58. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/mcp.md +0 -0
  59. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/memory.md +0 -0
  60. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/models.md +0 -0
  61. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/sandbox.md +0 -0
  62. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/search.md +0 -0
  63. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/servers.md +0 -0
  64. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/docs/troubleshooting.md +0 -0
  65. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/examples/README.md +0 -0
  66. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/examples/api/fix_tests.py +0 -0
  67. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/examples/commands/review.md +0 -0
  68. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/examples/skills/commit-message/SKILL.md +0 -0
  69. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  70. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/install.sh +0 -0
  71. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/mkdocs.yml +0 -0
  72. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/overrides/main.html +0 -0
  73. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/scripts/record_demo.py +0 -0
  74. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/scripts/social-preview.html +0 -0
  75. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/__main__.py +0 -0
  76. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/action.py +0 -0
  77. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/api.py +0 -0
  78. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/backends.py +0 -0
  79. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/bench_backend.py +0 -0
  80. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/bench_runtime.py +0 -0
  81. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/catalog.py +0 -0
  82. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/checkpoints.py +0 -0
  83. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/checks.py +0 -0
  84. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/cli.py +0 -0
  85. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/clipboard.py +0 -0
  86. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/codesearch.py +0 -0
  87. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/config.py +0 -0
  88. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/extensions.py +0 -0
  89. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/frontmatter.py +0 -0
  90. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/gitflow.py +0 -0
  91. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/hardware.py +0 -0
  92. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/hooks.py +0 -0
  93. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/jobs.py +0 -0
  94. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/limits.py +0 -0
  95. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/lsp.py +0 -0
  96. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/__init__.py +0 -0
  97. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/auth.py +0 -0
  98. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/catalog.py +0 -0
  99. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/catalog.toml +0 -0
  100. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/client.py +0 -0
  101. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/commands.py +0 -0
  102. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/config.py +0 -0
  103. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/http.py +0 -0
  104. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/manager.py +0 -0
  105. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/protocol.py +0 -0
  106. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/server.py +0 -0
  107. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/mcp/stdio.py +0 -0
  108. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/memory.py +0 -0
  109. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/models.toml +0 -0
  110. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/notify.py +0 -0
  111. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/ollama.py +0 -0
  112. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/permissions.py +0 -0
  113. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/planning.py +0 -0
  114. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/render.py +0 -0
  115. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/repomap.py +0 -0
  116. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/residency.py +0 -0
  117. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/sandbox.py +0 -0
  118. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/secretscan.py +0 -0
  119. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/servers/__init__.py +0 -0
  120. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/servers/encord.py +0 -0
  121. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/servers/valohai.py +0 -0
  122. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/servers/xai.py +0 -0
  123. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/sessions.py +0 -0
  124. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/update.py +0 -0
  125. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/vision.py +0 -0
  126. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/src/lcode/web.py +0 -0
  127. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/conftest.py +0 -0
  128. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/fake_lsp_server.py +0 -0
  129. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/fake_mcp_server.py +0 -0
  130. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_acp.py +0 -0
  131. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_action.py +0 -0
  132. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_agent.py +0 -0
  133. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_api.py +0 -0
  134. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_backends.py +0 -0
  135. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_catalog.py +0 -0
  136. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_checkpoints.py +0 -0
  137. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_checks.py +0 -0
  138. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_cli.py +0 -0
  139. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_clipboard.py +0 -0
  140. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_codesearch.py +0 -0
  141. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_config.py +0 -0
  142. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_context_picker.py +0 -0
  143. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_extensions.py +0 -0
  144. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_gitflow.py +0 -0
  145. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_hooks.py +0 -0
  146. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_jobs.py +0 -0
  147. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_lsp.py +0 -0
  148. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_mcp.py +0 -0
  149. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_memory.py +0 -0
  150. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_permissions.py +0 -0
  151. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_plan_mode.py +0 -0
  152. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_repeats.py +0 -0
  153. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_repomap.py +0 -0
  154. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_residency.py +0 -0
  155. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_robustness.py +0 -0
  156. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_sandbox.py +0 -0
  157. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_secretscan.py +0 -0
  158. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_servers.py +0 -0
  159. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_sessions.py +0 -0
  160. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_subagents.py +0 -0
  161. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_update.py +0 -0
  162. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_vision.py +0 -0
  163. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_web.py +0 -0
  164. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/tests/test_xai.py +0 -0
  165. {lcode_cli-0.29.0 → lcode_cli-0.31.0}/uv.lock +0 -0
@@ -6,6 +6,35 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.31.0] - 2026-10-14
10
+
11
+ ### Added
12
+
13
+ - A task sheet that survives long sessions. The plan (the todo list), the model's notes (decisions,
14
+ findings, open questions, kept in `todo_write`'s new `notes`, or inside a todo item, where small
15
+ models tend to put them), the files changed and the last check are pinned next to the summary and
16
+ next to your request when old output is removed. It's saved with the session, so `lcode -c`
17
+ brings it back (the todo list used to be lost), and `/status` shows it. See
18
+ [Context management](https://nasser1941.github.io/lcode/how-it-works/#context-management).
19
+
20
+ ## [0.30.0] - 2026-10-13
21
+
22
+ ### Added
23
+
24
+ - Edits that the model copied imperfectly land where they belong instead of failing: line numbers
25
+ copied from `read_file`, stray escapes, a block at the wrong indentation or spaces for tabs (the new
26
+ text is re-indented), dropped blank lines, and very close matches. A close match changes only the
27
+ lines the model meant to change, so an earlier edit its copy doesn't show survives. Ambiguous or
28
+ weak matches are refused, and the model is shown the closest lines with their numbers. See
29
+ [Imperfect edits](https://nasser1941.github.io/lcode/usage/#imperfect-edits).
30
+ - `apply_patch`: the model can apply a unified diff to several files at once. Hunks are found by
31
+ their text, so wrong `@@` line numbers don't matter, and a file changes only if every hunk fits.
32
+ - Common tool slips get a useful answer instead of an error: a `grep` pattern that isn't a valid regex
33
+ (`def add(`) is searched as plain text, a path that doesn't exist comes back with the closest
34
+ existing ones (`Did you mean: src/pkg/math_utils.py?`), `read_file` on a folder lists it, and
35
+ `list_dir` on a file says to read it.
36
+ - `lcode bench --json` breaks failed tool calls down by tool (`errors_by_tool`).
37
+
9
38
  ## [0.29.0] - 2026-10-13
10
39
 
11
40
  ### Added
@@ -650,7 +679,9 @@ First public release.
650
679
  - `AGENTS.md` project instructions and `/init` to generate them.
651
680
  - One-line installer for Linux and macOS.
652
681
 
653
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.29.0...HEAD
682
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.31.0...HEAD
683
+ [0.31.0]: https://github.com/nasser1941/lcode/compare/v0.30.0...v0.31.0
684
+ [0.30.0]: https://github.com/nasser1941/lcode/compare/v0.29.0...v0.30.0
654
685
  [0.29.0]: https://github.com/nasser1941/lcode/compare/v0.28.1...v0.29.0
655
686
  [0.28.1]: https://github.com/nasser1941/lcode/compare/v0.28.0...v0.28.1
656
687
  [0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.29.0
3
+ Version: 0.31.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/
@@ -97,6 +97,16 @@ context is old tool output, so:
97
97
  findings, files changed, next steps) and continues from it. Your latest requests (up to eight)
98
98
  are kept word for word under the summary, so the goal isn't lost in the retelling.
99
99
 
100
+ **The task sheet.** Next to the summary, and next to your request when old tool output is removed,
101
+ lcode pins a short sheet, so the state of the work survives:
102
+
103
+ - the plan (the model's todo list);
104
+ - the notes the model keeps with it (decisions and why, findings, open questions);
105
+ - the files changed in the session;
106
+ - the last check.
107
+
108
+ It's saved with the session, so `lcode -c` brings it back, and `/status` shows it to you.
109
+
100
110
  `lcode config set compact_at 128k` moves the ceiling, and `0` turns it off, leaving only the 85%
101
111
  rule. `/context` shows where the current session makes room.
102
112
 
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.29.0 — local coding agent │
15
+ │ lcode v0.31.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -80,6 +80,7 @@ reasoning is on.
80
80
  | `/mode [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
81
81
  | `/cd DIR` | Change the working directory |
82
82
  | `/todos` | Show the model's task list |
83
+ | `/status` | The task sheet: your latest requests, the plan and the model's notes, files changed, the last check |
83
84
  | `/jobs [stop ID]` | The [background commands](#background-commands) the model started; stop one |
84
85
  | `/commit [notes]` | [Commit](git.md#commit) the changes with a message in the repository's style, once you approve it |
85
86
  | `/review [base]` | [Review](git.md#review) the uncommitted changes, or the branch against a base |
@@ -92,13 +93,14 @@ reasoning is on.
92
93
  |---|---|
93
94
  | `read_file` | Read a file with line numbers (paged for large files) |
94
95
  | `write_file` | Create or overwrite a file (shows a preview or diff) |
95
- | `edit_file` | Replace an exact snippet in a file (shows a diff); tolerates trailing-whitespace differences |
96
+ | `edit_file` | Replace a snippet in a file (shows a diff); [forgiving](#imperfect-edits) when the model copies it imperfectly |
97
+ | `apply_patch` | Apply a unified diff (as `git diff` writes it) to one or more files; hunks are found by their text |
96
98
  | `list_dir` | Directory tree, skipping `.git`, `node_modules`, virtualenvs and caches |
97
99
  | `glob` | Find files by pattern, newest first |
98
100
  | `grep` | Regex search with ripgrep (falls back to Python if ripgrep is missing) |
99
101
  | `bash` | Run a shell command with live output, a timeout and a persistent working directory; with `background`, keep it running ([details](#background-commands)) |
100
102
  | `bash_output`, `bash_stop` | Read what a background command printed since last time; stop it |
101
- | `todo_write` | Keep a visible task list for multi-step work |
103
+ | `todo_write` | Keep a visible task list for multi-step work, with notes (decisions, findings) that survive when old messages are removed; `/status` shows them |
102
104
  | `web_search` | Search the web for current information (needs a [search provider](#web-search)) |
103
105
  | `web_fetch` | Read a web page or text file by URL as clean text |
104
106
  | `view_image` | Look at an image: a model that can see describes it ([Images](#images)) |
@@ -113,6 +115,34 @@ reasoning is on.
113
115
  lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since
114
116
  it was read, so the model always edits the current version.
115
117
 
118
+ ### Imperfect edits
119
+
120
+ Small models copy text imperfectly, and an edit that doesn't match exactly used to fail, often again
121
+ and again. When the text isn't in the file exactly, lcode tries, in order:
122
+
123
+ - **Copying slips:** removing the line numbers `read_file` shows, and undoing stray escapes such
124
+ as `\"`.
125
+ - **Looser line matches:** comparing lines without trailing spaces, then without indentation (a block
126
+ at the wrong level, or spaces where the file has tabs). The new text is re-indented to fit.
127
+ - **Blank lines:** ignoring blank lines the model dropped or added.
128
+ - **A very close match:** at least 90% alike, and clearly better than any other place. Only the lines
129
+ the model meant to change are changed, so an earlier edit that the model's copy doesn't show is
130
+ kept. If a changed line differs in the file by more than a typo, the edit is refused.
131
+
132
+ The result says how it matched (`Edited config.py (1 replacement(s), ignoring indentation)`), and the
133
+ diff shows the change as usual. A match that's ambiguous or too weak is never applied. Instead, the
134
+ model gets the closest lines with their numbers, so it can copy them exactly. `apply_patch` places
135
+ each hunk the same way, so wrong `@@` line numbers don't matter, and it changes a file only if every
136
+ hunk fits.
137
+
138
+ Other common slips get a useful answer instead of a bare error:
139
+
140
+ - **A pattern that isn't a valid regex** (`grep "def add("`) is searched as plain text, and the result
141
+ says so.
142
+ - **A path that doesn't exist** comes back with the closest existing ones: the same file name
143
+ elsewhere in the project, or a near name in the folder (`Did you mean: src/pkg/math_utils.py?`).
144
+ - **`read_file` on a folder** lists it, and **`list_dir` on a file** says to read it.
145
+
116
146
  ## Checks before done
117
147
 
118
148
  Small models like to call work done after a syntax check. So after a request that changed files, lcode
@@ -82,7 +82,8 @@ ignore = [
82
82
  "src/lcode/context.py" = ["E501"]
83
83
  "src/lcode/lsp.py" = ["E501"]
84
84
  "src/lcode/repomap.py" = ["E501"]
85
- "src/lcode/bench_runtime.py" = ["E501"] # the task's files and hidden test
85
+ "src/lcode/bench_runtime.py" = ["E501"]
86
+ "tests/test_matching.py" = ["E501"] # file contents as test data # the task's files and hidden test
86
87
  "src/lcode/codesearch.py" = ["E501"]
87
88
  "src/lcode/bench_backend.py" = ["E501"]
88
89
  "src/lcode/backends.py" = ["E501"]
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.29.0"
6
+ __version__ = "0.31.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -44,7 +44,7 @@ MODES = [
44
44
  ]
45
45
  KINDS = {
46
46
  "read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
47
- "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
47
+ "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit", "apply_patch": "edit",
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
@@ -112,7 +112,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
112
112
  - To understand a repository: look at the layout, README/docs, entry points and config, then grep and read the relevant files. Cite code as `path:line`.
113
113
  - Read a file before editing it. Use edit_file for targeted changes and write_file for new files or full rewrites. old_string must be copied exactly from the file (without the line-number prefixes).
114
114
  - Match the existing code style, naming and structure. Don't add unrequested features.
115
- - For multi-step tasks, plan with todo_write and keep it updated.
115
+ - For multi-step tasks, plan with todo_write and keep it updated; put decisions and findings you must not lose in its notes.
116
116
  - Work autonomously until the task is done. Only stop to ask the user when you are genuinely blocked or the decision is theirs.
117
117
  - Don't run destructive or irreversible commands (rm -rf, git reset --hard, git push --force, dropping data) unless the user explicitly asked.
118
118
  - If the user denies a tool call, don't retry the same thing; adjust or ask.
@@ -613,6 +613,9 @@ class Agent:
613
613
  "title": self.session_title,
614
614
  "messages": self.messages,
615
615
  "checkpoints": self.checkpoints.to_json(),
616
+ "todos": self.tools.todos,
617
+ "notes": self.tools.notes,
618
+ "changed": list(self.tools.changed),
616
619
  }
617
620
  # Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
618
621
  partial = f.with_name(f".{f.name}.partial")
@@ -626,6 +629,8 @@ class Agent:
626
629
  self.session_name = ""
627
630
  self.session_title = ""
628
631
  self.checkpoints.reset(self.session_id)
632
+ self.tools.todos, self.tools.notes, self.tools.changed = [], [], {}
633
+ self.check_results = []
629
634
 
630
635
  def rename(self, name: str) -> None:
631
636
  self.session_name = " ".join(name.split())
@@ -652,6 +657,9 @@ class Agent:
652
657
  self.session_name = data.get("name", "")
653
658
  self.session_title = data.get("title") or sessions.title_from(self.messages)
654
659
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
660
+ self.tools.todos = list(data.get("todos") or [])
661
+ self.tools.notes = [str(n) for n in data.get("notes") or []]
662
+ self.tools.changed = dict.fromkeys(str(f) for f in data.get("changed") or [])
655
663
  self.reflected = len(self.messages) # checked for notes to remember when that session ended
656
664
  self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
657
665
  self.counted_chars = None
@@ -1134,7 +1142,7 @@ class Agent:
1134
1142
  )
1135
1143
  if verdict.stop:
1136
1144
  looping = self.describe_call(name, args)
1137
- if name in ("edit_file", "write_file") and not result.startswith(REFUSED):
1145
+ if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
1138
1146
  changed, verified = True, False
1139
1147
  elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
1140
1148
  verified = True # the model ran the checks itself, after its last change
@@ -1289,6 +1297,8 @@ class Agent:
1289
1297
  self.messages, keep=1, removed=removed
1290
1298
  ) # not enough: before the last request
1291
1299
  context_tools.note_removed(self.messages, removed)
1300
+ if saved:
1301
+ context_tools.pin(self.messages, context_tools.task_sheet(self))
1292
1302
  if saved:
1293
1303
  self.sent_chars = 0 # the conversation changed: the model reads it again
1294
1304
  self.pruned += 1
@@ -1333,7 +1343,9 @@ class Agent:
1333
1343
  {"role": "system", "content": self.system_prompt()},
1334
1344
  {
1335
1345
  "role": "user",
1336
- "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}" + context_tools.requests_block(requests),
1346
+ "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}"
1347
+ + context_tools.requests_block(requests)
1348
+ + (f"\n\n{sheet}" if (sheet := context_tools.task_sheet(self)) else ""),
1337
1349
  },
1338
1350
  {"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
1339
1351
  ]
@@ -789,6 +789,7 @@ class TaskResult:
789
789
  steps: int = 0 # model responses
790
790
  tool_errors: int = 0 # failed tool calls, including malformed ones
791
791
  error: str | None = None # why the run stopped early: a timeout or an Ollama error
792
+ errors_by_tool: dict[str, int] = field(default_factory=dict) # which tools failed, "malformed" for unreadable calls
792
793
 
793
794
 
794
795
  @dataclass
@@ -861,14 +862,20 @@ class Elapsed:
861
862
  return Text(f"{self.label} {time.monotonic() - self.start:.0f}s", style="dim")
862
863
 
863
864
 
865
+ def errors_by_tool(messages: list[dict]) -> dict[str, int]:
866
+ """Failed tool calls by tool, and "malformed" for calls the model wrote unreadably."""
867
+ counts: dict[str, int] = {}
868
+ for m in messages:
869
+ if m.get("role") == "tool" and m.get("content", "").startswith("Error:"):
870
+ name = m.get("tool_name") or "?"
871
+ counts[name] = counts.get(name, 0) + 1
872
+ elif m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call"):
873
+ counts["malformed"] = counts.get("malformed", 0) + 1
874
+ return counts
875
+
876
+
864
877
  def count_tool_errors(messages: list[dict]) -> int:
865
- failed = sum(1 for m in messages if m.get("role") == "tool" and m.get("content", "").startswith("Error:"))
866
- malformed = sum(
867
- 1
868
- for m in messages
869
- if m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call")
870
- )
871
- return failed + malformed
878
+ return sum(errors_by_tool(messages).values())
872
879
 
873
880
 
874
881
  def run_task(
@@ -933,6 +940,7 @@ def run_task(
933
940
  detail=error or check.detail,
934
941
  steps=agent.usage["requests"] - before.get("requests", 0),
935
942
  tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
943
+ errors_by_tool=errors_by_tool(agent.messages[first:] if len(agent.messages) > first else agent.messages),
936
944
  error=error,
937
945
  )
938
946
 
@@ -49,7 +49,9 @@ REQUESTS_HEADER = "[The user's requests in this conversation, word for word, old
49
49
  MAX_REQUESTS = 8 # the most recent ones are kept through a summary
50
50
  MAX_REQUEST_CHARS = 1500
51
51
  # What lcode appends to the user's words: attached files, images and folders, and its own notes.
52
- ATTACHED = re.compile(r"\n\n(?:<file path=|<image path=|<directory path=|\[lcode[:\]] |\[The user wants the )")
52
+ ATTACHED = re.compile(
53
+ r"\n\n(?:<file path=|<image path=|<directory path=|\[lcode[:\]] |\[The user wants the )"
54
+ ) # also the sheet
53
55
 
54
56
 
55
57
  def request_text(content: str) -> str:
@@ -187,6 +189,51 @@ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS, removed: list[
187
189
  return saved
188
190
 
189
191
 
192
+ SHEET_HEADER = "[lcode] Task sheet: the plan, notes and changes so far, kept when old messages are removed"
193
+ MAX_SHEET = 3000 # characters
194
+
195
+
196
+ def task_sheet(agent: Agent) -> str:
197
+ """The state of the work, from lcode's own records: the todo list and the model's notes (todo_write), the
198
+ files changed in the session and the last check. Pinned where old messages go: in summaries and next to the
199
+ request when tool output is removed."""
200
+ tools = agent.tools
201
+ parts = []
202
+ if tools.todos:
203
+ marks = {"completed": "[x]", "in_progress": "[>]", "pending": "[ ]"}
204
+ plan = "\n".join(f"{marks.get(t.get('status', ''), '[ ]')} {t.get('content', '')}" for t in tools.todos)
205
+ parts.append(f"Plan:\n{plan}")
206
+ if tools.notes:
207
+ parts.append("Notes:\n" + "\n".join(f"- {note}" for note in tools.notes))
208
+ if tools.changed:
209
+ files = list(tools.changed)
210
+ more = f" and {len(files) - 30} more" if len(files) > 30 else ""
211
+ parts.append("Files changed in this session: " + ", ".join(files[-30:]) + more)
212
+ if agent.check_results:
213
+ last = agent.check_results[-1]
214
+ code = last.get("exit_code")
215
+ state = "passed" if code == 0 else "didn't run" if code is None else f"failed (exit code {code})"
216
+ parts.append(f"Last check: `{last.get('command')}` {state}")
217
+ if not parts:
218
+ return ""
219
+ sheet = "\n\n".join(parts)
220
+ if len(sheet) > MAX_SHEET:
221
+ sheet = sheet[: MAX_SHEET - 20] + "\n[…cut]"
222
+ return f"{SHEET_HEADER}\n{sheet}"
223
+
224
+
225
+ def pin(messages: list[dict], text: str) -> None:
226
+ """Put a note next to the request the model is working on, replacing an earlier sheet there."""
227
+ if not text:
228
+ return
229
+ for message in reversed(messages):
230
+ content = str(message.get("content", ""))
231
+ if message.get("role") == "user" and not content.startswith(("[lcode]", SUMMARY_PREFIX)):
232
+ content = content.split("\n\n" + SHEET_HEADER, 1)[0]
233
+ message["content"] = f"{content}\n\n{text}"
234
+ return
235
+
236
+
190
237
  def note_removed(messages: list[dict], removed: list[str]) -> None:
191
238
  """Tell the model, next to the request it's working on, which files it no longer has: a stub deep in the
192
239
  conversation is easy to overlook, and models then answer from memory instead of reading again."""
@@ -0,0 +1,287 @@
1
+ """Find where an edit goes when the model's text doesn't match the file exactly.
2
+
3
+ Small models copy text imperfectly: with the line numbers `read_file` shows, with spaces where the file has
4
+ tabs, a block at the wrong indentation, quotes still escaped, a blank line dropped, a letter wrong. An exact
5
+ match fails and the model often repeats the same failing edit. So when the exact text isn't there, lcode tries,
6
+ in order: removing line-number prefixes and stray escapes; comparing lines without trailing whitespace, without
7
+ indentation (and re-indenting the new text to fit), without blank lines; and, last, a very close match. It
8
+ only ever applies a match that is unique; anything ambiguous or weak is refused, with the closest lines and
9
+ their numbers, so the model can copy them exactly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from difflib import SequenceMatcher
17
+
18
+ LINE_NUMBER = re.compile(r"^\s*\d+(?:\t|→| \| |: )") # how read_file and other tools number lines
19
+ CLOSE_ENOUGH = 0.9 # similarity a loose match needs
20
+ CLEAR_LEAD = 0.05 # how much better than the next one it must be
21
+ MAX_SIMILARITY_LINES = 20_000 # bigger files: no similarity search
22
+ HINT_FLOOR = 0.5 # weaker candidates aren't worth showing
23
+
24
+
25
+ @dataclass
26
+ class Found:
27
+ text: str # the file with the edit applied
28
+ how: str # how the match was found, for the model ("ignoring indentation"); "" for an exact match
29
+ start_line: int # first matched line, 1-based
30
+
31
+
32
+ @dataclass
33
+ class Miss:
34
+ reason: str # "not found" or "ambiguous"
35
+ hint: str # the closest lines, numbered, or ""
36
+
37
+
38
+ def numbered(lines: list[str], start: int) -> str:
39
+ return "\n".join(f"{start + i:6}\t{line.rstrip(chr(10))}" for i, line in enumerate(lines))
40
+
41
+
42
+ def strip_line_numbers(text: str) -> str | None:
43
+ lines = text.split("\n")
44
+ content = [line for line in lines if line.strip()]
45
+ if not content or not all(LINE_NUMBER.match(line) for line in content):
46
+ return None
47
+ return "\n".join(LINE_NUMBER.sub("", line, count=1) if line.strip() else line for line in lines)
48
+
49
+
50
+ def unescape(text: str, file_text: str) -> str | None:
51
+ """`\\"`, `\\n` and `\\t` written literally, when the file doesn't contain them literally."""
52
+ changed = text
53
+ for literal, real in (('\\"', '"'), ("\\'", "'"), ("\\t", "\t")):
54
+ if literal in changed and literal not in file_text:
55
+ changed = changed.replace(literal, real)
56
+ if "\\n" in changed and "\n" not in changed and "\\n" not in file_text:
57
+ changed = changed.replace("\\n", "\n")
58
+ return changed if changed != text else None
59
+
60
+
61
+ def indent_of(line: str) -> str:
62
+ return line[: len(line) - len(line.lstrip(" \t"))]
63
+
64
+
65
+ def width(indent: str) -> int:
66
+ return len(indent.expandtabs(4))
67
+
68
+
69
+ def reindent(new: str, old_lines: list[str], file_lines: list[str]) -> str:
70
+ """Move `new` from the old text's indentation to the file's, keeping its relative structure."""
71
+ old_first = next((line for line in old_lines if line.strip()), "")
72
+ file_first = next((line for line in file_lines if line.strip()), "")
73
+ new_first = next((line for line in new.split("\n") if line.strip()), "")
74
+ base_old, base_file = width(indent_of(old_first)), indent_of(file_first)
75
+ same_kind = ("\t" in base_file) == ("\t" in indent_of(new_first))
76
+ if base_old != width(base_file) and width(indent_of(new_first)) == width(base_file) and same_kind:
77
+ return new # the model got the old text's indentation wrong but wrote the new text in the file's
78
+ old_steps = sorted({width(indent_of(line)) - base_old for line in old_lines if line.strip()} - {0})
79
+ step_old = next((s for s in old_steps if s > 0), 4)
80
+ tabs = "\t" in base_file or any(indent_of(line).startswith("\t") for line in file_lines if line.strip())
81
+ file_steps = sorted({width(indent_of(line)) - width(base_file) for line in file_lines if line.strip()} - {0})
82
+ unit = "\t" if tabs else " " * next((s for s in file_steps if s > 0), step_old)
83
+ out = []
84
+ for line in new.split("\n"):
85
+ if not line.strip():
86
+ out.append(line)
87
+ continue
88
+ levels = round((width(indent_of(line)) - base_old) / step_old)
89
+ lead = base_file + unit * levels if levels >= 0 else base_file[: max(0, len(base_file) + levels * len(unit))]
90
+ out.append(lead + line.lstrip(" \t"))
91
+ return "\n".join(out)
92
+
93
+
94
+ def splice(lines: list[str], start: int, end: int, new: str) -> str:
95
+ """Replace lines[start:end] with `new`, keeping the line break the region ended with."""
96
+ ending = "\n" if lines[end - 1].endswith("\n") else ""
97
+ body = new.rstrip("\n")
98
+ return "".join(lines[:start]) + (body + ending if body else "") + "".join(lines[end:])
99
+
100
+
101
+ def line_matches(file_lines: list[str], old_lines: list[str], key) -> list[int]:
102
+ n = len(old_lines)
103
+ wanted = [key(line) for line in old_lines]
104
+ return [i for i in range(len(file_lines) - n + 1) if [key(line) for line in file_lines[i : i + n]] == wanted]
105
+
106
+
107
+ def locate(text: str, old: str, new: str) -> Found | Miss:
108
+ """Where `old` goes in `text`, when it isn't there exactly; with `new` in its place."""
109
+ # 1. Copying mistakes that leave the text itself right.
110
+ for fix, how in ((strip_line_numbers, "without the line numbers"), (unescape, "with the escapes undone")):
111
+ fixed = fix(old) if fix is strip_line_numbers else unescape(old, text)
112
+ if fixed is None:
113
+ continue
114
+ fixed_new = (fix(new) if fix is strip_line_numbers else unescape(new, text)) or new
115
+ if text.count(fixed) == 1:
116
+ start = text.index(fixed)
117
+ return Found(text.replace(fixed, fixed_new, 1), how, text.count("\n", 0, start) + 1)
118
+ old, new = fixed, fixed_new # the looser steps below work on the fixed text
119
+ file_lines = text.splitlines(keepends=True)
120
+ old_lines = old.strip("\n").split("\n")
121
+ if not old_lines or not any(line.strip() for line in old_lines):
122
+ return Miss("not found", "")
123
+ plain = [line.rstrip("\r\n") for line in file_lines]
124
+ # 2. Line by line, looser each time.
125
+ for key, how, indent in (
126
+ (lambda s: s.rstrip(), "ignoring trailing spaces", False),
127
+ (lambda s: s.strip(), "ignoring indentation", True),
128
+ ):
129
+ hits = line_matches(plain, old_lines, key)
130
+ if len(hits) == 1:
131
+ i = hits[0]
132
+ region = plain[i : i + len(old_lines)]
133
+ body = reindent(new.strip("\n"), old_lines, region) if indent else new.strip("\n")
134
+ return Found(splice(file_lines, i, i + len(old_lines), body), how, i + 1)
135
+ if len(hits) > 1:
136
+ return Miss("ambiguous", "\n\n".join(numbered(plain[i : i + len(old_lines)], i + 1) for i in hits[:3]))
137
+ # Without blank lines: map the non-blank lines back to the file.
138
+ solid = [line for line in old_lines if line.strip()]
139
+ content = [(i, line) for i, line in enumerate(plain) if line.strip()]
140
+ keys = [line.strip() for _, line in content]
141
+ wanted = [line.strip() for line in solid]
142
+ hits = [j for j in range(len(keys) - len(wanted) + 1) if keys[j : j + len(wanted)] == wanted]
143
+ first, last = (content[hits[0]][0], content[hits[0] + len(wanted) - 1][0]) if len(hits) == 1 else (0, -1)
144
+ if len(hits) == 1 and last - first + 1 != len(old_lines):
145
+ body = reindent(new.strip("\n"), old_lines, plain[first : last + 1])
146
+ return Found(splice(file_lines, first, last + 1, body), "ignoring blank lines", first + 1)
147
+ # 3. A very close match of the same number of lines, clearly better than any other. Only the lines the model
148
+ # changed change: the rest stays as the file has it now, so an earlier edit the model's copy doesn't show
149
+ # survives. Where a changed line differs in the file by more than a typo, the edit is refused.
150
+ if len(plain) > MAX_SIMILARITY_LINES:
151
+ return Miss("not found", "")
152
+ n = len(old_lines)
153
+ target = "\n".join(line.strip() for line in old_lines)
154
+ scores = []
155
+ for j in range(len(plain) - n + 1):
156
+ if (old_lines[0].strip() and not plain[j].strip()) or (old_lines[-1].strip() and not plain[j + n - 1].strip()):
157
+ continue # a window that starts or ends on a blank line where the model's text doesn't
158
+ window = "\n".join(line.strip() for line in plain[j : j + n])
159
+ matcher = SequenceMatcher(None, target, window, autojunk=False)
160
+ if matcher.real_quick_ratio() < HINT_FLOOR or matcher.quick_ratio() < HINT_FLOOR:
161
+ continue
162
+ scores.append((matcher.ratio(), j))
163
+ scores.sort(reverse=True)
164
+ if not scores or scores[0][0] < HINT_FLOOR:
165
+ return Miss("not found", "")
166
+ best, j = scores[0]
167
+ runner_up = scores[1][0] if len(scores) > 1 else 0.0
168
+ if best >= CLOSE_ENOUGH and best - runner_up >= CLEAR_LEAD:
169
+ merged = merge(old_lines, new.strip("\n").split("\n"), plain[j : j + n])
170
+ if merged is not None:
171
+ body = "\n".join(merged)
172
+ return Found(splice(file_lines, j, j + n, body), f"at a close match ({best:.0%} alike)", j + 1)
173
+ # To show the model where to look, compare line by line: one long line doesn't outweigh the rest.
174
+ hint = max(scores[:20], key=lambda score: line_by_line(old_lines, plain[score[1] : score[1] + n]))[1]
175
+ return Miss("not found", numbered(plain[hint : hint + n], hint + 1))
176
+
177
+
178
+ def line_by_line(old: list[str], region: list[str]) -> float:
179
+ pairs = list(zip(old, region, strict=False)) # the region may end early, at the end of the file
180
+ return sum(SequenceMatcher(None, a.strip(), b.strip(), autojunk=False).ratio() for a, b in pairs) / len(pairs)
181
+
182
+
183
+ def merge(old: list[str], new: list[str], region: list[str]) -> list[str] | None:
184
+ """Apply the model's change (old -> new) to the file's lines (`region`, aligned with `old`). None when a line
185
+ it changes differs in the file by more than a typo: the model's copy is out of date there."""
186
+ new = reindent("\n".join(new), old, region).split("\n")
187
+ out: list[str] = []
188
+ ops = SequenceMatcher(None, [line.strip() for line in old], [line.strip() for line in new], autojunk=False)
189
+ for tag, i1, i2, j1, j2 in ops.get_opcodes():
190
+ if tag == "equal":
191
+ out += region[i1:i2] # as the file has them
192
+ continue
193
+ for k in range(i1, i2):
194
+ alike = SequenceMatcher(None, old[k].strip(), region[k].strip(), autojunk=False).ratio()
195
+ if old[k].strip() != region[k].strip() and alike < CLOSE_ENOUGH:
196
+ return None
197
+ out += new[j1:j2]
198
+ return out
199
+
200
+
201
+ # ----------------------------------------------------------------------------- unified diffs
202
+
203
+
204
+ @dataclass
205
+ class Hunk:
206
+ header: str # the @@ line, for messages
207
+ old: str # its context and removed lines
208
+ new: str # its context and added lines
209
+
210
+
211
+ @dataclass
212
+ class FilePatch:
213
+ path: str # without a/ and b/
214
+ hunks: list[Hunk]
215
+ new_file: bool = False
216
+ deleted: bool = False
217
+
218
+
219
+ def strip_prefix(path: str) -> str:
220
+ path = path.strip().split("\t")[0]
221
+ return path[2:] if path[:2] in ("a/", "b/") else path
222
+
223
+
224
+ def parse_patch(patch: str) -> list[FilePatch]:
225
+ """A unified diff (as git diff writes it) as files and hunks. The @@ line numbers aren't trusted: hunks are
226
+ found by their text. Raises ValueError when it isn't a diff."""
227
+ files: list[FilePatch] = []
228
+ hunk: list[str] | None = None
229
+ current: FilePatch | None = None
230
+
231
+ def close() -> None:
232
+ nonlocal hunk
233
+ if hunk is not None and current is not None:
234
+ old = [line[1:] for line in hunk[1:] if line[:1] in (" ", "-")]
235
+ new = [line[1:] for line in hunk[1:] if line[:1] in (" ", "+")]
236
+ current.hunks.append(Hunk(hunk[0], "\n".join(old), "\n".join(new)))
237
+ hunk = None
238
+
239
+ lines = patch.replace("\r\n", "\n").split("\n")
240
+ i = 0
241
+ while i < len(lines):
242
+ line = lines[i]
243
+ if line.startswith("--- ") and i + 1 < len(lines) and lines[i + 1].startswith("+++ "):
244
+ close()
245
+ old_path, new_path = line[4:], lines[i + 1][4:]
246
+ gone, born = old_path.strip() == "/dev/null", new_path.strip() == "/dev/null"
247
+ current = FilePatch(strip_prefix(old_path if born else new_path), [], new_file=gone, deleted=born)
248
+ files.append(current)
249
+ i += 2
250
+ continue
251
+ if line.startswith("@@") and current is not None:
252
+ close()
253
+ hunk = [line]
254
+ elif hunk is not None:
255
+ if line.startswith(("\\ No newline", "diff --git", "index ")):
256
+ pass
257
+ elif line == "" and i == len(lines) - 1:
258
+ pass # the patch's own final newline
259
+ else:
260
+ hunk.append(line if line[:1] in (" ", "-", "+") else " " + line) # a context line that lost its space
261
+ i += 1
262
+ close()
263
+ if not files or not any(f.hunks for f in files):
264
+ raise ValueError("no ---/+++ file headers with @@ hunks: write a unified diff, as git diff does")
265
+ return files
266
+
267
+
268
+ def apply_hunks(text: str, hunks: list[Hunk]) -> tuple[str, list[str]]:
269
+ """Apply hunks one after another; returns the text and how each loose match was made. Raises ValueError
270
+ naming the hunk that can't be placed."""
271
+ notes = []
272
+ for n, hunk in enumerate(hunks, 1):
273
+ if not hunk.old.strip():
274
+ raise ValueError(f"hunk {n} ({hunk.header}) has no context lines to place it by")
275
+ count = text.count(hunk.old)
276
+ if count == 1:
277
+ text = text.replace(hunk.old, hunk.new, 1)
278
+ continue
279
+ if count > 1:
280
+ raise ValueError(f"hunk {n} ({hunk.header}) matches {count} places: add more context lines")
281
+ found = locate(text, hunk.old, hunk.new)
282
+ if isinstance(found, Miss):
283
+ where = f" The closest text is:\n{found.hint}" if found.hint else ""
284
+ raise ValueError(f"hunk {n} ({hunk.header}) doesn't match the file.{where}")
285
+ text = found.text
286
+ notes.append(f"hunk {n} {found.how}")
287
+ return text, notes
@@ -82,7 +82,7 @@ class Repeats:
82
82
  )
83
83
  hint = (
84
84
  "If it's an edit, read the file again and copy the text exactly. "
85
- if failed and name in ("edit_file", "write_file")
85
+ if failed and name in ("edit_file", "write_file", "apply_patch")
86
86
  else ""
87
87
  )
88
88
  return Verdict(
@@ -97,7 +97,7 @@ def changes_things(name: str, args: dict, result: str) -> bool:
97
97
  """Whether the call may have changed files (or anything else a later call could see)."""
98
98
  if name in UNCHANGING:
99
99
  return False
100
- if name in ("edit_file", "write_file"):
100
+ if name in ("edit_file", "write_file", "apply_patch"):
101
101
  return not result.startswith(FAILED)
102
102
  if name == "bash":
103
103
  return not is_read_only(str(args.get("command", "")))