lcode-cli 0.21.0__tar.gz → 0.23.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 (153) hide show
  1. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/CHANGELOG.md +31 -1
  2. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/PKG-INFO +3 -2
  3. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/README.md +2 -1
  4. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/automation.md +3 -2
  5. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/configuration.md +1 -0
  6. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/how-it-works.md +19 -0
  7. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/mcp.md +31 -0
  8. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/quickstart.md +1 -1
  9. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/pyproject.toml +2 -0
  10. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/__init__.py +1 -1
  11. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/acp.py +6 -1
  12. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/action.py +4 -2
  13. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/agent.py +49 -7
  14. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/api.py +5 -4
  15. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/config.py +1 -0
  16. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/catalog.toml +21 -0
  17. lcode_cli-0.23.0/src/lcode/repeats.py +104 -0
  18. lcode_cli-0.23.0/src/lcode/servers/xai.py +289 -0
  19. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/subagents.py +8 -3
  20. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_agent.py +17 -0
  21. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_api.py +8 -0
  22. lcode_cli-0.23.0/tests/test_repeats.py +88 -0
  23. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_subagents.py +14 -0
  24. lcode_cli-0.23.0/tests/test_xai.py +163 -0
  25. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.editorconfig +0 -0
  26. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.github/CODEOWNERS +0 -0
  27. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  28. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  29. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  30. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  31. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.github/dependabot.yml +0 -0
  33. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.github/workflows/ci.yml +0 -0
  34. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.github/workflows/docs.yml +0 -0
  35. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.github/workflows/release.yml +0 -0
  36. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.gitignore +0 -0
  37. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/.pre-commit-config.yaml +0 -0
  38. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/AGENTS.md +0 -0
  39. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/CODE_OF_CONDUCT.md +0 -0
  40. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/CONTRIBUTING.md +0 -0
  41. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/LICENSE +0 -0
  42. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/SECURITY.md +0 -0
  43. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/action.yml +0 -0
  44. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/agents.md +0 -0
  45. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/assets/demo.svg +0 -0
  46. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/assets/extra.css +0 -0
  47. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/assets/logo.svg +0 -0
  48. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/assets/social-preview.png +0 -0
  49. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/bench.md +0 -0
  50. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/changelog.md +0 -0
  51. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/commands.md +0 -0
  52. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/contributing.md +0 -0
  53. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/editors.md +0 -0
  54. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/git.md +0 -0
  55. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/hooks.md +0 -0
  56. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/index.md +0 -0
  57. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/installation.md +0 -0
  58. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/lsp.md +0 -0
  59. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/memory.md +0 -0
  60. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/models.md +0 -0
  61. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/sandbox.md +0 -0
  62. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/search.md +0 -0
  63. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/servers.md +0 -0
  64. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/troubleshooting.md +0 -0
  65. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/docs/usage.md +0 -0
  66. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/examples/README.md +0 -0
  67. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/examples/api/fix_tests.py +0 -0
  68. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/examples/commands/review.md +0 -0
  69. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/examples/skills/commit-message/SKILL.md +0 -0
  70. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  71. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/install.sh +0 -0
  72. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/mkdocs.yml +0 -0
  73. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/overrides/main.html +0 -0
  74. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/scripts/record_demo.py +0 -0
  75. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/scripts/social-preview.html +0 -0
  76. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/__main__.py +0 -0
  77. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/backends.py +0 -0
  78. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/bench.py +0 -0
  79. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/bench_backend.py +0 -0
  80. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/catalog.py +0 -0
  81. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/checkpoints.py +0 -0
  82. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/cli.py +0 -0
  83. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/codesearch.py +0 -0
  84. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/context.py +0 -0
  85. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/extensions.py +0 -0
  86. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/frontmatter.py +0 -0
  87. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/gitflow.py +0 -0
  88. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/hardware.py +0 -0
  89. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/hooks.py +0 -0
  90. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/jobs.py +0 -0
  91. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/limits.py +0 -0
  92. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/lsp.py +0 -0
  93. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/__init__.py +0 -0
  94. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/auth.py +0 -0
  95. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/catalog.py +0 -0
  96. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/client.py +0 -0
  97. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/commands.py +0 -0
  98. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/config.py +0 -0
  99. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/http.py +0 -0
  100. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/manager.py +0 -0
  101. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/protocol.py +0 -0
  102. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/server.py +0 -0
  103. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/mcp/stdio.py +0 -0
  104. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/memory.py +0 -0
  105. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/models.toml +0 -0
  106. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/notify.py +0 -0
  107. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/ollama.py +0 -0
  108. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/permissions.py +0 -0
  109. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/planning.py +0 -0
  110. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/render.py +0 -0
  111. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/repl.py +0 -0
  112. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/repomap.py +0 -0
  113. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/sandbox.py +0 -0
  114. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/servers/__init__.py +0 -0
  115. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/servers/encord.py +0 -0
  116. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/servers/valohai.py +0 -0
  117. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/sessions.py +0 -0
  118. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/tools.py +0 -0
  119. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/update.py +0 -0
  120. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/vision.py +0 -0
  121. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/src/lcode/web.py +0 -0
  122. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/conftest.py +0 -0
  123. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/fake_lsp_server.py +0 -0
  124. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/fake_mcp_server.py +0 -0
  125. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_acp.py +0 -0
  126. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_action.py +0 -0
  127. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_backends.py +0 -0
  128. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_bench.py +0 -0
  129. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_catalog.py +0 -0
  130. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_checkpoints.py +0 -0
  131. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_cli.py +0 -0
  132. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_codesearch.py +0 -0
  133. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_config.py +0 -0
  134. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_context.py +0 -0
  135. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_context_picker.py +0 -0
  136. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_extensions.py +0 -0
  137. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_gitflow.py +0 -0
  138. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_hooks.py +0 -0
  139. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_jobs.py +0 -0
  140. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_lsp.py +0 -0
  141. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_mcp.py +0 -0
  142. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_memory.py +0 -0
  143. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_permissions.py +0 -0
  144. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_plan_mode.py +0 -0
  145. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_repomap.py +0 -0
  146. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_sandbox.py +0 -0
  147. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_servers.py +0 -0
  148. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_sessions.py +0 -0
  149. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_tools.py +0 -0
  150. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_update.py +0 -0
  151. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_vision.py +0 -0
  152. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/tests/test_web.py +0 -0
  153. {lcode_cli-0.21.0 → lcode_cli-0.23.0}/uv.lock +0 -0
