local-coding-agent 1.1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Universal model discovery, indexing, and persistent local registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import string
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_CONFIG_DIR = Path.home() / ".local_coding_agent"
|
|
18
|
+
_REGISTRY_FILE = _CONFIG_DIR / "models.json"
|
|
19
|
+
|
|
20
|
+
# Common noise directories to skip during deep drive scans
|
|
21
|
+
_SKIP_DIR_NAMES = {
|
|
22
|
+
"$recycle.bin",
|
|
23
|
+
"system volume information",
|
|
24
|
+
"windows",
|
|
25
|
+
"winnt",
|
|
26
|
+
"program files (x86)",
|
|
27
|
+
"node_modules",
|
|
28
|
+
".git",
|
|
29
|
+
".pytest_cache",
|
|
30
|
+
"__pycache__",
|
|
31
|
+
"venv",
|
|
32
|
+
".venv",
|
|
33
|
+
"env",
|
|
34
|
+
"site-packages",
|
|
35
|
+
"dist",
|
|
36
|
+
"build",
|
|
37
|
+
"steamlibrary",
|
|
38
|
+
"steamapps",
|
|
39
|
+
"hearthstone",
|
|
40
|
+
"overwatch",
|
|
41
|
+
"epic games",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Non-LLM substrings in GGUF names to ignore (image diffusion, projectors, audio)
|
|
45
|
+
_NON_LLM_INDICATORS = (
|
|
46
|
+
"mmproj",
|
|
47
|
+
"flux",
|
|
48
|
+
"seedvr",
|
|
49
|
+
"t5-v1",
|
|
50
|
+
"clip",
|
|
51
|
+
"vae",
|
|
52
|
+
"unet",
|
|
53
|
+
"diffusion",
|
|
54
|
+
"stable-diffusion",
|
|
55
|
+
"sdxl",
|
|
56
|
+
"controlnet",
|
|
57
|
+
"lora",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def gguf_model_id(path: str | Path) -> str:
|
|
62
|
+
"""Stable normalized-path id for a GGUF file.
|
|
63
|
+
|
|
64
|
+
Two discovered models may share a basename in different directories; the
|
|
65
|
+
id disambiguates them deterministically: forward slashes, lowercase
|
|
66
|
+
Windows drive letter and extension, collapse of redundant separators.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
normalized = str(Path(path).resolve())
|
|
70
|
+
except OSError:
|
|
71
|
+
normalized = str(path)
|
|
72
|
+
normalized = normalized.replace("\\", "/")
|
|
73
|
+
if len(normalized) >= 2 and normalized[1] == ":":
|
|
74
|
+
directory, separator, filename = normalized.rpartition("/")
|
|
75
|
+
normalized = directory.lower() + separator + filename
|
|
76
|
+
if normalized.lower().endswith(".gguf"):
|
|
77
|
+
normalized = normalized[:-5]
|
|
78
|
+
return normalized
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class DiscoveredModel:
|
|
83
|
+
"""Metadata for a discovered model on the local system."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
display_name: str
|
|
87
|
+
path: str
|
|
88
|
+
size_gb: float
|
|
89
|
+
backend: str = "gguf" # 'gguf' or 'ollama'
|
|
90
|
+
source: str = "scanner" # 'standard', 'custom', 'deep_scan', 'ollama'
|
|
91
|
+
modified_at: float = 0.0
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict[str, Any]:
|
|
94
|
+
data = asdict(self)
|
|
95
|
+
# Stable unambiguous identity: two GGUFs may share a basename, so the
|
|
96
|
+
# UI and /api/model/load must address them by path-derived id.
|
|
97
|
+
data["id"] = gguf_model_id(self.path)
|
|
98
|
+
return data
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_dict(cls, data: dict[str, Any]) -> DiscoveredModel:
|
|
102
|
+
return cls(
|
|
103
|
+
name=data.get("name", ""),
|
|
104
|
+
display_name=data.get("display_name", data.get("name", "")),
|
|
105
|
+
path=data.get("path", ""),
|
|
106
|
+
size_gb=float(data.get("size_gb", 0.0)),
|
|
107
|
+
backend=data.get("backend", "gguf"),
|
|
108
|
+
source=data.get("source", "scanner"),
|
|
109
|
+
modified_at=float(data.get("modified_at", 0.0)),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class ModelRegistryData:
|
|
115
|
+
"""Persistent storage schema for ~/.local_coding_agent/models.json."""
|
|
116
|
+
|
|
117
|
+
custom_directories: list[str] = field(default_factory=list)
|
|
118
|
+
discovered_models: list[dict[str, Any]] = field(default_factory=list)
|
|
119
|
+
last_scanned_at: float = 0.0
|
|
120
|
+
scan_stats: dict[str, Any] = field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class LocalModelRegistry:
|
|
124
|
+
"""Manages discovery and local caching of local LLM models (GGUF / Ollama)."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, registry_file: Path | None = None) -> None:
|
|
127
|
+
self.registry_file = registry_file or _REGISTRY_FILE
|
|
128
|
+
self._lock = threading.Lock()
|
|
129
|
+
self._ensure_config_dir()
|
|
130
|
+
|
|
131
|
+
def _ensure_config_dir(self) -> None:
|
|
132
|
+
self.registry_file.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
def load(self) -> ModelRegistryData:
|
|
135
|
+
"""Load registry from disk or return default data."""
|
|
136
|
+
if not self.registry_file.exists():
|
|
137
|
+
return ModelRegistryData()
|
|
138
|
+
with self._lock:
|
|
139
|
+
try:
|
|
140
|
+
raw = self.registry_file.read_text(encoding="utf-8")
|
|
141
|
+
data = json.loads(raw)
|
|
142
|
+
if isinstance(data, dict):
|
|
143
|
+
return ModelRegistryData(
|
|
144
|
+
custom_directories=data.get("custom_directories", []),
|
|
145
|
+
discovered_models=data.get("discovered_models", []),
|
|
146
|
+
last_scanned_at=data.get("last_scanned_at", 0.0),
|
|
147
|
+
scan_stats=data.get("scan_stats", {}),
|
|
148
|
+
)
|
|
149
|
+
except (OSError, json.JSONDecodeError):
|
|
150
|
+
pass
|
|
151
|
+
return ModelRegistryData()
|
|
152
|
+
|
|
153
|
+
def save(self, data: ModelRegistryData) -> None:
|
|
154
|
+
"""Save registry data to local machine JSON file atomically."""
|
|
155
|
+
self._ensure_config_dir()
|
|
156
|
+
with self._lock:
|
|
157
|
+
try:
|
|
158
|
+
payload = {
|
|
159
|
+
"custom_directories": data.custom_directories,
|
|
160
|
+
"discovered_models": data.discovered_models,
|
|
161
|
+
"last_scanned_at": data.last_scanned_at,
|
|
162
|
+
"scan_stats": data.scan_stats,
|
|
163
|
+
}
|
|
164
|
+
tmp_file = self.registry_file.with_suffix(".tmp")
|
|
165
|
+
tmp_file.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
166
|
+
tmp_file.replace(self.registry_file)
|
|
167
|
+
except OSError:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
def add_custom_directory(self, path: str | Path) -> bool:
|
|
171
|
+
"""Add a custom directory to scan and persist it."""
|
|
172
|
+
norm = str(Path(path).resolve())
|
|
173
|
+
data = self.load()
|
|
174
|
+
if norm not in data.custom_directories:
|
|
175
|
+
data.custom_directories.append(norm)
|
|
176
|
+
self.save(data)
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
def remove_custom_directory(self, path: str | Path) -> bool:
|
|
181
|
+
"""Remove a custom directory from the scan list."""
|
|
182
|
+
norm = str(Path(path).resolve())
|
|
183
|
+
data = self.load()
|
|
184
|
+
if norm in data.custom_directories:
|
|
185
|
+
data.custom_directories.remove(norm)
|
|
186
|
+
self.save(data)
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def list_custom_directories(self) -> list[str]:
|
|
191
|
+
return self.load().custom_directories
|
|
192
|
+
|
|
193
|
+
def get_models(self, auto_scan: bool = True) -> list[DiscoveredModel]:
|
|
194
|
+
"""Return discovered models. If empty and auto_scan=True, performs quick scan."""
|
|
195
|
+
data = self.load()
|
|
196
|
+
models = [
|
|
197
|
+
DiscoveredModel.from_dict(m)
|
|
198
|
+
for m in data.discovered_models
|
|
199
|
+
if isinstance(m, dict) and isinstance(m.get("path"), str) and Path(m["path"]).is_file()
|
|
200
|
+
]
|
|
201
|
+
if not models and auto_scan:
|
|
202
|
+
return self.scan(deep=False)
|
|
203
|
+
return models
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def get_system_drives() -> list[Path]:
|
|
207
|
+
"""Detect all available drive roots on Windows or root mount points on POSIX."""
|
|
208
|
+
drives: list[Path] = []
|
|
209
|
+
if os.name == "nt":
|
|
210
|
+
# Check available Windows drive letters
|
|
211
|
+
for letter in string.ascii_uppercase:
|
|
212
|
+
drive_path = Path(f"{letter}:\\")
|
|
213
|
+
try:
|
|
214
|
+
if drive_path.is_dir():
|
|
215
|
+
drives.append(drive_path)
|
|
216
|
+
except OSError:
|
|
217
|
+
continue
|
|
218
|
+
else:
|
|
219
|
+
drives.append(Path("/"))
|
|
220
|
+
# Check common mount roots
|
|
221
|
+
for m in (Path("/media"), Path("/mnt"), Path("/Volumes")):
|
|
222
|
+
if m.is_dir():
|
|
223
|
+
drives.append(m)
|
|
224
|
+
return drives
|
|
225
|
+
|
|
226
|
+
def get_standard_search_roots(self) -> list[Path]:
|
|
227
|
+
"""Get standard well-known directories where users typically store local models."""
|
|
228
|
+
roots: list[Path] = []
|
|
229
|
+
home = Path.home()
|
|
230
|
+
|
|
231
|
+
# 1. LM Studio locations
|
|
232
|
+
roots.append(home / ".cache" / "lm-studio" / "models")
|
|
233
|
+
roots.append(home / ".lmstudio" / "models")
|
|
234
|
+
|
|
235
|
+
# 2. Ollama & GPT4All locations
|
|
236
|
+
roots.append(home / ".ollama" / "models")
|
|
237
|
+
roots.append(home / ".local" / "share" / "nomic.ai" / "GPT4All")
|
|
238
|
+
|
|
239
|
+
# 3. User home models directory
|
|
240
|
+
roots.append(home / "models")
|
|
241
|
+
roots.append(home / "LLM")
|
|
242
|
+
roots.append(home / "AI")
|
|
243
|
+
|
|
244
|
+
# 4. Standard per-drive candidate folders
|
|
245
|
+
for d in self.get_system_drives():
|
|
246
|
+
roots.append(d / "models")
|
|
247
|
+
roots.append(d / "AI" / "models")
|
|
248
|
+
roots.append(d / "LLM")
|
|
249
|
+
roots.append(d / "llama.cpp" / "models")
|
|
250
|
+
|
|
251
|
+
# 5. Environment variable overrides
|
|
252
|
+
env_dirs = os.environ.get("LOCAL_GGUF_DIRS") or os.environ.get("LOCAL_MODELS_PATH") or os.environ.get("GGUF_MODEL_DIR")
|
|
253
|
+
if env_dirs:
|
|
254
|
+
for part in env_dirs.split(os.pathsep):
|
|
255
|
+
if part.strip() and Path(part.strip()).is_dir():
|
|
256
|
+
roots.append(Path(part.strip()))
|
|
257
|
+
|
|
258
|
+
available: list[Path] = []
|
|
259
|
+
for root in roots:
|
|
260
|
+
try:
|
|
261
|
+
if root.is_dir():
|
|
262
|
+
available.append(root)
|
|
263
|
+
except OSError:
|
|
264
|
+
continue
|
|
265
|
+
return available
|
|
266
|
+
|
|
267
|
+
def scan(
|
|
268
|
+
self,
|
|
269
|
+
deep: bool = False,
|
|
270
|
+
target_drives: list[str] | None = None,
|
|
271
|
+
max_depth: int = 6,
|
|
272
|
+
) -> list[DiscoveredModel]:
|
|
273
|
+
"""Perform a scan for local GGUF models across standard roots and drives."""
|
|
274
|
+
start_time = time.monotonic()
|
|
275
|
+
data = self.load()
|
|
276
|
+
|
|
277
|
+
search_dirs: list[tuple[Path, str]] = []
|
|
278
|
+
|
|
279
|
+
# 1. Custom user directories (highest priority)
|
|
280
|
+
for cd in data.custom_directories:
|
|
281
|
+
p = Path(cd)
|
|
282
|
+
try:
|
|
283
|
+
if p.is_dir():
|
|
284
|
+
search_dirs.append((p, "custom"))
|
|
285
|
+
except OSError:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# 2. Standard known roots
|
|
289
|
+
for sr in self.get_standard_search_roots():
|
|
290
|
+
search_dirs.append((sr, "standard"))
|
|
291
|
+
|
|
292
|
+
# 3. If deep scan requested, include drive roots
|
|
293
|
+
if deep:
|
|
294
|
+
drives = self.get_system_drives()
|
|
295
|
+
if target_drives:
|
|
296
|
+
allowed_letters = {td.upper().rstrip(":\\/") for td in target_drives}
|
|
297
|
+
drives = [d for d in drives if d.drive.upper().rstrip(":") in allowed_letters or str(d) in allowed_letters]
|
|
298
|
+
|
|
299
|
+
for d in drives:
|
|
300
|
+
search_dirs.append((d, "deep_scan"))
|
|
301
|
+
|
|
302
|
+
found_models: list[DiscoveredModel] = []
|
|
303
|
+
seen_paths: set[str] = set()
|
|
304
|
+
|
|
305
|
+
for base_dir, source in search_dirs:
|
|
306
|
+
self._scan_directory(
|
|
307
|
+
base_dir=base_dir,
|
|
308
|
+
source=source,
|
|
309
|
+
found_models=found_models,
|
|
310
|
+
seen_paths=seen_paths,
|
|
311
|
+
max_depth=max_depth if source != "deep_scan" else 4,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
found_models.sort(key=lambda m: m.name.lower())
|
|
315
|
+
|
|
316
|
+
# Update and save registry
|
|
317
|
+
data.discovered_models = [m.to_dict() for m in found_models]
|
|
318
|
+
data.last_scanned_at = time.time()
|
|
319
|
+
data.scan_stats = {
|
|
320
|
+
"elapsed_seconds": round(time.monotonic() - start_time, 2),
|
|
321
|
+
"total_models": len(found_models),
|
|
322
|
+
"scanned_roots_count": len(search_dirs),
|
|
323
|
+
"deep": deep,
|
|
324
|
+
}
|
|
325
|
+
self.save(data)
|
|
326
|
+
|
|
327
|
+
return found_models
|
|
328
|
+
|
|
329
|
+
def _scan_directory(
|
|
330
|
+
self,
|
|
331
|
+
base_dir: Path,
|
|
332
|
+
source: str,
|
|
333
|
+
found_models: list[DiscoveredModel],
|
|
334
|
+
seen_paths: set[str],
|
|
335
|
+
max_depth: int = 5,
|
|
336
|
+
current_depth: int = 0,
|
|
337
|
+
) -> None:
|
|
338
|
+
if current_depth > max_depth:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
if not base_dir.is_dir():
|
|
343
|
+
return
|
|
344
|
+
for entry in base_dir.iterdir():
|
|
345
|
+
# Check for noise directory skipping
|
|
346
|
+
if entry.is_dir():
|
|
347
|
+
dir_name_lower = entry.name.lower()
|
|
348
|
+
if dir_name_lower in _SKIP_DIR_NAMES or dir_name_lower.startswith("."):
|
|
349
|
+
continue
|
|
350
|
+
self._scan_directory(
|
|
351
|
+
base_dir=entry,
|
|
352
|
+
source=source,
|
|
353
|
+
found_models=found_models,
|
|
354
|
+
seen_paths=seen_paths,
|
|
355
|
+
max_depth=max_depth,
|
|
356
|
+
current_depth=current_depth + 1,
|
|
357
|
+
)
|
|
358
|
+
elif entry.is_file() and entry.name.lower().endswith(".gguf"):
|
|
359
|
+
name_lower = entry.name.lower()
|
|
360
|
+
if any(ind in name_lower for ind in _NON_LLM_INDICATORS):
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
norm_path = str(entry.resolve())
|
|
364
|
+
if norm_path in seen_paths:
|
|
365
|
+
continue
|
|
366
|
+
seen_paths.add(norm_path)
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
stat = entry.stat()
|
|
370
|
+
size_gb = round(stat.st_size / (1024**3), 2)
|
|
371
|
+
# Filter out tiny test files < 200MB (unless custom directory)
|
|
372
|
+
if size_gb >= 0.2 or source == "custom":
|
|
373
|
+
found_models.append(
|
|
374
|
+
DiscoveredModel(
|
|
375
|
+
name=entry.name,
|
|
376
|
+
display_name=entry.stem,
|
|
377
|
+
path=norm_path,
|
|
378
|
+
size_gb=size_gb,
|
|
379
|
+
backend="gguf",
|
|
380
|
+
source=source,
|
|
381
|
+
modified_at=stat.st_mtime,
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
except Exception:
|
|
385
|
+
pass
|
|
386
|
+
except (PermissionError, OSError):
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# Global singleton registry
|
|
391
|
+
_GLOBAL_REGISTRY: LocalModelRegistry | None = None
|
|
392
|
+
_GLOBAL_REGISTRY_LOCK = threading.Lock()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def get_model_registry() -> LocalModelRegistry:
|
|
396
|
+
"""Return singleton instance of LocalModelRegistry."""
|
|
397
|
+
global _GLOBAL_REGISTRY
|
|
398
|
+
with _GLOBAL_REGISTRY_LOCK:
|
|
399
|
+
if _GLOBAL_REGISTRY is None or _GLOBAL_REGISTRY.registry_file != _REGISTRY_FILE:
|
|
400
|
+
_GLOBAL_REGISTRY = LocalModelRegistry()
|
|
401
|
+
return _GLOBAL_REGISTRY
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def discover_all_gguf_models(deep: bool = False, force_rescan: bool = False) -> list[dict[str, Any]]:
|
|
405
|
+
"""Helper function to discover all GGUF models across local machine."""
|
|
406
|
+
reg = get_model_registry()
|
|
407
|
+
if force_rescan:
|
|
408
|
+
models = reg.scan(deep=deep)
|
|
409
|
+
else:
|
|
410
|
+
models = reg.get_models(auto_scan=True)
|
|
411
|
+
if not models and not force_rescan:
|
|
412
|
+
models = reg.scan(deep=deep)
|
|
413
|
+
return [m.to_dict() for m in models]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def get_live_system_path() -> str:
|
|
417
|
+
"""Read fresh Windows User and System PATH from Registry so dynamically added paths work immediately."""
|
|
418
|
+
paths: list[str] = []
|
|
419
|
+
if os.name == "nt":
|
|
420
|
+
try:
|
|
421
|
+
import winreg
|
|
422
|
+
for root, subkey in [
|
|
423
|
+
(winreg.HKEY_CURRENT_USER, r"Environment"),
|
|
424
|
+
(winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
|
|
425
|
+
]:
|
|
426
|
+
try:
|
|
427
|
+
with winreg.OpenKey(root, subkey) as key:
|
|
428
|
+
val, _ = winreg.QueryValueEx(key, "Path")
|
|
429
|
+
if val:
|
|
430
|
+
paths.extend(val.split(";"))
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
except Exception:
|
|
434
|
+
pass
|
|
435
|
+
|
|
436
|
+
current_p = os.environ.get("PATH", "")
|
|
437
|
+
paths.extend(current_p.split(os.pathsep))
|
|
438
|
+
expanded = [os.path.expandvars(p.strip()) for p in paths if p.strip()]
|
|
439
|
+
cleaned = [p for p in expanded if p and Path(p).exists()]
|
|
440
|
+
return os.pathsep.join(list(dict.fromkeys(cleaned)))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def discover_llama_server_binary(custom_path: str | None = None) -> str | None:
|
|
444
|
+
"""Universally discover llama-server executable without any hardcoded drive letters or paths."""
|
|
445
|
+
# 1. Custom explicit path if provided
|
|
446
|
+
if custom_path and custom_path.strip():
|
|
447
|
+
p = Path(custom_path.strip()).expanduser()
|
|
448
|
+
if p.is_file():
|
|
449
|
+
return str(p.resolve())
|
|
450
|
+
|
|
451
|
+
# 2. Environment variables
|
|
452
|
+
for env_var in ("LLAMA_SERVER_PATH", "LLAMA_CPP_PATH", "LLAMA_BIN", "LLAMACPP_SERVER_PATH"):
|
|
453
|
+
env_val = os.environ.get(env_var)
|
|
454
|
+
if env_val and env_val.strip():
|
|
455
|
+
p = Path(env_val.strip()).expanduser()
|
|
456
|
+
if p.is_file():
|
|
457
|
+
return str(p.resolve())
|
|
458
|
+
if p.is_dir():
|
|
459
|
+
for name in ("llama-server.exe", "llama-server", "server.exe", "server"):
|
|
460
|
+
cand = p / name
|
|
461
|
+
if cand.is_file():
|
|
462
|
+
return str(cand.resolve())
|
|
463
|
+
|
|
464
|
+
# 3. Live system PATH across the machine
|
|
465
|
+
live_path = get_live_system_path()
|
|
466
|
+
for name in ("llama-server", "llama-server.exe", "server", "server.exe"):
|
|
467
|
+
found = shutil.which(name, path=live_path)
|
|
468
|
+
if found:
|
|
469
|
+
return str(Path(found).resolve())
|
|
470
|
+
|
|
471
|
+
# 4. Universal relative subpaths across all dynamically detected system drives and user home
|
|
472
|
+
# A discovered executable path is an explicit local installation, not a
|
|
473
|
+
# request to execute a foreign binary. Check both conventional suffixes
|
|
474
|
+
# on every platform so discovery and its fixtures stay path-based.
|
|
475
|
+
exe_names = ("llama-server.exe", "llama-server", "server.exe", "server")
|
|
476
|
+
|
|
477
|
+
subpaths = [
|
|
478
|
+
Path("AI") / "llama-server",
|
|
479
|
+
Path("AI") / "llama.cpp",
|
|
480
|
+
Path("llama-server"),
|
|
481
|
+
Path("llama.cpp"),
|
|
482
|
+
Path("tools") / "llama-server",
|
|
483
|
+
Path("tools") / "llama.cpp",
|
|
484
|
+
Path("bin"),
|
|
485
|
+
Path("Program Files") / "llama.cpp",
|
|
486
|
+
Path("Program Files") / "llama-server",
|
|
487
|
+
Path(".docker") / "bin" / "inference",
|
|
488
|
+
Path(".local") / "bin",
|
|
489
|
+
Path("AppData") / "Local" / "Programs" / "llama-server",
|
|
490
|
+
Path("AppData") / "Local" / "Programs" / "llama.cpp",
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
# Search user home directory standard paths safely
|
|
494
|
+
try:
|
|
495
|
+
home = Path.home()
|
|
496
|
+
except Exception:
|
|
497
|
+
home = None
|
|
498
|
+
|
|
499
|
+
if home:
|
|
500
|
+
for sp in subpaths:
|
|
501
|
+
for name in exe_names:
|
|
502
|
+
cand = home / sp / name
|
|
503
|
+
if cand.is_file():
|
|
504
|
+
return str(cand.resolve())
|
|
505
|
+
|
|
506
|
+
# Search across all dynamically detected drives (C:\, D:\, E:\, etc. on Windows, or / on POSIX)
|
|
507
|
+
for drive in LocalModelRegistry.get_system_drives():
|
|
508
|
+
for sp in subpaths:
|
|
509
|
+
for name in exe_names:
|
|
510
|
+
cand = drive / sp / name
|
|
511
|
+
if cand.is_file():
|
|
512
|
+
return str(cand.resolve())
|
|
513
|
+
|
|
514
|
+
# POSIX standard directories
|
|
515
|
+
if os.name != "nt":
|
|
516
|
+
for posix_p in (
|
|
517
|
+
Path("/usr/local/bin/llama-server"),
|
|
518
|
+
Path("/opt/homebrew/bin/llama-server"),
|
|
519
|
+
Path("/usr/bin/llama-server"),
|
|
520
|
+
Path("/opt/llama.cpp/llama-server"),
|
|
521
|
+
):
|
|
522
|
+
if posix_p.is_file():
|
|
523
|
+
return str(posix_p.resolve())
|
|
524
|
+
|
|
525
|
+
return None
|