llmcode-cli 1.0.0__py3-none-any.whl

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 (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,252 @@
1
+ """Memory lint — validate project memory for stale refs, gaps, orphans, and age."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone, timedelta
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from llm_code.api.types import Message, MessageRequest, TextBlock
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _FILE_PATH_RE = re.compile(r"(?:^|\s)([\w./]+\.(?:py|ts|js|go|rs|md|toml|json|yaml|yml))\b")
17
+ _MAX_AGE_DAYS = 30
18
+
19
+ _SKIP_DIRS = frozenset({
20
+ ".git", "__pycache__", "node_modules", ".venv", "venv",
21
+ "dist", "build", ".egg-info", ".tox", ".mypy_cache", ".llm-code",
22
+ })
23
+
24
+ _CONTRADICTION_SYSTEM_PROMPT = """\
25
+ You are a memory consistency checker. Given a list of project memory entries, \
26
+ identify any pairs that contradict each other.
27
+
28
+ Return a JSON array of objects with keys: "key_a", "key_b", "description".
29
+ If no contradictions found, return an empty array: []
30
+
31
+ Only flag clear factual contradictions, not differences in detail level.
32
+ """
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class StaleReference:
37
+ key: str
38
+ reference: str
39
+ line: int = 0
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Contradiction:
44
+ key_a: str
45
+ key_b: str
46
+ description: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class MemoryLintResult:
51
+ stale: tuple[StaleReference, ...]
52
+ contradictions: tuple[Contradiction, ...]
53
+ coverage_gaps: tuple[str, ...]
54
+ orphans: tuple[str, ...]
55
+ old: tuple[str, ...]
56
+
57
+ def format_summary(self) -> str:
58
+ parts = []
59
+ if self.stale:
60
+ parts.append(f"{len(self.stale)} stale")
61
+ if self.coverage_gaps:
62
+ parts.append(f"{len(self.coverage_gaps)} coverage gaps")
63
+ if self.orphans:
64
+ parts.append(f"{len(self.orphans)} orphans")
65
+ if self.old:
66
+ parts.append(f"{len(self.old)} old")
67
+ if self.contradictions:
68
+ parts.append(f"{len(self.contradictions)} contradictions")
69
+ return f"Summary: {', '.join(parts)}" if parts else "Summary: no issues found"
70
+
71
+ def format_report(self) -> str:
72
+ lines = ["## Memory Health Check\n"]
73
+ for s in self.stale:
74
+ lines.append(f" STALE {s.key}:{s.line} References \"{s.reference}\" — not found")
75
+ for gap in self.coverage_gaps:
76
+ lines.append(f" GAP {gap} No memory coverage")
77
+ for orphan in self.orphans:
78
+ lines.append(f" ORPHAN {orphan} References nothing in codebase")
79
+ for old_key in self.old:
80
+ lines.append(f" OLD {old_key} Last updated >30 days ago")
81
+ for c in self.contradictions:
82
+ lines.append(f" CONTRA {c.key_a} vs {c.key_b}: {c.description}")
83
+ lines.append(f"\n{self.format_summary()}")
84
+ return "\n".join(lines)
85
+
86
+
87
+ def lint_memory(
88
+ memory_dir: Path,
89
+ cwd: Path,
90
+ llm_provider: Any | None = None,
91
+ ) -> MemoryLintResult:
92
+ """Run fast computational memory health checks."""
93
+ entries = _load_entries(memory_dir)
94
+ stale = _check_stale(entries, cwd)
95
+ coverage_gaps = _check_coverage_gaps(entries, cwd)
96
+ old = _check_old(entries)
97
+
98
+ return MemoryLintResult(
99
+ stale=tuple(stale),
100
+ contradictions=(),
101
+ coverage_gaps=tuple(coverage_gaps),
102
+ orphans=(),
103
+ old=tuple(old),
104
+ )
105
+
106
+
107
+ async def lint_memory_deep(
108
+ memory_dir: Path,
109
+ cwd: Path,
110
+ llm_provider: Any | None = None,
111
+ ) -> MemoryLintResult:
112
+ """Run all checks including LLM contradiction detection."""
113
+ base = lint_memory(memory_dir=memory_dir, cwd=cwd)
114
+
115
+ if llm_provider is None:
116
+ return base
117
+
118
+ entries = _load_entries(memory_dir)
119
+ contradictions = await _check_contradictions(entries, llm_provider)
120
+
121
+ return MemoryLintResult(
122
+ stale=base.stale,
123
+ contradictions=tuple(contradictions),
124
+ coverage_gaps=base.coverage_gaps,
125
+ orphans=base.orphans,
126
+ old=base.old,
127
+ )
128
+
129
+
130
+ def _load_entries(memory_dir: Path) -> dict[str, dict]:
131
+ """Load memory.json entries, excluding internal keys."""
132
+ memory_file = memory_dir / "memory.json"
133
+ if not memory_file.exists():
134
+ return {}
135
+ try:
136
+ data = json.loads(memory_file.read_text(encoding="utf-8"))
137
+ return {k: v for k, v in data.items() if not k.startswith("_")}
138
+ except (json.JSONDecodeError, OSError):
139
+ return {}
140
+
141
+
142
+ def _check_stale(entries: dict[str, dict], cwd: Path) -> list[StaleReference]:
143
+ """Find memory entries that reference files that no longer exist."""
144
+ stale: list[StaleReference] = []
145
+ for key, entry in entries.items():
146
+ value = entry.get("value", "")
147
+ for line_no, line in enumerate(value.splitlines(), 1):
148
+ for match in _FILE_PATH_RE.finditer(line):
149
+ ref = match.group(1)
150
+ if "/" in ref and not (cwd / ref).exists():
151
+ stale.append(StaleReference(key=key, reference=ref, line=line_no))
152
+ return stale
153
+
154
+
155
+ def _is_python_package(path: Path) -> bool:
156
+ """Return True if the directory looks like a Python package or source dir."""
157
+ return (path / "__init__.py").exists() or any(path.glob("*.py"))
158
+
159
+
160
+ def _check_coverage_gaps(entries: dict[str, dict], cwd: Path) -> list[str]:
161
+ """Find source directories with no mention in any memory entry."""
162
+ source_dirs: set[str] = set()
163
+ for child in sorted(cwd.iterdir()):
164
+ if not child.is_dir() or child.name in _SKIP_DIRS or child.name.startswith("."):
165
+ continue
166
+ # Qualify top-level dir: has own Python files OR contains Python subdirs
167
+ has_python = _is_python_package(child)
168
+ has_python_subs = any(
169
+ sub.is_dir() and _is_python_package(sub)
170
+ for sub in child.iterdir()
171
+ if sub.name not in _SKIP_DIRS and not sub.name.startswith("_")
172
+ )
173
+ if has_python or has_python_subs:
174
+ for sub in child.iterdir():
175
+ if sub.is_dir() and sub.name not in _SKIP_DIRS and not sub.name.startswith("_"):
176
+ if _is_python_package(sub):
177
+ source_dirs.add(f"{child.name}/{sub.name}")
178
+
179
+ if not source_dirs:
180
+ return []
181
+
182
+ all_values = " ".join(entry.get("value", "") for entry in entries.values())
183
+ gaps: list[str] = []
184
+ for dir_path in sorted(source_dirs):
185
+ dir_name = dir_path.split("/")[-1]
186
+ if dir_name not in all_values and dir_path not in all_values:
187
+ gaps.append(f"{dir_path}/")
188
+ return gaps
189
+
190
+
191
+ def _check_old(entries: dict[str, dict], max_age_days: int = _MAX_AGE_DAYS) -> list[str]:
192
+ """Find entries not updated within max_age_days."""
193
+ old: list[str] = []
194
+ cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
195
+ for key, entry in entries.items():
196
+ updated = entry.get("updated_at", "")
197
+ if not updated:
198
+ continue
199
+ try:
200
+ dt = datetime.fromisoformat(updated)
201
+ if dt < cutoff:
202
+ old.append(key)
203
+ except (ValueError, TypeError):
204
+ pass
205
+ return old
206
+
207
+
208
+ async def _check_contradictions(
209
+ entries: dict[str, dict],
210
+ llm_provider: Any,
211
+ ) -> list[Contradiction]:
212
+ """Use LLM to detect contradictory memory entries."""
213
+ if len(entries) < 2:
214
+ return []
215
+
216
+ entries_text = "\n".join(
217
+ f"- [{key}]: {entry.get('value', '')[:200]}"
218
+ for key, entry in entries.items()
219
+ )
220
+
221
+ request = MessageRequest(
222
+ model="",
223
+ messages=(Message(role="user", content=(TextBlock(text=f"Memory entries:\n{entries_text}"),)),),
224
+ system=_CONTRADICTION_SYSTEM_PROMPT,
225
+ tools=(),
226
+ max_tokens=512,
227
+ temperature=0.2,
228
+ )
229
+
230
+ try:
231
+ response = await llm_provider.send_message(request)
232
+ text = ""
233
+ for block in response.content:
234
+ if hasattr(block, "text"):
235
+ text += block.text
236
+
237
+ parsed = json.loads(text)
238
+ if not isinstance(parsed, list):
239
+ return []
240
+
241
+ return [
242
+ Contradiction(
243
+ key_a=item.get("key_a", ""),
244
+ key_b=item.get("key_b", ""),
245
+ description=item.get("description", ""),
246
+ )
247
+ for item in parsed
248
+ if isinstance(item, dict)
249
+ ]
250
+ except Exception:
251
+ logger.debug("Contradiction check failed", exc_info=True)
252
+ return []
@@ -0,0 +1,37 @@
1
+ """Model alias resolution."""
2
+ from __future__ import annotations
3
+
4
+ # Built-in aliases
5
+ BUILTIN_ALIASES: dict[str, str] = {
6
+ # Short names
7
+ "gpt4o": "gpt-4o",
8
+ "gpt4": "gpt-4o",
9
+ "gpt-mini": "gpt-4o-mini",
10
+ "4o": "gpt-4o",
11
+ "4o-mini": "gpt-4o-mini",
12
+ "o3": "o3",
13
+ "o4-mini": "o4-mini",
14
+ # Anthropic
15
+ "opus": "claude-opus-4-6",
16
+ "sonnet": "claude-sonnet-4-6",
17
+ "haiku": "claude-haiku-4-5",
18
+ "claude": "claude-sonnet-4-6",
19
+ # Qwen shortcuts
20
+ "qwen": "qwen3.5",
21
+ "qwen-large": "Qwen3.5-122B-A10B",
22
+ # Ollama convenience aliases
23
+ "qwen-small": "qwen3:1.7b",
24
+ "qwen-medium": "qwen3.5:4b",
25
+ }
26
+
27
+
28
+ def resolve_model(model: str, custom_aliases: dict[str, str] | None = None) -> str:
29
+ """Resolve a model alias to its full name.
30
+
31
+ Priority: custom_aliases (from config) > BUILTIN_ALIASES > return as-is
32
+ """
33
+ if custom_aliases and model in custom_aliases:
34
+ return custom_aliases[model]
35
+ if model in BUILTIN_ALIASES:
36
+ return BUILTIN_ALIASES[model]
37
+ return model
@@ -0,0 +1,93 @@
1
+ """Ollama API client for probe, model listing, and selection helpers."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ import httpx
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class OllamaModel:
11
+ """Represents a locally available Ollama model."""
12
+
13
+ name: str
14
+ size_gb: float
15
+ parameter_size: str
16
+ quantization: str
17
+
18
+ @property
19
+ def estimated_vram_gb(self) -> float:
20
+ """Estimated runtime VRAM: disk size * 1.2 for KV cache + buffers."""
21
+ return self.size_gb * 1.2
22
+
23
+ def fits_in_vram(self, available_gb: float) -> bool:
24
+ return self.estimated_vram_gb <= available_gb
25
+
26
+ def is_recommended(self, available_gb: float) -> bool:
27
+ """Fits within 90% of available VRAM."""
28
+ return self.estimated_vram_gb <= available_gb * 0.9
29
+
30
+
31
+ class OllamaClient:
32
+ """Thin async client for Ollama's native API."""
33
+
34
+ def __init__(self, base_url: str = "http://localhost:11434") -> None:
35
+ self._base_url = base_url.rstrip("/")
36
+ self._client = httpx.AsyncClient(timeout=httpx.Timeout(2.0))
37
+
38
+ async def probe(self) -> bool:
39
+ """Check if Ollama is reachable."""
40
+ try:
41
+ resp = await self._client.get(f"{self._base_url}/api/tags")
42
+ return resp.status_code == 200
43
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, OSError):
44
+ return False
45
+
46
+ async def list_models(self) -> list[OllamaModel]:
47
+ """Fetch locally available models from Ollama."""
48
+ try:
49
+ resp = await self._client.get(f"{self._base_url}/api/tags")
50
+ if resp.status_code != 200:
51
+ return []
52
+ data = resp.json()
53
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, OSError, ValueError):
54
+ return []
55
+
56
+ models: list[OllamaModel] = []
57
+ for entry in data.get("models", []):
58
+ details = entry.get("details", {})
59
+ size_bytes = entry.get("size", 0)
60
+ models.append(
61
+ OllamaModel(
62
+ name=entry.get("name", ""),
63
+ size_gb=size_bytes / (1024**3),
64
+ parameter_size=details.get("parameter_size", ""),
65
+ quantization=details.get("quantization_level", ""),
66
+ )
67
+ )
68
+ return models
69
+
70
+ async def close(self) -> None:
71
+ await self._client.aclose()
72
+
73
+
74
+ def sort_models_for_selection(
75
+ models: list[OllamaModel],
76
+ vram_gb: float | None,
77
+ ) -> list[OllamaModel]:
78
+ """Sort models for the interactive selector.
79
+
80
+ With VRAM info: models that fit sorted descending (biggest first),
81
+ then models that don't fit sorted ascending (smallest overshoot first).
82
+ Without VRAM info: sorted ascending by size.
83
+ """
84
+ if vram_gb is None:
85
+ return sorted(models, key=lambda m: m.size_gb)
86
+
87
+ fits = [m for m in models if m.fits_in_vram(vram_gb)]
88
+ exceeds = [m for m in models if not m.fits_in_vram(vram_gb)]
89
+
90
+ fits.sort(key=lambda m: m.size_gb, reverse=True)
91
+ exceeds.sort(key=lambda m: m.size_gb)
92
+
93
+ return fits + exceeds
@@ -0,0 +1,124 @@
1
+ """Copy-on-Write overlay filesystem for speculative tool execution."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+
9
+ class OverlayFS:
10
+ """A lightweight Copy-on-Write overlay that mirrors writes to a tmpdir.
11
+
12
+ Reads check the overlay first, falling back to the real filesystem.
13
+ ``commit()`` copies all pending overlay files to their real paths.
14
+ ``discard()`` deletes the tmpdir without touching the real filesystem.
15
+ """
16
+
17
+ def __init__(self, base_dir: Path, session_id: str) -> None:
18
+ self._base_dir = base_dir
19
+ self._session_id = session_id
20
+ self._tmp_root = Path(tempfile.gettempdir()) / "llm-code-overlay"
21
+ self._tmp_root.mkdir(parents=True, exist_ok=True)
22
+ self.overlay_dir: Path = self._tmp_root / session_id
23
+ self.overlay_dir.mkdir(parents=True, exist_ok=True)
24
+ # Tracks real-path → overlay-mirror-path mappings
25
+ self._pending: dict[Path, Path] = {}
26
+
27
+ # ------------------------------------------------------------------
28
+ # Core API
29
+ # ------------------------------------------------------------------
30
+
31
+ def write(self, path: Path, content: str) -> None:
32
+ """Write *content* to the overlay mirror of *path*.
33
+
34
+ The real filesystem is not touched.
35
+
36
+ Parameters
37
+ ----------
38
+ path:
39
+ Absolute path on the real filesystem (write destination after commit).
40
+ content:
41
+ UTF-8 text content to stage in the overlay.
42
+
43
+ Raises
44
+ ------
45
+ ValueError
46
+ If *path* is not absolute.
47
+ """
48
+ if not path.is_absolute():
49
+ raise ValueError(f"path must be absolute, got: {path!r}")
50
+ mirror = self._mirror_path(path)
51
+ mirror.parent.mkdir(parents=True, exist_ok=True)
52
+ mirror.write_text(content, encoding="utf-8")
53
+ self._pending[path.resolve()] = mirror
54
+
55
+ def read(self, path: Path) -> str:
56
+ """Read content, checking the overlay first then the real filesystem.
57
+
58
+ Parameters
59
+ ----------
60
+ path:
61
+ Absolute path to read.
62
+
63
+ Returns
64
+ -------
65
+ str
66
+ UTF-8 text content.
67
+
68
+ Raises
69
+ ------
70
+ FileNotFoundError
71
+ If *path* is absent from both overlay and real filesystem.
72
+ """
73
+ resolved = path.resolve()
74
+ if resolved in self._pending:
75
+ return self._pending[resolved].read_text(encoding="utf-8")
76
+ if path.exists():
77
+ return path.read_text(encoding="utf-8")
78
+ raise FileNotFoundError(f"File not found in overlay or real FS: {path}")
79
+
80
+ def commit(self) -> None:
81
+ """Copy all pending overlay files to their real-filesystem paths.
82
+
83
+ Parent directories are created as needed. The overlay tmpdir is
84
+ *not* removed by this call; call ``discard()`` afterwards if desired.
85
+ """
86
+ for real_path, mirror_path in self._pending.items():
87
+ real_path.parent.mkdir(parents=True, exist_ok=True)
88
+ shutil.copy2(mirror_path, real_path)
89
+
90
+ def discard(self) -> None:
91
+ """Delete the overlay tmpdir without writing to the real filesystem."""
92
+ if self.overlay_dir.exists():
93
+ shutil.rmtree(self.overlay_dir, ignore_errors=True)
94
+ self._pending.clear()
95
+
96
+ def list_pending(self) -> list[Path]:
97
+ """Return the list of real paths that have been staged in the overlay."""
98
+ return list(self._pending.keys())
99
+
100
+ # ------------------------------------------------------------------
101
+ # Context manager support
102
+ # ------------------------------------------------------------------
103
+
104
+ def __enter__(self) -> "OverlayFS":
105
+ return self
106
+
107
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
108
+ if exc_type is not None:
109
+ self.discard()
110
+ return False # never suppress exceptions
111
+
112
+ # ------------------------------------------------------------------
113
+ # Internal helpers
114
+ # ------------------------------------------------------------------
115
+
116
+ def _mirror_path(self, real_path: Path) -> Path:
117
+ """Translate a real absolute path to its overlay mirror path."""
118
+ # Strip the leading separator so we can join with overlay_dir
119
+ try:
120
+ rel = real_path.resolve().relative_to("/")
121
+ except ValueError:
122
+ # On Windows paths like C:\...; use str-based stripping
123
+ rel = Path(str(real_path.resolve()).lstrip("/\\"))
124
+ return self.overlay_dir / rel