@@ -6,6 +6,34 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.23.0] - 2026-10-10
10
+
11
+ ### Added
12
+
13
+ - lcode notices when the model goes round in circles, making the same tool call with the same result
14
+ again and again, as small models sometimes do. The third time in a request (the second, for a call
15
+ that failed) the result tells the model so; two more, and lcode ends the request (status `loop`,
16
+ exit code `4` with `lcode -p`). Results that change, and checks run after an edit, don't count.
17
+ `repeat_limit` changes the threshold; `0` turns it off. See
18
+ [When the model repeats itself](https://nasser1941.github.io/lcode/how-it-works/#when-the-model-repeats-itself).
19
+
20
+ ### Fixed
21
+
22
+ - A tool call Ollama can't parse because its XML is broken (`XML syntax error … unexpected EOF`, from
23
+ models such as Qwen3.6 that write their calls in XML) is now sent back to the model to make again,
24
+ like a call with broken JSON, instead of ending the request with an error.
25
+
26
+ ## [0.22.0] - 2026-10-06
27
+
28
+ ### Added
29
+
30
+ - `lcode mcp add xai`: images, image edits, videos and speech with xAI's Grok Imagine and text to
31
+ speech APIs, in the cloud and billed to your xAI account. It asks for your xAI API key, and runs
32
+ `lcode-mcp-xai`, a new MCP server that ships with lcode, with the tools `generate_image`,
33
+ `edit_image`, `generate_video` (waits for the video and downloads it), `video_status` and
34
+ `text_to_speech`. Files are saved in `generated/` in the project or where you ask. See
35
+ [Images, videos and speech with xAI](https://nasser1941.github.io/lcode/mcp/#images-videos-and-speech-with-xai).
36
+
9
37
  ## [0.21.0] - 2026-10-06
10
38
 
11
39
  ### Added
@@ -480,7 +508,9 @@ First public release.
480
508
  - `AGENTS.md` project instructions and `/init` to generate them.
481
509
  - One-line installer for Linux and macOS.
482
510
 
483
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.21.0...HEAD
511
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.23.0...HEAD
512
+ [0.23.0]: https://github.com/nasser1941/lcode/compare/v0.22.0...v0.23.0
513
+ [0.22.0]: https://github.com/nasser1941/lcode/compare/v0.21.0...v0.22.0
484
514
  [0.21.0]: https://github.com/nasser1941/lcode/compare/v0.20.0...v0.21.0
485
515
  [0.20.0]: https://github.com/nasser1941/lcode/compare/v0.19.0...v0.20.0
486
516
  [0.19.0]: https://github.com/nasser1941/lcode/compare/v0.18.0...v0.19.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.21.0
3
+ Version: 0.23.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/
@@ -116,7 +116,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
116
116
  (`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
117
117
  - **Sees images.** Attach a screenshot or mockup with `@path` and lcode looks at it, using the
118
118
  vision part of your model. With ComfyUI (`lcode mcp add comfyui`) it can also generate and edit
119
- images with local models such as FLUX, SDXL and Qwen-Image.
119
+ images with local models such as FLUX, SDXL and Qwen-Image, or, in the cloud, images, videos
120
+ and speech with xAI's Grok Imagine (`lcode mcp add xai`).
120
121
  - **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
121
122
  search, Brave, Tavily or your own SearXNG, and reads pages as clean text.
122
123
  - **Private.** The model runs on your machine, lcode never uploads your files and has no telemetry.
@@ -80,7 +80,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
80
80
  (`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
81
81
  - **Sees images.** Attach a screenshot or mockup with `@path` and lcode looks at it, using the
82
82
  vision part of your model. With ComfyUI (`lcode mcp add comfyui`) it can also generate and edit
83
- images with local models such as FLUX, SDXL and Qwen-Image.
83
+ images with local models such as FLUX, SDXL and Qwen-Image, or, in the cloud, images, videos
84
+ and speech with xAI's Grok Imagine (`lcode mcp add xai`).
84
85
  - **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
85
86
  search, Brave, Tavily or your own SearXNG, and reads pages as clean text.
86
87
  - **Private.** The model runs on your machine, lcode never uploads your files and has no telemetry.
@@ -23,7 +23,8 @@ lcode -p "Explain the retry logic" --output stream-json --max-steps 20
23
23
  | `--allowed-tools LIST` | Only these tools, e.g. `"read_file,grep,glob,bash"` |
24
24
 
25
25
  lcode's usual output goes to stderr, so stdout holds only the JSON. The exit code says how it
26
- went: `0` success, `1` an error, `3` stopped at `--max-steps`, `130` interrupted.
26
+ went: `0` success, `1` an error, `3` stopped at `--max-steps`, `4` stopped because the model
27
+ [kept repeating itself](how-it-works.md#when-the-model-repeats-itself), `130` interrupted.
27
28
 
28
29
  ### The result
29
30
 
@@ -49,7 +50,7 @@ went: `0` success, `1` an error, `3` stopped at `--max-steps`, `130` interrupted
49
50
 
50
51
  | Field | |
51
52
  |---|---|
52
- | `status` | `success`, `error`, `max_steps` or `interrupted` |
53
+ | `status` | `success`, `error`, `max_steps`, `loop` (the model kept repeating the same call) or `interrupted` |
53
54
  | `text` | The model's final answer |
54
55
  | `tool_calls` | Every tool call, with its arguments and output (the first 4,000 characters). `error` is true when the call failed or wasn't allowed |
55
56
  | `files_changed` | Files the request added (`A`), modified (`M`) or deleted (`D`), shell commands included |
@@ -42,6 +42,7 @@ lcode config path # print the file location
42
42
  | `max_parallel_agents` | `1` | Subagents that run at the same time; more needs `OLLAMA_NUM_PARALLEL` on the Ollama server ([details](agents.md#several-at-once)) |
43
43
  | `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
44
44
  | `notify_after` | `30` | Seconds a request runs before it notifies |
45
+ | `repeat_limit` | `3` | Identical tool calls with identical results before lcode [steps in](how-it-works.md#when-the-model-repeats-itself); `0` turns it off |
45
46
  | `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
46
47
 
47
48
  Besides these settings, `config.toml` can hold [hooks](hooks.md) (`[[hooks]]`) and
@@ -29,6 +29,25 @@ All tools return text, and failures come back to the model as `Error: …` messa
29
29
  crashing the session, so the model can correct itself (for example re-read a file when an edit
30
30
  didn't match).
31
31
 
32
+ ### When the model repeats itself
33
+
34
+ Small models sometimes fall into a loop: they make a call, get a result, and make the very same call
35
+ again, and again. lcode counts, within a request, the calls that repeat an earlier one exactly: the
36
+ same tool, the same arguments and the same result.
37
+
38
+ - **At the third one** (the second, when the call failed) the result gets a note: the call returned
39
+ the same thing several times, so the model should use what it got, try something different, or
40
+ tell you what's blocking it.
41
+ - **Two more,** and lcode ends the request with
42
+ `Stopped: the model kept making the same call with the same result`. The JSON output's status is
43
+ `loop`.
44
+
45
+ A result that changes isn't a repeat, so a test that's run again after a fix, or a background job
46
+ that's checked while it runs, never adds up. A call that may have changed something, like an edit or
47
+ a shell command that isn't read-only, starts the count again for the other calls: checking the build
48
+ after each edit is fine. Change the threshold with `lcode config set repeat_limit 5`, or turn it off
49
+ with `0`.
50
+
32
51
  ## Subagents
33
52
 
34
53
  The `agent` tool starts a [subagent](agents.md): another agent loop with its own conversation, the
@@ -29,6 +29,7 @@ and find the code for the top one."*
29
29
  | `github` | Repositories, issues, pull requests, Actions | a GitHub token (or the GitHub CLI) |
30
30
  | `playwright` | A real (headless) browser: open pages, click, fill forms | `npx` |
31
31
  | `comfyui` | Generate and edit images with local models (FLUX, SDXL, SD 1.5, Qwen-Image) | `uvx` and [ComfyUI](#images-with-comfyui) running locally |
32
+ | `xai` | Generate and edit images, and generate videos and speech, with xAI's Grok Imagine (paid, in the cloud) | `uvx`, an xAI API key ([below](#images-videos-and-speech-with-xai)) |
32
33
  | `context7` | Up-to-date docs and examples for thousands of libraries | nothing (an API key is optional) |
33
34
  | `sentry` | Errors, issues, traces and releases | a Sentry account (browser sign-in) |
34
35
  | `postgres` | Schemas, read-only queries, query performance | `uvx`, a connection URL |
@@ -137,6 +138,36 @@ size, prompt), which can take several minutes of trial and error. Once an image
137
138
  lcode to save that workflow in your project (for example `assets/icon.workflow.json`) and reuse it:
138
139
  later images are a single `run_workflow` call.
139
140
 
141
+ ### Images, videos and speech with xAI
142
+
143
+ xAI's [Grok Imagine](https://docs.x.ai/developers/model-capabilities/imagine) and
144
+ [text to speech](https://docs.x.ai/developers/models/text-to-speech) APIs make images, videos and
145
+ speech in the cloud: no GPU needed, but each call is billed to your xAI account. lcode ships an MCP
146
+ server for them, `lcode-mcp-xai`.
147
+
148
+ ```bash
149
+ lcode mcp add xai # asks for your xAI API key (create one at https://console.x.ai)
150
+ ```
151
+
152
+ The key is typed hidden and kept in `~/.config/lcode/mcp.json`, readable only by you. If
153
+ `XAI_API_KEY` is already set in your environment, lcode uses that and doesn't store the key.
154
+
155
+ | Tool | | Costs |
156
+ |---|---|---|
157
+ | `generate_image` | Images from a description: 1–10 at a time, aspect ratio, 1k or 2k | $0.02–0.05 an image |
158
+ | `edit_image` | Change a local image as described | the same |
159
+ | `generate_video` | A 1–15 second MP4 from a description, optionally starting from a local image, up to 1080p. Waits for it (a minute or more) | $0.02–0.08 a second |
160
+ | `video_status` | Fetch a video that wasn't ready within 10 minutes | |
161
+ | `text_to_speech` | Speech in one of 26 voices and 20 languages, as MP3 or WAV | per character |
162
+
163
+ Files are saved in `generated/` in the project, or where you ask (*"generate an app icon and save
164
+ it as assets/icon.png"*). xAI has no music generation API.
165
+
166
+ Your prompts, and the images you edit or animate, go to xAI. lcode asks before each call, since each
167
+ one costs money. To stop asking for some tools, list them under `"allow"` for the server in
168
+ `mcp.json`, for example `"allow": ["generate_image", "text_to_speech"]`. xAI applies its own usage
169
+ policies to what it generates; when it refuses a prompt, lcode passes its message to the model.
170
+
140
171
  ### Metabase
141
172
 
142
173
  Metabase has its own MCP server inside every instance (Metabase 60 and later), at
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.21.0 — local coding agent │
15
+ │ lcode v0.23.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -49,6 +49,7 @@ Changelog = "https://github.com/nasser1941/lcode/blob/main/CHANGELOG.md"
49
49
  lcode = "lcode.cli:main"
50
50
  lcode-mcp-valohai = "lcode.servers.valohai:main"
51
51
  lcode-mcp-encord = "lcode.servers.encord:main"
52
+ lcode-mcp-xai = "lcode.servers.xai:main"
52
53
 
53
54
  [dependency-groups]
54
55
  dev = ["pytest>=8", "ruff>=0.6"]
@@ -88,6 +89,7 @@ ignore = [
88
89
  "src/lcode/action.py" = ["E501"]
89
90
  "src/lcode/acp.py" = ["E501"]
90
91
  "src/lcode/update.py" = ["E501"]
92
+ "src/lcode/servers/xai.py" = ["E501"]
91
93
 
92
94
  [tool.pytest.ini_options]
93
95
  testpaths = ["tests"]
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.21.0"
6
+ __version__ = "0.23.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -48,7 +48,12 @@ KINDS = {
48
48
  "bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
49
49
  "present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
50
50
  } # fmt: skip
51
- STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"}
51
+ STOP = {
52
+ "success": "end_turn",
53
+ "max_steps": "max_turn_requests",
54
+ "loop": "max_turn_requests",
55
+ "interrupted": "cancelled",
56
+ }
52
57
  MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
53
58
  METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR, NOT_FOUND = -32601, -32602, -32603, -32002
54
59
 
@@ -297,7 +297,7 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
297
297
  )
298
298
  with open_session(inputs, cwd) as session:
299
299
  result = session.run(prompt)
300
- if result.status not in ("success", "max_steps"):
300
+ if result.status not in ("success", "max_steps", "loop"):
301
301
  raise ActionError(result.error or result.status)
302
302
  reply = result.text.strip() or "(no answer)"
303
303
  changed = git(cwd, "status", "--porcelain").strip()
@@ -336,6 +336,8 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
336
336
  reply += f"\n\n{link}"
337
337
  if result.status == "max_steps":
338
338
  reply += f"\n\n(Stopped after {inputs.max_steps} steps.)"
339
+ elif result.status == "loop":
340
+ reply += "\n\n(Stopped: the model kept repeating the same call.)"
339
341
  hub.comment(task.number, reply[:MAX_COMMENT] + footer(result))
340
342
 
341
343
 
@@ -349,7 +351,7 @@ def review(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
349
351
  prompt = gitflow.review_prompt(session.agent, base)
350
352
  session.agent.no_changes = "This is a review, so nothing can be changed: report what should change instead."
351
353
  result = session.run(prompt)
352
- if result.status not in ("success", "max_steps"):
354
+ if result.status not in ("success", "max_steps", "loop"):
353
355
  raise ActionError(result.error or result.status)
354
356
  hub.comment(task.number, f"**lcode review**\n\n{result.text.strip()[:MAX_COMMENT]}{footer(result)}")
355
357
 
@@ -32,6 +32,7 @@ from lcode.mcp import McpManager
32
32
  from lcode.ollama import Ollama, OllamaError
33
33
  from lcode.permissions import Permissions
34
34
  from lcode.render import MarkdownStreamer
35
+ from lcode.repeats import Repeats
35
36
  from lcode.sandbox import Sandbox, SandboxError, project_root
36
37
  from lcode.tools import (
37
38
  LSP_SCHEMA,
@@ -57,7 +58,9 @@ MAX_STEPS_PER_TURN = 150
57
58
  EVENT_OUTPUT = 4000 # characters of a tool's output in an event
58
59
  # How a tool result starts when the call failed or wasn't allowed (an "error" in events and results).
59
60
  REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
60
- MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON # safety cap on tool-call iterations for one request
61
+ MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
62
+ # How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
63
+ MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
61
64
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
62
65
  PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
63
66
  PROJECT_FILES = ("AGENTS.md", "LCODE.md", "CLAUDE.md")
@@ -216,6 +219,7 @@ class Settings:
216
219
  notify: bool = False # desktop notifications after long requests (lcode.notify)
217
220
  notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
218
221
  max_parallel_agents: int = 1
222
+ repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
219
223
 
220
224
 
221
225
  class Agent:
@@ -267,7 +271,8 @@ class Agent:
267
271
  # Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
268
272
  self.plan_review: Callable[[str, str], str] | None = None
269
273
  self.max_steps = MAX_STEPS_PER_TURN
270
- self.turn_status = "" # how the last request ended: success, max_steps
274
+ self.turn_status = "" # how the last request ended: success, max_steps, loop
275
+ self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
271
276
  self.response = None # the streaming response, so another thread can abort it
272
277
  self.ctx_used = 0
273
278
  self.last_speed = 0.0
@@ -809,7 +814,9 @@ class Agent:
809
814
  self.turn_status = "running"
810
815
  note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
811
816
  self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
817
+ self.repeats.reset()
812
818
  malformed = 0
819
+ looping = "" # the call the model kept repeating, when lcode stopped the request for it
813
820
  for step in range(self.max_steps):
814
821
  if self.cancel is not None and self.cancel.is_set():
815
822
  raise KeyboardInterrupt
@@ -834,17 +841,22 @@ class Agent:
834
841
  }
835
842
  )
836
843
  except OllamaError as e:
837
- if "error parsing tool call" not in str(e) or malformed >= MAX_MALFORMED_CALL_RETRIES:
844
+ if not any(m in str(e) for m in MALFORMED_CALL) or malformed >= MAX_MALFORMED_CALL_RETRIES:
838
845
  raise
839
846
  malformed += 1
840
- reason = str(e).rsplit("err=", 1)[-1].strip() if "err=" in str(e) else "invalid JSON"
847
+ if "err=" in str(e):
848
+ reason = str(e).rsplit("err=", 1)[-1].strip()
849
+ elif "XML syntax error" in str(e):
850
+ reason = str(e).split("XML syntax error", 1)[1].strip(" :") or "invalid XML"
851
+ else:
852
+ reason = "invalid JSON"
841
853
  self.console.print("[yellow]The model wrote a malformed tool call; asking it to try again.[/]")
842
854
  self.messages.append(
843
855
  {
844
856
  "role": "user",
845
- "content": f"[lcode] Your last tool call could not be parsed ({reason}): its arguments "
846
- "must be one complete, valid JSON object. Make the call again. If it writes a file, "
847
- "make sure the whole content is included and properly escaped.",
857
+ "content": f"[lcode] Your last tool call could not be parsed ({reason}): it must be "
858
+ "complete and well-formed, with every argument closed. Make the call again. If it writes "
859
+ "a file, make sure the whole content is included and properly escaped.",
848
860
  }
849
861
  )
850
862
  continue
@@ -892,6 +904,15 @@ class Agent:
892
904
  self.console.print(Text(f" {result[:300]}", style="red"))
893
905
  elif name in ("read_file", "grep", "glob", "list_dir"):
894
906
  self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
907
+ verdict = self.repeats.check(name, args, result)
908
+ if verdict.note:
909
+ result += verdict.note
910
+ what = "stopping the request" if verdict.stop else "told the model"
911
+ self.console.print(
912
+ Text(f" ⎿ same call, same result, {verdict.count} times: {what}", style="yellow")
913
+ )
914
+ if verdict.stop:
915
+ looping = self.describe_call(name, args)
895
916
  tool_msg = {"role": "tool", "tool_name": name, "content": result}
896
917
  if call.get("id"):
897
918
  tool_msg["tool_call_id"] = call["id"]
@@ -905,6 +926,27 @@ class Agent:
905
926
  "output": result[:EVENT_OUTPUT] + ("…" if len(result) > EVENT_OUTPUT else ""),
906
927
  }
907
928
  )
929
+ if looping:
930
+ for rest in calls[i + 1 :]:
931
+ skipped = {
932
+ "role": "tool",
933
+ "tool_name": rest.get("function", {}).get("name", ""),
934
+ "content": "Not run: lcode stopped the request before this call.",
935
+ }
936
+ if rest.get("id"):
937
+ skipped["tool_call_id"] = rest["id"]
938
+ self.messages.append(skipped)
939
+ break
940
+ if looping:
941
+ self.turn_status = "loop"
942
+ self.console.print(
943
+ Text(
944
+ f"Stopped: the model kept making the same call with the same result: {looping}. Tell it "
945
+ "what to do differently, or try again.",
946
+ style="yellow",
947
+ )
948
+ )
949
+ return
908
950
  else:
909
951
  self.turn_status = "max_steps"
910
952
  self.console.print(f"[yellow]Stopped after {self.max_steps} steps.[/]")
@@ -35,8 +35,8 @@ if TYPE_CHECKING:
35
35
  from lcode.hardware import Hardware
36
36
 
37
37
  SCHEMA_VERSION = 1
38
- STATUSES = ("success", "error", "max_steps", "interrupted")
39
- EXIT_CODES = {"success": 0, "error": 1, "max_steps": 3, "interrupted": 130}
38
+ STATUSES = ("success", "error", "max_steps", "loop", "interrupted")
39
+ EXIT_CODES = {"success": 0, "error": 1, "max_steps": 3, "loop": 4, "interrupted": 130}
40
40
  OLLAMA_INSTALL = {
41
41
  "linux": "curl -fsSL https://ollama.com/install.sh | sh",
42
42
  "darwin": "brew install ollama (or download it from https://ollama.com/download)",
@@ -69,7 +69,7 @@ class Options:
69
69
  class Result:
70
70
  """How a request went. `to_json()` is what `lcode -p --output json` prints."""
71
71
 
72
- status: str # success, error, max_steps or interrupted
72
+ status: str # success, error, max_steps, loop (stopped: the model kept repeating itself) or interrupted
73
73
  text: str # the model's final answer
74
74
  session_id: str
75
75
  model: str
@@ -194,6 +194,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
194
194
  embed_model=cfg["embed_model"],
195
195
  notify=cfg["notify"],
196
196
  notify_after=cfg["notify_after"],
197
+ repeat_limit=cfg["repeat_limit"],
197
198
  )
198
199
  agent = Agent(ollama, settings, cwd, console=console)
199
200
  agent.interactive = options.interactive
@@ -272,7 +273,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
272
273
 
273
274
  try:
274
275
  agent.run_turn(prompt)
275
- status = "max_steps" if agent.turn_status == "max_steps" else "success"
276
+ status = agent.turn_status if agent.turn_status in ("max_steps", "loop") else "success"
276
277
  except KeyboardInterrupt:
277
278
  status, error = "interrupted", "interrupted before the request was done"
278
279
  except ModelError as e:
@@ -59,6 +59,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
59
59
  "subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
60
60
  "notify": (True, bool, "desktop notification when a long request is done or waits for you"),
61
61
  "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
62
+ "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
62
63
  "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
63
64
  }
64
65
  ENV_OVERRIDES = {
@@ -281,3 +281,24 @@ secret = true
281
281
  var = "VALOHAI_HOST"
282
282
  prompt = "Valohai address (Enter for app.valohai.com; for a self-hosted installation, its URL)"
283
283
  optional = true
284
+
285
+ [xai]
286
+ name = "xAI Grok Imagine"
287
+ description = "Generate and edit images, and generate videos and speech, with xAI's Grok Imagine and voice API (paid, in the cloud)"
288
+ homepage = "https://nasser1941.github.io/lcode/mcp/#xai"
289
+ requires = ["uvx"]
290
+ setup = """Uses xAI's API: your prompts, and images you edit or animate, go to xAI, and every call is billed to your xAI
291
+ account (an image $0.02-0.05, video $0.02-0.08 a second, speech per character). Create an API key at https://console.x.ai.
292
+ Files are saved in generated/ in the project. lcode asks before each call; to stop asking for some tools, list them
293
+ under "allow" for this server in mcp.json. xAI has no music generation."""
294
+
295
+ [xai.server]
296
+ command = "uvx"
297
+ args = ["--isolated", "--python", "3.12", "--from", "lcode-cli", "lcode-mcp-xai"]
298
+ env = { XAI_API_KEY = "${XAI_API_KEY}" }
299
+ timeout = 900 # a video takes minutes
300
+
301
+ [[xai.inputs]]
302
+ var = "XAI_API_KEY"
303
+ prompt = "xAI API key (create one at https://console.x.ai)"
304
+ secret = true
@@ -0,0 +1,104 @@
1
+ """Notice when the model goes round in circles.
2
+
3
+ Small models sometimes fall into a loop: they make a tool call, get a result, and make the very same call
4
+ again, and again, until the user steps in. Within one request lcode counts the calls that repeat an earlier
5
+ one exactly: the same tool, the same arguments and the same result. At the `limit`-th one (a call that
6
+ failed: one sooner) the result gets a note that tells the model so, and if it still goes on, lcode ends the
7
+ request.
8
+
9
+ Results that change aren't repeats: a test run again after a fix, a background job read while it runs. And
10
+ a call that may have changed something (a file edit, a shell command that isn't read-only) starts the count
11
+ again for every other call, so checking a build after each edit never adds up. Its own repeats still count:
12
+ running the same failing command over and over is a loop.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ from dataclasses import dataclass
20
+
21
+ from lcode.permissions import is_read_only
22
+
23
+ STOP_AFTER_NOTE = 2 # repeats after the note before the request ends
24
+ FAILED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
25
+ UNCHANGING = frozenset(
26
+ {
27
+ "read_file",
28
+ "list_dir",
29
+ "glob",
30
+ "grep",
31
+ "view_image",
32
+ "repo_map",
33
+ "search_code",
34
+ "lsp",
35
+ "web_search",
36
+ "web_fetch",
37
+ "bash_output",
38
+ "todo_write",
39
+ "skill",
40
+ "mcp_find_tools",
41
+ }
42
+ ) # tools that don't change files; anything else might
43
+
44
+
45
+ @dataclass
46
+ class Verdict:
47
+ count: int # how often this exact call got this exact result in the request
48
+ note: str = "" # to add to the tool result
49
+ stop: bool = False # end the request
50
+
51
+
52
+ class Repeats:
53
+ def __init__(self, limit: int = 3) -> None:
54
+ self.limit = limit # 0: off
55
+ self.seen: dict[str, int] = {}
56
+
57
+ def reset(self) -> None:
58
+ """A new request."""
59
+ self.seen.clear()
60
+
61
+ def check(self, name: str, args: dict, result: str) -> Verdict:
62
+ """Count this call and its result."""
63
+ if self.limit <= 0:
64
+ return Verdict(1)
65
+ key = hashlib.sha256(json.dumps([name, args, result], sort_keys=True, default=str).encode()).hexdigest()
66
+ count = self.seen.get(key, 0) + 1
67
+ if changes_things(name, args, result):
68
+ self.seen.clear()
69
+ self.seen[key] = count
70
+ failed = result.startswith(FAILED)
71
+ note_at = max(2, self.limit - 1) if failed else max(2, self.limit)
72
+ if count < note_at:
73
+ return Verdict(count)
74
+ same = "the same error" if failed else "the same result"
75
+ if count >= note_at + STOP_AFTER_NOTE:
76
+ return Verdict(
77
+ count,
78
+ f"\n\n[lcode] You made this exact call {count} times in this request and got {same} each "
79
+ "time, so lcode stopped the request here. Next time, don't repeat a call that didn't help: "
80
+ "change your approach, or tell the user what is blocking you.",
81
+ stop=True,
82
+ )
83
+ hint = (
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")
86
+ else ""
87
+ )
88
+ return Verdict(
89
+ count,
90
+ f"\n\n[lcode] You made this exact call {count} times in this request and got {same} each time. "
91
+ f"Making it again won't change that. Use what it returned, or try something different. {hint}"
92
+ "If you're stuck, stop and tell the user what is blocking you.",
93
+ )
94
+
95
+
96
+ def changes_things(name: str, args: dict, result: str) -> bool:
97
+ """Whether the call may have changed files (or anything else a later call could see)."""
98
+ if name in UNCHANGING:
99
+ return False
100
+ if name in ("edit_file", "write_file"):
101
+ return not result.startswith(FAILED)
102
+ if name == "bash":
103
+ return not is_read_only(str(args.get("command", "")))
104
+ return True # other tools (MCP, subagents…) might