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.
- llm_code/__init__.py +2 -0
- llm_code/analysis/__init__.py +6 -0
- llm_code/analysis/cache.py +33 -0
- llm_code/analysis/engine.py +256 -0
- llm_code/analysis/go_rules.py +114 -0
- llm_code/analysis/js_rules.py +84 -0
- llm_code/analysis/python_rules.py +311 -0
- llm_code/analysis/rules.py +140 -0
- llm_code/analysis/rust_rules.py +108 -0
- llm_code/analysis/universal_rules.py +111 -0
- llm_code/api/__init__.py +0 -0
- llm_code/api/client.py +90 -0
- llm_code/api/errors.py +73 -0
- llm_code/api/openai_compat.py +390 -0
- llm_code/api/provider.py +35 -0
- llm_code/api/sse.py +52 -0
- llm_code/api/types.py +140 -0
- llm_code/cli/__init__.py +0 -0
- llm_code/cli/commands.py +70 -0
- llm_code/cli/image.py +122 -0
- llm_code/cli/render.py +214 -0
- llm_code/cli/status_line.py +79 -0
- llm_code/cli/streaming.py +92 -0
- llm_code/cli/tui_main.py +220 -0
- llm_code/computer_use/__init__.py +11 -0
- llm_code/computer_use/app_detect.py +49 -0
- llm_code/computer_use/app_tier.py +57 -0
- llm_code/computer_use/coordinator.py +99 -0
- llm_code/computer_use/input_control.py +71 -0
- llm_code/computer_use/screenshot.py +93 -0
- llm_code/cron/__init__.py +13 -0
- llm_code/cron/parser.py +145 -0
- llm_code/cron/scheduler.py +135 -0
- llm_code/cron/storage.py +126 -0
- llm_code/enterprise/__init__.py +1 -0
- llm_code/enterprise/audit.py +59 -0
- llm_code/enterprise/auth.py +26 -0
- llm_code/enterprise/oidc.py +95 -0
- llm_code/enterprise/rbac.py +65 -0
- llm_code/harness/__init__.py +5 -0
- llm_code/harness/config.py +33 -0
- llm_code/harness/engine.py +129 -0
- llm_code/harness/guides.py +41 -0
- llm_code/harness/sensors.py +68 -0
- llm_code/harness/templates.py +84 -0
- llm_code/hida/__init__.py +1 -0
- llm_code/hida/classifier.py +187 -0
- llm_code/hida/engine.py +49 -0
- llm_code/hida/profiles.py +95 -0
- llm_code/hida/types.py +28 -0
- llm_code/ide/__init__.py +1 -0
- llm_code/ide/bridge.py +80 -0
- llm_code/ide/detector.py +76 -0
- llm_code/ide/server.py +169 -0
- llm_code/logging.py +29 -0
- llm_code/lsp/__init__.py +0 -0
- llm_code/lsp/client.py +298 -0
- llm_code/lsp/detector.py +42 -0
- llm_code/lsp/manager.py +56 -0
- llm_code/lsp/tools.py +288 -0
- llm_code/marketplace/__init__.py +0 -0
- llm_code/marketplace/builtin_registry.py +102 -0
- llm_code/marketplace/installer.py +162 -0
- llm_code/marketplace/plugin.py +78 -0
- llm_code/marketplace/registry.py +360 -0
- llm_code/mcp/__init__.py +0 -0
- llm_code/mcp/bridge.py +87 -0
- llm_code/mcp/client.py +117 -0
- llm_code/mcp/health.py +120 -0
- llm_code/mcp/manager.py +214 -0
- llm_code/mcp/oauth.py +219 -0
- llm_code/mcp/transport.py +254 -0
- llm_code/mcp/types.py +53 -0
- llm_code/remote/__init__.py +0 -0
- llm_code/remote/client.py +136 -0
- llm_code/remote/protocol.py +22 -0
- llm_code/remote/server.py +275 -0
- llm_code/remote/ssh_proxy.py +56 -0
- llm_code/runtime/__init__.py +0 -0
- llm_code/runtime/auto_commit.py +56 -0
- llm_code/runtime/auto_diagnose.py +62 -0
- llm_code/runtime/checkpoint.py +70 -0
- llm_code/runtime/checkpoint_recovery.py +142 -0
- llm_code/runtime/compaction.py +35 -0
- llm_code/runtime/compressor.py +415 -0
- llm_code/runtime/config.py +533 -0
- llm_code/runtime/context.py +49 -0
- llm_code/runtime/conversation.py +921 -0
- llm_code/runtime/cost_tracker.py +126 -0
- llm_code/runtime/dream.py +127 -0
- llm_code/runtime/file_protection.py +150 -0
- llm_code/runtime/hardware.py +85 -0
- llm_code/runtime/hooks.py +223 -0
- llm_code/runtime/indexer.py +230 -0
- llm_code/runtime/knowledge_compiler.py +232 -0
- llm_code/runtime/memory.py +132 -0
- llm_code/runtime/memory_layers.py +467 -0
- llm_code/runtime/memory_lint.py +252 -0
- llm_code/runtime/model_aliases.py +37 -0
- llm_code/runtime/ollama.py +93 -0
- llm_code/runtime/overlay.py +124 -0
- llm_code/runtime/permissions.py +200 -0
- llm_code/runtime/plan.py +45 -0
- llm_code/runtime/prompt.py +238 -0
- llm_code/runtime/repo_map.py +174 -0
- llm_code/runtime/sandbox.py +116 -0
- llm_code/runtime/session.py +268 -0
- llm_code/runtime/skill_resolver.py +61 -0
- llm_code/runtime/skills.py +133 -0
- llm_code/runtime/speculative.py +75 -0
- llm_code/runtime/streaming_executor.py +216 -0
- llm_code/runtime/telemetry.py +196 -0
- llm_code/runtime/token_budget.py +26 -0
- llm_code/runtime/vcr.py +142 -0
- llm_code/runtime/vision.py +102 -0
- llm_code/swarm/__init__.py +1 -0
- llm_code/swarm/backend_subprocess.py +108 -0
- llm_code/swarm/backend_tmux.py +103 -0
- llm_code/swarm/backend_worktree.py +306 -0
- llm_code/swarm/checkpoint.py +74 -0
- llm_code/swarm/coordinator.py +236 -0
- llm_code/swarm/mailbox.py +88 -0
- llm_code/swarm/manager.py +202 -0
- llm_code/swarm/memory_sync.py +80 -0
- llm_code/swarm/recovery.py +21 -0
- llm_code/swarm/team.py +67 -0
- llm_code/swarm/types.py +31 -0
- llm_code/task/__init__.py +16 -0
- llm_code/task/diagnostics.py +93 -0
- llm_code/task/manager.py +162 -0
- llm_code/task/types.py +112 -0
- llm_code/task/verifier.py +104 -0
- llm_code/tools/__init__.py +0 -0
- llm_code/tools/agent.py +145 -0
- llm_code/tools/agent_roles.py +82 -0
- llm_code/tools/base.py +94 -0
- llm_code/tools/bash.py +565 -0
- llm_code/tools/computer_use_tools.py +278 -0
- llm_code/tools/coordinator_tool.py +75 -0
- llm_code/tools/cron_create.py +90 -0
- llm_code/tools/cron_delete.py +49 -0
- llm_code/tools/cron_list.py +51 -0
- llm_code/tools/deferred.py +92 -0
- llm_code/tools/dump.py +116 -0
- llm_code/tools/edit_file.py +282 -0
- llm_code/tools/git_tools.py +531 -0
- llm_code/tools/glob_search.py +112 -0
- llm_code/tools/grep_search.py +144 -0
- llm_code/tools/ide_diagnostics.py +59 -0
- llm_code/tools/ide_open.py +58 -0
- llm_code/tools/ide_selection.py +52 -0
- llm_code/tools/memory_tools.py +138 -0
- llm_code/tools/multi_edit.py +143 -0
- llm_code/tools/notebook_edit.py +107 -0
- llm_code/tools/notebook_read.py +81 -0
- llm_code/tools/parsing.py +63 -0
- llm_code/tools/read_file.py +154 -0
- llm_code/tools/registry.py +58 -0
- llm_code/tools/search_backends/__init__.py +56 -0
- llm_code/tools/search_backends/brave.py +56 -0
- llm_code/tools/search_backends/duckduckgo.py +129 -0
- llm_code/tools/search_backends/searxng.py +71 -0
- llm_code/tools/search_backends/tavily.py +73 -0
- llm_code/tools/swarm_create.py +109 -0
- llm_code/tools/swarm_delete.py +95 -0
- llm_code/tools/swarm_list.py +44 -0
- llm_code/tools/swarm_message.py +109 -0
- llm_code/tools/task_close.py +79 -0
- llm_code/tools/task_plan.py +79 -0
- llm_code/tools/task_verify.py +90 -0
- llm_code/tools/tool_search.py +65 -0
- llm_code/tools/web_common.py +258 -0
- llm_code/tools/web_fetch.py +223 -0
- llm_code/tools/web_search.py +280 -0
- llm_code/tools/write_file.py +118 -0
- llm_code/tui/__init__.py +1 -0
- llm_code/tui/app.py +2432 -0
- llm_code/tui/chat_view.py +82 -0
- llm_code/tui/chat_widgets.py +309 -0
- llm_code/tui/header_bar.py +46 -0
- llm_code/tui/input_bar.py +349 -0
- llm_code/tui/keybindings.py +142 -0
- llm_code/tui/marketplace.py +210 -0
- llm_code/tui/status_bar.py +72 -0
- llm_code/tui/theme.py +96 -0
- llm_code/utils/__init__.py +0 -0
- llm_code/utils/diff.py +111 -0
- llm_code/utils/errors.py +70 -0
- llm_code/utils/hyperlink.py +73 -0
- llm_code/utils/notebook.py +179 -0
- llm_code/utils/search.py +69 -0
- llm_code/utils/text_normalize.py +28 -0
- llm_code/utils/version_check.py +62 -0
- llm_code/vim/__init__.py +4 -0
- llm_code/vim/engine.py +51 -0
- llm_code/vim/motions.py +172 -0
- llm_code/vim/operators.py +183 -0
- llm_code/vim/text_objects.py +139 -0
- llm_code/vim/transitions.py +279 -0
- llm_code/vim/types.py +68 -0
- llm_code/voice/__init__.py +1 -0
- llm_code/voice/languages.py +43 -0
- llm_code/voice/recorder.py +136 -0
- llm_code/voice/stt.py +36 -0
- llm_code/voice/stt_anthropic.py +66 -0
- llm_code/voice/stt_google.py +32 -0
- llm_code/voice/stt_whisper.py +52 -0
- llmcode_cli-1.0.0.dist-info/METADATA +524 -0
- llmcode_cli-1.0.0.dist-info/RECORD +212 -0
- llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
- llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|