lcode-cli 0.27.0__tar.gz → 0.28.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 (160) hide show
  1. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/CHANGELOG.md +26 -1
  2. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/quickstart.md +1 -1
  4. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/usage.md +27 -0
  5. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/__init__.py +1 -1
  6. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/agent.py +123 -17
  7. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/api.py +1 -0
  8. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/repl.py +24 -4
  9. lcode_cli-0.28.0/src/lcode/residency.py +167 -0
  10. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/tools.py +2 -3
  11. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/conftest.py +6 -0
  12. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_agent.py +4 -3
  13. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_mcp.py +2 -1
  14. lcode_cli-0.28.0/tests/test_residency.py +164 -0
  15. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_vision.py +5 -4
  16. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.editorconfig +0 -0
  17. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/CODEOWNERS +0 -0
  18. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  20. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  21. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  22. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/dependabot.yml +0 -0
  24. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/workflows/ci.yml +0 -0
  25. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/workflows/docs.yml +0 -0
  26. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/workflows/release.yml +0 -0
  27. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.gitignore +0 -0
  28. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.pre-commit-config.yaml +0 -0
  29. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/AGENTS.md +0 -0
  30. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/CODE_OF_CONDUCT.md +0 -0
  31. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/CONTRIBUTING.md +0 -0
  32. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/LICENSE +0 -0
  33. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/README.md +0 -0
  34. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/SECURITY.md +0 -0
  35. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/action.yml +0 -0
  36. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/agents.md +0 -0
  37. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/assets/demo.svg +0 -0
  38. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/assets/extra.css +0 -0
  39. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/assets/logo.svg +0 -0
  40. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/assets/social-preview.png +0 -0
  41. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/automation.md +0 -0
  42. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/bench.md +0 -0
  43. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/changelog.md +0 -0
  44. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/commands.md +0 -0
  45. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/configuration.md +0 -0
  46. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/contributing.md +0 -0
  47. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/editors.md +0 -0
  48. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/git.md +0 -0
  49. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/hooks.md +0 -0
  50. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/how-it-works.md +0 -0
  51. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/index.md +0 -0
  52. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/installation.md +0 -0
  53. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/lsp.md +0 -0
  54. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/mcp.md +0 -0
  55. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/memory.md +0 -0
  56. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/models.md +0 -0
  57. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/sandbox.md +0 -0
  58. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/search.md +0 -0
  59. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/servers.md +0 -0
  60. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/troubleshooting.md +0 -0
  61. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/examples/README.md +0 -0
  62. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/examples/api/fix_tests.py +0 -0
  63. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/examples/commands/review.md +0 -0
  64. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/examples/skills/commit-message/SKILL.md +0 -0
  65. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  66. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/install.sh +0 -0
  67. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/mkdocs.yml +0 -0
  68. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/overrides/main.html +0 -0
  69. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/pyproject.toml +0 -0
  70. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/scripts/record_demo.py +0 -0
  71. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/scripts/social-preview.html +0 -0
  72. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/__main__.py +0 -0
  73. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/acp.py +0 -0
  74. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/action.py +0 -0
  75. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/backends.py +0 -0
  76. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/bench.py +0 -0
  77. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/bench_backend.py +0 -0
  78. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/bench_runtime.py +0 -0
  79. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/catalog.py +0 -0
  80. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/checkpoints.py +0 -0
  81. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/cli.py +0 -0
  82. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/clipboard.py +0 -0
  83. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/codesearch.py +0 -0
  84. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/config.py +0 -0
  85. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/context.py +0 -0
  86. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/extensions.py +0 -0
  87. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/frontmatter.py +0 -0
  88. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/gitflow.py +0 -0
  89. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/hardware.py +0 -0
  90. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/hooks.py +0 -0
  91. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/jobs.py +0 -0
  92. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/limits.py +0 -0
  93. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/lsp.py +0 -0
  94. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/__init__.py +0 -0
  95. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/auth.py +0 -0
  96. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/catalog.py +0 -0
  97. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/catalog.toml +0 -0
  98. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/client.py +0 -0
  99. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/commands.py +0 -0
  100. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/config.py +0 -0
  101. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/http.py +0 -0
  102. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/manager.py +0 -0
  103. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/protocol.py +0 -0
  104. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/server.py +0 -0
  105. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/stdio.py +0 -0
  106. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/memory.py +0 -0
  107. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/models.toml +0 -0
  108. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/notify.py +0 -0
  109. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/ollama.py +0 -0
  110. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/permissions.py +0 -0
  111. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/planning.py +0 -0
  112. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/render.py +0 -0
  113. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/repeats.py +0 -0
  114. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/repomap.py +0 -0
  115. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/sandbox.py +0 -0
  116. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/secretscan.py +0 -0
  117. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/servers/__init__.py +0 -0
  118. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/servers/encord.py +0 -0
  119. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/servers/valohai.py +0 -0
  120. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/servers/xai.py +0 -0
  121. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/sessions.py +0 -0
  122. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/subagents.py +0 -0
  123. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/update.py +0 -0
  124. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/vision.py +0 -0
  125. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/web.py +0 -0
  126. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/fake_lsp_server.py +0 -0
  127. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/fake_mcp_server.py +0 -0
  128. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_acp.py +0 -0
  129. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_action.py +0 -0
  130. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_api.py +0 -0
  131. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_backends.py +0 -0
  132. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_bench.py +0 -0
  133. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_catalog.py +0 -0
  134. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_checkpoints.py +0 -0
  135. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_cli.py +0 -0
  136. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_clipboard.py +0 -0
  137. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_codesearch.py +0 -0
  138. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_config.py +0 -0
  139. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_context.py +0 -0
  140. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_context_picker.py +0 -0
  141. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_extensions.py +0 -0
  142. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_gitflow.py +0 -0
  143. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_hooks.py +0 -0
  144. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_jobs.py +0 -0
  145. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_lsp.py +0 -0
  146. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_memory.py +0 -0
  147. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_permissions.py +0 -0
  148. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_plan_mode.py +0 -0
  149. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_repeats.py +0 -0
  150. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_repomap.py +0 -0
  151. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_sandbox.py +0 -0
  152. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_secretscan.py +0 -0
  153. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_servers.py +0 -0
  154. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_sessions.py +0 -0
  155. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_subagents.py +0 -0
  156. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_tools.py +0 -0
  157. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_update.py +0 -0
  158. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_web.py +0 -0
  159. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_xai.py +0 -0
  160. {lcode_cli-0.27.0 → lcode_cli-0.28.0}/uv.lock +0 -0
@@ -6,6 +6,30 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.28.0] - 2026-10-13
10
+
11
+ ### Added
12
+
13
+ - lcode says what's happening under the hood, in a line, only when it happens:
14
+ - loading the model and then reading the conversation are named in the status line instead of
15
+ "Thinking";
16
+ - after a load, how long it took and how much of the model is on the GPU, with a warning when less
17
+ is than usual;
18
+ - at startup and on `/model`, a warning when a dense model won't fit on the GPU and will be slow,
19
+ with what to use instead;
20
+ - the vision model making the main model step aside, and the reload after it;
21
+ - an image tool taking the GPU;
22
+ - clearer explanations when Ollama runs out of memory.
23
+
24
+ The status bar shows when the model is still loading at startup. See
25
+ [What's happening under the hood](https://nasser1941.github.io/lcode/usage/#whats-happening-under-the-hood).
26
+
27
+ ### Fixed
28
+
29
+ - After `/model`, images were still described by the vision model chosen for the previous model.
30
+ - The tests no longer write into your lcode state folder; one of them had left about a hundred
31
+ entries in `extensions-approved.json`.
32
+
9
33
  ## [0.27.0] - 2026-10-10
10
34
 
11
35
  ### Changed
@@ -575,7 +599,8 @@ First public release.
575
599
  - `AGENTS.md` project instructions and `/init` to generate them.
576
600
  - One-line installer for Linux and macOS.
577
601
 
578
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.27.0...HEAD
602
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.0...HEAD
603
+ [0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
579
604
  [0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
580
605
  [0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
581
606
  [0.25.0]: https://github.com/nasser1941/lcode/compare/v0.24.0...v0.25.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.27.0
3
+ Version: 0.28.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/
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.27.0 — local coding agent │
15
+ │ lcode v0.28.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -133,6 +133,33 @@ since last time with `bash_output`, and stops it with `bash_stop`.
133
133
  - Background commands ask for permission like any other command, and run in the
134
134
  [sandbox](sandbox.md) when it's on.
135
135
 
136
+ ## What's happening under the hood
137
+
138
+ Much of the waiting with a local model isn't the model thinking: it's loading the model, or reading
139
+ a long conversation before the first word. lcode says which, in the status line under a request:
140
+
141
+ ```text
142
+ ⠋ Loading qwen3.6-35b into memory… (0 tokens, 9s · Ctrl+C to stop)
143
+ ⠋ Reading the conversation (18K tokens)… (0 tokens, 21s · Ctrl+C to stop)
144
+ ⠋ Thinking… (312 tokens, 27s · Ctrl+C to stop)
145
+ ```
146
+
147
+ It prints a line only when something worth knowing happens, and nothing while the model simply
148
+ stays loaded:
149
+
150
+ | When | What it says |
151
+ |---|---|
152
+ | The model was loaded | `◆ Loaded qwen3.6-35b in 15s: 22.3 GB, 45% on the GPU and the rest in system RAM.`, and a warning when less of it is on the GPU than usual, because another program or model is using GPU memory |
153
+ | A dense model doesn't fit on the GPU (at startup and on `/model`) | `⚠ qwen3.8-27b is a dense model: all 27B parameters work on every token. It's capable, but slow here. Only about 61% of it fits in the 12 GB of GPU memory…`, with what to do instead |
154
+ | The model that looks at images needs the memory | `Loading the vision model … to look at bug.png; qwen3.6-35b makes room and reloads afterwards`, then `◆ qwen3.6-35b was unloaded to make room…`, and the next step says `Reloading qwen3.6-35b into memory (it made room for the vision model …)` |
155
+ | A tool needs the GPU to itself, such as image generation | `◆ Unloaded qwen3.6-35b so comfyui can use the GPU; it reloads afterwards.` |
156
+ | Ollama ran out of memory | `⚠` what ran out (the prompt batch, or the context next to the model), what lcode retries with, and what it remembers for next time |
157
+
158
+ While the model loads in the background at startup, the status bar says
159
+ `loading the model into memory…`. Mixture-of-Experts models, such as qwen3.6-35b, run well with
160
+ part of them in system RAM. Dense ones slow down a lot, to a few tokens a second, which is why they
161
+ get the warning.
162
+
136
163
  ## Notifications
137
164
 
138
165
  Local models take a while, so you'll often switch to something else during a request. lcode shows
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.27.0"
6
+ __version__ = "0.28.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import base64
6
+ import contextlib
6
7
  import datetime as dt
7
8
  import json
8
9
  import platform
@@ -23,7 +24,20 @@ from rich.markup import escape
23
24
  from rich.panel import Panel
24
25
  from rich.text import Text
25
26
 
26
- from lcode import catalog, codesearch, extensions, limits, notify, planning, repomap, sessions, subagents, vision, web
27
+ from lcode import (
28
+ catalog,
29
+ codesearch,
30
+ extensions,
31
+ limits,
32
+ notify,
33
+ planning,
34
+ repomap,
35
+ residency,
36
+ sessions,
37
+ subagents,
38
+ vision,
39
+ web,
40
+ )
27
41
  from lcode import context as context_tools
28
42
  from lcode import memory as memory_notes
29
43
  from lcode.checkpoints import Checkpoints
@@ -53,7 +67,12 @@ from lcode.tools import (
53
67
  truncate,
54
68
  )
55
69
 
56
- GPU_MEMORY_ERRORS = ("out of memory", "illegal memory access", "cudamalloc failed")
70
+ GPU_MEMORY_ERRORS = (
71
+ "out of memory",
72
+ "illegal memory access",
73
+ "cudamalloc failed",
74
+ "requires more system memory", # Ollama: the model and its context don't fit in RAM either
75
+ )
57
76
  SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
58
77
  MAX_STEPS_PER_TURN = 150
59
78
  EVENT_OUTPUT = 4000 # characters of a tool's output in an event
@@ -62,6 +81,7 @@ REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's pe
62
81
  MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
63
82
  # How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
64
83
  MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
84
+ READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
65
85
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
66
86
  PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
67
87
  # @path, @"path with spaces" or @'path'
@@ -171,6 +191,10 @@ def project_root_or_cwd(cwd: Path) -> Path:
171
191
  return work_tree_for(cwd)
172
192
 
173
193
 
194
+ def conversation_chars(messages: list[dict]) -> int:
195
+ return sum(len(str(m.get("content") or "")) + len(str(m.get("thinking") or "")) for m in messages)
196
+
197
+
174
198
  def call_arguments(call: dict) -> dict:
175
199
  """A tool call's arguments as a dict (some models send them as a JSON string)."""
176
200
  args = call.get("function", {}).get("arguments") or {}
@@ -189,17 +213,22 @@ class Progress:
189
213
  arrives, for example while the model writes a long file into a tool call.
190
214
  """
191
215
 
192
- def __init__(self) -> None:
216
+ def __init__(self, phase: str = "Thinking") -> None:
193
217
  self.started = self.last_chunk = time.time()
194
- self.phase = "Thinking"
218
+ self.phase = phase
195
219
  self.chars = 0
196
220
 
197
221
  def update(self, phase: str, text: str) -> None:
198
222
  self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
199
223
 
224
+ def set_phase(self, phase: str) -> None:
225
+ """What happens before the first token: loading the model, reading the prompt."""
226
+ self.phase, self.last_chunk = phase, time.time()
227
+
200
228
  def __rich__(self) -> Text:
201
229
  now = time.time()
202
- phase = self.phase if now - self.last_chunk < 3 else "Working"
230
+ streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
231
+ phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
203
232
  return Text.from_markup(
204
233
  f"[cyan]{phase}…[/] [dim]({self.chars // 4} tokens, {now - self.started:.0f}s · Ctrl+C to stop)[/]"
205
234
  )
@@ -288,6 +317,10 @@ class Agent:
288
317
  # Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
289
318
  self.plan_review: Callable[[str, str], str] | None = None
290
319
  self.max_steps = MAX_STEPS_PER_TURN
320
+ self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
321
+ self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
322
+ self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
323
+ self.sent_chars = 0 # characters of the conversation the model had already read
291
324
  self.turn_status = "" # how the last request ended: success, max_steps, loop
292
325
  self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
293
326
  self.response = None # the streaming response, so another thread can abort it
@@ -465,12 +498,33 @@ class Agent:
465
498
  image = vision.read_image(path)
466
499
  # The session's own model keeps its settings, so Ollama doesn't reload it.
467
500
  options = self.options() if model == self.settings.model else None
468
- loading = "" if model == self.settings.model else " (loads it; the next request reloads the main model)"
469
- with self.console.status(f"Looking at {path.name} with {model}{loading}…", spinner="dots"):
501
+ with self.vision_swap(model, path.name):
470
502
  return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
471
503
  except vision.VisionError as e:
472
504
  raise ToolError(str(e)) from e
473
505
 
506
+ @contextlib.contextmanager
507
+ def vision_swap(self, model: str, what: str):
508
+ """Look at an image with `model`, saying when it has to load and when the main model made room for it."""
509
+ main = self.settings.model
510
+ own = model == main
511
+ main_was_loaded = not own and residency.placement(self.ollama, main) is not None
512
+ if own or residency.placement(self.ollama, model) is not None:
513
+ text = f"Looking at {what} with {model}…"
514
+ else:
515
+ text = f"Loading the vision model {model} to look at {what}" + (
516
+ f"; {main} makes room and reloads afterwards…" if main_was_loaded else "…"
517
+ )
518
+ with self.console.status(text, spinner="dots"):
519
+ yield
520
+ if main_was_loaded and residency.placement(self.ollama, main) is None:
521
+ self.made_room_for = f"the vision model {model}"
522
+ self.say(f"{main} was unloaded to make room for {model}; it reloads for the next step.")
523
+
524
+ def say(self, text: str, warning: bool = False) -> None:
525
+ """One line about what happens under the hood (loading, memory), not about the work itself."""
526
+ self.console.print(Text(f"{'⚠' if warning else '◆'} {text}", style="yellow" if warning else "dim cyan"))
527
+
474
528
  def describe_image_data(self, data: str, mime: str) -> str:
475
529
  """For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
476
530
  model = self.vision_model()
@@ -478,7 +532,7 @@ class Agent:
478
532
  return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
479
533
  options = self.options() if model == self.settings.model else None
480
534
  try:
481
- with self.console.status(f"Looking at the {mime} image with {model}…", spinner="dots"):
535
+ with self.vision_swap(model, f"the {mime} image"):
482
536
  text = vision.describe(
483
537
  self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
484
538
  )
@@ -624,9 +678,11 @@ class Agent:
624
678
  if not started and batch and batch > SAFE_NUM_BATCH:
625
679
  # Larger batches read prompts faster but need extra VRAM that isn't always free.
626
680
  self.settings.num_batch = SAFE_NUM_BATCH
627
- self.console.print(
628
- f"[yellow]The GPU ran out of memory with a prompt batch of {batch}; retrying with "
629
- f"{SAFE_NUM_BATCH}.[/] [dim]To skip this retry: lcode config set num_batch {SAFE_NUM_BATCH}[/]"
681
+ self.say(
682
+ f"The GPU ran out of memory reading the prompt in batches of {batch} tokens. Retrying with "
683
+ f"batches of {SAFE_NUM_BATCH}: a little slower to read, but it needs less memory. To start "
684
+ f"there next time: lcode config set num_batch {SAFE_NUM_BATCH}",
685
+ warning=True,
630
686
  )
631
687
  yield from self.chat(messages, tools, think)
632
688
  return
@@ -636,16 +692,19 @@ class Agent:
636
692
  smaller = max(catalog.MIN_USEFUL_CONTEXT, context // 2)
637
693
  self.settings.context = smaller
638
694
  limits.record(self.settings.model, smaller)
639
- self.console.print(
640
- f"[yellow]{self.settings.model} didn't fit in GPU memory with a {format_tokens(context)} "
641
- f"context; retrying with {format_tokens(smaller)}.[/] [dim]lcode will start there next "
642
- "time on this machine.[/]"
695
+ self.say(
696
+ f"{self.settings.model} didn't fit in memory with a {format_tokens(context)} context: the "
697
+ "context needs its own memory next to the model. Reloading it with "
698
+ f"{format_tokens(smaller)}; lcode will start there next time on this machine.",
699
+ warning=True,
643
700
  )
644
701
  yield from self.chat(messages, tools, think)
645
702
  return
646
703
  raise OllamaError(
647
- f"{e}\nThe model ran out of GPU memory. Try a smaller context (/ctx 128k), close other programs "
648
- "using the GPU, or pick a smaller model (/models)."
704
+ f"{e}\n{self.settings.model} ran out of memory. Close other programs that use the GPU (another "
705
+ "model, a game, an image generator), use a smaller context (/ctx "
706
+ f"{format_tokens(max(catalog.MIN_USEFUL_CONTEXT // 2, context // 2)).lower()}), or pick a smaller "
707
+ "model (/models)."
649
708
  ) from e
650
709
  raise
651
710
 
@@ -659,6 +718,7 @@ class Agent:
659
718
  content, thinking, tool_calls, final = "", "", [], {}
660
719
  md = MarkdownStreamer(self.console)
661
720
  progress = Progress()
721
+ watch = self.before_request(progress)
662
722
  status = None
663
723
  printed_thinking = False
664
724
 
@@ -680,6 +740,9 @@ class Agent:
680
740
  if self.cancel is not None and self.cancel.is_set():
681
741
  raise KeyboardInterrupt
682
742
  msg = chunk.get("message", {})
743
+ if watch is not None:
744
+ self.after_load(watch)
745
+ watch = None
683
746
  if msg.get("thinking"):
684
747
  thinking += msg["thinking"]
685
748
  if self.on_delta:
@@ -714,6 +777,8 @@ class Agent:
714
777
  raise
715
778
  finally:
716
779
  stop_spinner()
780
+ if watch is not None:
781
+ watch.stop()
717
782
  if printed_thinking:
718
783
  self.console.print()
719
784
  md.flush()
@@ -732,10 +797,49 @@ class Agent:
732
797
  self.usage["output_tokens"] += final.get("eval_count", 0)
733
798
  self.usage["output_ns"] += final.get("eval_duration", 0)
734
799
  self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
800
+ self.sent_chars = conversation_chars(self.messages)
735
801
  seconds = final.get("eval_duration", 0) / 1e9
736
802
  self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
737
803
  return message
738
804
 
805
+ def before_request(self, progress: Progress) -> residency.LoadWatch | None:
806
+ """Set the status line for what comes before the first token: loading the model if it isn't in memory
807
+ (watched until it is), then reading the prompt. Returns the watch while the model loads."""
808
+ if getattr(self.ollama, "kind", "ollama") != "ollama":
809
+ return None
810
+ model, context = self.settings.model, self.settings.context
811
+ fresh = max(0, conversation_chars(self.messages) - self.sent_chars) // 3
812
+ where = residency.placement(self.ollama, model)
813
+ if where is not None and (not where.context or where.context == context):
814
+ if fresh >= READING_NOTE_TOKENS:
815
+ progress.set_phase(f"Reading {format_tokens(fresh)} new tokens")
816
+ return None
817
+ total = conversation_chars(self.messages) // 3
818
+ if where is not None:
819
+ phase = f"Reloading {model} with a {format_tokens(context)} context"
820
+ elif self.made_room_for:
821
+ phase = f"Reloading {model} into memory (it made room for {self.made_room_for})"
822
+ else:
823
+ phase = f"Loading {model} into memory"
824
+ progress.set_phase(phase)
825
+ watch = residency.LoadWatch(
826
+ self.ollama, model, context, progress, f"Reading the conversation ({format_tokens(total)} tokens)"
827
+ )
828
+ watch.reload = where is not None or bool(self.made_room_for)
829
+ return watch
830
+
831
+ def after_load(self, watch: residency.LoadWatch) -> None:
832
+ """The first token after a load: one line on how long it took and how the model fits."""
833
+ seconds = watch.stop()
834
+ self.made_room_for = ""
835
+ self.load_state = ""
836
+ where = residency.placement(self.ollama, self.settings.model)
837
+ dense = residency.is_moe(self.ollama, self.settings.model) is False
838
+ report = residency.load_report(
839
+ self.settings.model, seconds, where, self.settings.context, self.hardware, dense, watch.reload
840
+ )
841
+ self.say(report, warning=report.endswith(("closed.", "slow.")))
842
+
739
843
  def describe_call(self, name: str, args: dict) -> str:
740
844
  if name in ("read_file", "write_file", "edit_file"):
741
845
  path = self.tools.rel(self.tools.resolve(args.get("path", "")))
@@ -1071,6 +1175,7 @@ class Agent:
1071
1175
  ) # not enough: before the last request
1072
1176
  context_tools.note_removed(self.messages, removed)
1073
1177
  if saved:
1178
+ self.sent_chars = 0 # the conversation changed: the model reads it again
1074
1179
  self.pruned += 1
1075
1180
  self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
1076
1181
  self.console.print(
@@ -1127,6 +1232,7 @@ class Agent:
1127
1232
  self.skills_loaded.clear()
1128
1233
  self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
1129
1234
  self.summarized_size = self.ctx_used
1235
+ self.sent_chars = 0
1130
1236
  self.reflected = len(self.messages)
1131
1237
  self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
1132
1238
 
@@ -199,6 +199,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
199
199
  compact_at=cfg["compact_at"],
200
200
  )
201
201
  agent = Agent(ollama, settings, cwd, console=console)
202
+ agent.hardware = hw
202
203
  agent.interactive = options.interactive
203
204
  if options.max_steps:
204
205
  agent.max_steps = options.max_steps
@@ -27,7 +27,7 @@ from rich.rule import Rule
27
27
  from rich.table import Table
28
28
  from rich.text import Text
29
29
 
30
- from lcode import __version__, backends, catalog, clipboard, extensions, gitflow, limits, sessions, web
30
+ from lcode import __version__, backends, catalog, clipboard, extensions, gitflow, limits, residency, sessions, web
31
31
  from lcode import context as context_tools
32
32
  from lcode import memory as memory_notes
33
33
  from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
@@ -153,6 +153,7 @@ def build_session(agent: Agent) -> PromptSession:
153
153
  f" <b>{html.escape(s.model)}</b> · ctx {format_tokens(agent.ctx_used)}/{format_tokens(s.context)} "
154
154
  f"({pct:.0f}%) · mode <{color}>{agent.perms.mode}</{color}> (shift+tab) · "
155
155
  f"think {'on' if s.think else 'off'} · {html.escape(agent.cwd.name)}/"
156
+ + (" · <ansiyellow>loading the model into memory…</ansiyellow>" if agent.load_state == "loading" else "")
156
157
  + (f" · <b>{html.escape(agent.session_name)}</b>" if agent.session_name else "")
157
158
  )
158
159
 
@@ -935,8 +936,12 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
935
936
  s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
936
937
  elif backends.is_openai(agent.ollama):
937
938
  s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
939
+ agent._vision = False # the model that looks at images goes with the model: decide again
938
940
  agent.messages[0]["content"] = agent.system_prompt()
939
941
  c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
942
+ warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
943
+ if warning:
944
+ agent.say(warning, warning=True)
940
945
  elif cmd == "/think":
941
946
  s.think = (arg == "on") if arg in ("on", "off") else not s.think
942
947
  c.print(f"Reasoning {'on' if s.think else 'off'}.")
@@ -1110,9 +1115,12 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1110
1115
  if prompt:
1111
1116
  run_safely(agent, prompt)
1112
1117
  return
1113
- preload(agent)
1114
1118
  banner(agent)
1119
+ warning = residency.dense_warning(agent.ollama, agent.settings.model, agent.settings.context, hardware)
1120
+ if warning:
1121
+ agent.say(warning, warning=True)
1115
1122
  session = build_session(agent)
1123
+ preload(agent, session)
1116
1124
  while True:
1117
1125
  try:
1118
1126
  agent.console.print()
@@ -1135,13 +1143,25 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1135
1143
  agent.console.print("[dim]Bye.[/]")
1136
1144
 
1137
1145
 
1138
- def preload(agent: Agent) -> None:
1139
- """Start loading the model in the background so the first answer comes sooner."""
1146
+ def preload(agent: Agent, session: PromptSession | None = None) -> None:
1147
+ """Start loading the model in the background so the first answer comes sooner; the status bar says so."""
1148
+ where = residency.placement(agent.ollama, agent.settings.model)
1149
+ if where is not None and (not where.context or where.context == agent.settings.context):
1150
+ return # already in memory
1140
1151
 
1141
1152
  def load() -> None:
1153
+ agent.load_state = "loading"
1154
+ refresh()
1142
1155
  try:
1143
1156
  agent.ollama.load(agent.settings.model, agent.options(), agent.settings.keep_alive)
1144
1157
  except OllamaError:
1145
1158
  pass
1159
+ finally:
1160
+ agent.load_state = ""
1161
+ refresh()
1162
+
1163
+ def refresh() -> None:
1164
+ if session is not None and session.app.is_running:
1165
+ session.app.invalidate()
1146
1166
 
1147
1167
  threading.Thread(target=load, daemon=True).start()
@@ -0,0 +1,167 @@
1
+ """Where the model is, in words: loading it, how it fits, making room for another one.
2
+
3
+ A local model has to be in memory before it answers. On a small GPU loading takes a while, how much of
4
+ the model fits on the GPU decides how fast it runs, and a second model (the one that looks at images)
5
+ can push the first one out. lcode says so, in a line, when it happens: nothing is printed while the
6
+ model simply stays loaded.
7
+
8
+ Only Ollama is asked: other servers load and place their models themselves.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ import time
15
+ from dataclasses import dataclass
16
+
17
+ from lcode import catalog
18
+ from lcode.ollama import OllamaError
19
+
20
+ GB = 1e9
21
+ SPLIT_WARNING = 0.95 # a dense model with less than this on the GPU runs much slower
22
+ SHORTFALL = 0.25 # this much less on the GPU than expected: something else is using GPU memory
23
+
24
+
25
+ @dataclass
26
+ class Placement:
27
+ """A loaded model, from Ollama's /api/ps."""
28
+
29
+ name: str
30
+ size: float # bytes in memory
31
+ vram: float # bytes of it on the GPU
32
+ context: int = 0 # the context window it was loaded with
33
+
34
+ @property
35
+ def gpu_share(self) -> float:
36
+ return self.vram / self.size if self.size else 0.0
37
+
38
+ def describe(self) -> str:
39
+ """'22.3 GB, 44% on the GPU and the rest in system RAM'"""
40
+ size = f"{self.size / GB:.1f} GB"
41
+ share = self.gpu_share
42
+ if share >= 0.995:
43
+ return f"{size}, all on the GPU"
44
+ if share <= 0.005:
45
+ return f"{size}, all in system RAM (on the CPU)"
46
+ return f"{size}, {share:.0%} on the GPU and the rest in system RAM"
47
+
48
+
49
+ def same_model(a: str, b: str) -> bool:
50
+ return a.removesuffix(":latest") == b.removesuffix(":latest")
51
+
52
+
53
+ def loaded(ollama, model: str | None = None) -> list[Placement]:
54
+ """The models Ollama has in memory (only `model`, if given). Empty when it can't be asked."""
55
+ if getattr(ollama, "kind", "ollama") != "ollama":
56
+ return []
57
+ try:
58
+ entries = ollama.running()
59
+ except (OllamaError, OSError):
60
+ return []
61
+ found = []
62
+ for entry in entries or []:
63
+ name = entry.get("name") or entry.get("model") or ""
64
+ if model is None or same_model(name, model):
65
+ size = float(entry.get("size") or 0)
66
+ vram = float(entry.get("size_vram") if entry.get("size_vram") is not None else size)
67
+ found.append(Placement(name.removesuffix(":latest"), size, vram, int(entry.get("context_length") or 0)))
68
+ return found
69
+
70
+
71
+ def placement(ollama, model: str) -> Placement | None:
72
+ found = loaded(ollama, model)
73
+ return found[0] if found else None
74
+
75
+
76
+ def is_moe(ollama, model: str) -> bool | None:
77
+ """Whether the model is a Mixture-of-Experts (None: unknown)."""
78
+ spec = catalog.find(model)
79
+ if spec:
80
+ return spec.moe
81
+ try:
82
+ info = (ollama.show(model) or {}).get("model_info") or {}
83
+ except (OllamaError, OSError):
84
+ return None
85
+ if not info:
86
+ return None
87
+ return any(key.endswith(".expert_count") and value for key, value in info.items())
88
+
89
+
90
+ def expected_share(model: str, context: int, hardware) -> float | None:
91
+ """How much of the model should fit on the GPU, from the catalog's estimate."""
92
+ spec = catalog.find(model)
93
+ if not spec or hardware is None or not hardware.gpu or hardware.unified:
94
+ return None
95
+ return min(1.0, hardware.vram_gib / spec.memory_gib(context))
96
+
97
+
98
+ def dense_warning(ollama, model: str, context: int, hardware) -> str:
99
+ """For a dense model that won't fit on the GPU: what that means and what to do. Empty otherwise."""
100
+ if hardware is None or hardware.unified or is_moe(ollama, model) is not False:
101
+ return ""
102
+ spec = catalog.find(model)
103
+ params = f" {spec.params.split(' ')[0]}" if spec and spec.params else ""
104
+ if not hardware.gpu:
105
+ where = "There's no GPU here, so it runs on the CPU"
106
+ else:
107
+ share = expected_share(model, context, hardware)
108
+ if share is None or share >= SPLIT_WARNING:
109
+ return ""
110
+ where = (
111
+ f"Only about {share:.0%} of it fits in the {hardware.vram_gib:.0f} GB of GPU memory and the rest runs "
112
+ "on the CPU"
113
+ )
114
+ return (
115
+ f"{model} is a dense model: all{params} parameters work on every token. It's capable, but slow here. "
116
+ f"{where}, so expect a few tokens a second. For speed, pick a Mixture-of-Experts model such as "
117
+ "qwen3.6-35b (/model qwen3.6-35b), or a smaller context (/ctx 16k) to keep more of it on the GPU."
118
+ )
119
+
120
+
121
+ def load_report(
122
+ model: str, seconds: float, where: Placement | None, context: int, hardware, dense: bool, reload: bool = False
123
+ ) -> str:
124
+ """The line after a load: how long it took, how it fits, and a warning if it fits worse than it should."""
125
+ took = f" in {seconds:.0f}s" if seconds >= 1 else ""
126
+ line = f"{'Reloaded' if reload else 'Loaded'} {model}{took}" + (f": {where.describe()}." if where else ".")
127
+ if where is None:
128
+ return line
129
+ expected = expected_share(model, context, hardware)
130
+ if expected is not None and where.gpu_share < expected - SHORTFALL:
131
+ line += (
132
+ f" Less of it is on the GPU than usual ({expected:.0%}): another program or model is using GPU "
133
+ "memory, so it runs slower until that's closed."
134
+ )
135
+ elif dense and where.gpu_share < SPLIT_WARNING:
136
+ line += " It's a dense model split between GPU and CPU, so expect it to be slow."
137
+ return line
138
+
139
+
140
+ class LoadWatch:
141
+ """While a model loads: asks Ollama once a second, and once the model is in memory moves the status line
142
+ on to reading the prompt, which comes next and can take a while too."""
143
+
144
+ POLL = 1.0
145
+
146
+ def __init__(self, ollama, model: str, context: int, progress, reading: str) -> None:
147
+ self.ollama, self.model, self.context = ollama, model, context
148
+ self.progress, self.reading = progress, reading
149
+ self.started = time.monotonic()
150
+ self.loaded_after: float | None = None
151
+ self.reload = False # it was in memory before: unloaded for another model, or loaded with another context
152
+ self.stopped = threading.Event()
153
+ threading.Thread(target=self.run, daemon=True).start()
154
+
155
+ def run(self) -> None:
156
+ while not self.stopped.wait(self.POLL):
157
+ where = placement(self.ollama, self.model)
158
+ if where and (not where.context or not self.context or where.context == self.context):
159
+ self.loaded_after = time.monotonic() - self.started
160
+ if not self.stopped.is_set():
161
+ self.progress.set_phase(self.reading)
162
+ return
163
+
164
+ def stop(self) -> float:
165
+ """Seconds the load took (until the first token, when the end of the load went unseen)."""
166
+ self.stopped.set()
167
+ return self.loaded_after if self.loaded_after is not None else time.monotonic() - self.started
@@ -614,9 +614,8 @@ class Toolbox:
614
614
  arguments = {**arguments, "wait": True}
615
615
  freed = self.agent.free_gpu()
616
616
  if freed:
617
- self.console.print(
618
- Text(f" ⎿ freed the GPU for {state.name} ({', '.join(freed)} reloads afterwards)", style="dim")
619
- )
617
+ self.agent.made_room_for = state.name
618
+ self.agent.say(f"Unloaded {', '.join(freed)} so {state.name} can use the GPU; it reloads afterwards.")
620
619
  result = mcp.call(state, tool, arguments, image_text=self.agent.describe_image_data)
621
620
  if result.startswith("Error:"):
622
621
  return result
@@ -70,6 +70,12 @@ def call(name: str, **arguments) -> dict:
70
70
  return {"function": {"name": name, "arguments": arguments}}
71
71
 
72
72
 
73
+ @pytest.fixture(autouse=True)
74
+ def isolated_state(tmp_path_factory, monkeypatch):
75
+ """Every test gets its own state folder, so none writes into the user's (approvals, sessions…)."""
76
+ monkeypatch.setattr("lcode.config.STATE_DIR", tmp_path_factory.mktemp("lcode-state"))
77
+
78
+
73
79
  @pytest.fixture
74
80
  def repo(tmp_path: Path) -> Path:
75
81
  (tmp_path / "src" / "pkg").mkdir(parents=True)