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,725 @@
|
|
|
1
|
+
"""Subcommand dispatch for the local-coding-agent CLI (part 2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..benchmark import run_benchmark, write_artifact
|
|
12
|
+
from ..doctor import diagnose_environment
|
|
13
|
+
from ..mcp_config import integrate_mcp_config
|
|
14
|
+
from ..memory import ModelMemoryManager
|
|
15
|
+
from ..mode_router import build_mode_router, classify_fast, classify_mode
|
|
16
|
+
from ..ollama_adapter import (
|
|
17
|
+
BACKEND_OFFLINE_HINT,
|
|
18
|
+
OllamaError,
|
|
19
|
+
build_client,
|
|
20
|
+
classify_backend_error,
|
|
21
|
+
)
|
|
22
|
+
from ..profiles import get_profile
|
|
23
|
+
from ..skill_config import integrate_skill_config
|
|
24
|
+
from ..smoke import run_smoke_test
|
|
25
|
+
from ..task import TaskEnvelope
|
|
26
|
+
|
|
27
|
+
_SOURCE_EXTS = {
|
|
28
|
+
".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".rb",
|
|
29
|
+
".php", ".c", ".cpp", ".h", ".hpp", ".cs", ".sh", ".kt", ".swift",
|
|
30
|
+
}
|
|
31
|
+
_SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build"}
|
|
32
|
+
|
|
33
|
+
_SESSIONS_DIR = Path(".local-run") / "sessions"
|
|
34
|
+
|
|
35
|
+
_CHAT_SYSTEM_PROMPT = "You are a concise local coding assistant."
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _detect_relevant_files(workspace: Path, prompt: str) -> list[str]:
|
|
39
|
+
"""Mirror desktop auto-detection: bounded scan for source files to allowlist.
|
|
40
|
+
|
|
41
|
+
Returns relative POSIX paths (as accepted by TaskEnvelope) for source files
|
|
42
|
+
whose basename appears in the prompt; falls back to the first few source
|
|
43
|
+
files if none match. Never recurses into build/vendor/cache dirs.
|
|
44
|
+
"""
|
|
45
|
+
source_files: list[Path] = []
|
|
46
|
+
for p in workspace.rglob("*"):
|
|
47
|
+
if not p.is_file():
|
|
48
|
+
continue
|
|
49
|
+
parts = p.relative_to(workspace).parts
|
|
50
|
+
if any(part in _SKIP_DIRS or part.startswith(".") for part in parts):
|
|
51
|
+
continue
|
|
52
|
+
if p.suffix.lower() in _SOURCE_EXTS:
|
|
53
|
+
source_files.append(p)
|
|
54
|
+
source_files.sort()
|
|
55
|
+
# ponytail: bounded scan, 200 files is plenty for an isolated-context allowlist.
|
|
56
|
+
source_files = source_files[:200]
|
|
57
|
+
|
|
58
|
+
matched = [
|
|
59
|
+
str(p.relative_to(workspace).as_posix())
|
|
60
|
+
for p in source_files
|
|
61
|
+
if p.name in prompt or p.stem in prompt
|
|
62
|
+
]
|
|
63
|
+
# ponytail: cap at max_files=5 so BoundedRepositoryTools never rejects a
|
|
64
|
+
# prompt that names more files than the allowlist permits.
|
|
65
|
+
if matched:
|
|
66
|
+
return matched[:5]
|
|
67
|
+
return [str(p.relative_to(workspace).as_posix()) for p in source_files[:5]]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _detect_test_checks(workspace: Path) -> list[str]:
|
|
71
|
+
if (workspace / "tests").is_dir():
|
|
72
|
+
return ["pytest tests/"]
|
|
73
|
+
if (workspace / "test").is_dir():
|
|
74
|
+
return ["pytest test/"]
|
|
75
|
+
for p in workspace.rglob("test_*.py"):
|
|
76
|
+
if not p.is_file():
|
|
77
|
+
continue
|
|
78
|
+
if any(part in _SKIP_DIRS or part.startswith(".") for part in p.relative_to(workspace).parts):
|
|
79
|
+
continue
|
|
80
|
+
return [f"pytest {p.relative_to(workspace).as_posix()}"]
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _detect_files_or_raise(workspace: Path, prompt: str) -> list[str]:
|
|
85
|
+
files = _detect_relevant_files(workspace, prompt)
|
|
86
|
+
if files:
|
|
87
|
+
return files
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"no source files detected in workspace; specify a workspace containing source files"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _profile_for_args(args: argparse.Namespace):
|
|
94
|
+
overrides: dict[str, object] = {}
|
|
95
|
+
if getattr(args, "model", None):
|
|
96
|
+
overrides["model"] = args.model
|
|
97
|
+
num_ctx = getattr(args, "num_ctx", None)
|
|
98
|
+
if num_ctx is not None:
|
|
99
|
+
overrides["num_ctx"] = num_ctx
|
|
100
|
+
return get_profile(args.profile, **overrides)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _backend_error_payload(error: Exception) -> dict[str, object]:
|
|
104
|
+
# Normalize to the same vocabulary the controller model boundary uses.
|
|
105
|
+
kind = {
|
|
106
|
+
"offline": "backend_offline",
|
|
107
|
+
"server_error": "backend_error",
|
|
108
|
+
}.get(classify_backend_error(error) or "", "backend_error")
|
|
109
|
+
payload: dict[str, object] = {"status": "failed", "error": {"kind": kind, "message": str(error)}}
|
|
110
|
+
if kind == "backend_offline":
|
|
111
|
+
payload["error"]["hint"] = BACKEND_OFFLINE_HINT
|
|
112
|
+
return payload
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _sanitize_session_id(session_id: str) -> str:
|
|
116
|
+
safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in session_id)[:80]
|
|
117
|
+
return safe or "session"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _load_session_log(session_id: str):
|
|
121
|
+
from ..session_events import SessionLog
|
|
122
|
+
|
|
123
|
+
log_path = _SESSIONS_DIR / f"{_sanitize_session_id(session_id)}.jsonl"
|
|
124
|
+
if log_path.exists():
|
|
125
|
+
return SessionLog.load_from_jsonl(log_path), True
|
|
126
|
+
_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
return SessionLog(session_id=_sanitize_session_id(session_id), storage_dir=_SESSIONS_DIR), False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _run_chat_repl(args: argparse.Namespace) -> int:
|
|
131
|
+
from datetime import datetime
|
|
132
|
+
|
|
133
|
+
from ..session_events import derive_messages
|
|
134
|
+
|
|
135
|
+
session_id = getattr(args, "session_id", None) or (
|
|
136
|
+
"chat-" + datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
log, resumed = _load_session_log(session_id)
|
|
140
|
+
except Exception as error:
|
|
141
|
+
print(f"Error: cannot load session {session_id!r}: {error}", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
if resumed:
|
|
144
|
+
print(f"[RESUMED session {log.session_id} — {len(log)} events]")
|
|
145
|
+
else:
|
|
146
|
+
log.record_created(metadata={"profile": args.profile, "workspace": str(args.workspace)})
|
|
147
|
+
print(f"[NEW session {log.session_id}] Multi-turn chat REPL. Type 'exit' or Ctrl+C to stop.")
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
profile = _profile_for_args(args)
|
|
151
|
+
client = build_client(profile)
|
|
152
|
+
except OllamaError as error:
|
|
153
|
+
print(json.dumps(_backend_error_payload(error), ensure_ascii=False, indent=2))
|
|
154
|
+
return 2
|
|
155
|
+
|
|
156
|
+
from ..chat_compaction import fit_history
|
|
157
|
+
|
|
158
|
+
interrupted = False
|
|
159
|
+
sticky_boundary = 0
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
line = input("you> ").strip()
|
|
163
|
+
except (EOFError, KeyboardInterrupt):
|
|
164
|
+
print()
|
|
165
|
+
break
|
|
166
|
+
if not line:
|
|
167
|
+
continue
|
|
168
|
+
if line.lower() in {"exit", "quit", "/exit", "/quit"}:
|
|
169
|
+
break
|
|
170
|
+
log.record_user_prompt(line)
|
|
171
|
+
messages = [{"role": "system", "content": _CHAT_SYSTEM_PROMPT}, *derive_messages(log.events)]
|
|
172
|
+
messages, cinfo = fit_history(
|
|
173
|
+
messages, max(1024, profile.num_ctx - 1024), sticky_boundary
|
|
174
|
+
)
|
|
175
|
+
sticky_boundary = cinfo["next_sticky_boundary"]
|
|
176
|
+
try:
|
|
177
|
+
resp = client.chat(messages)
|
|
178
|
+
except KeyboardInterrupt:
|
|
179
|
+
print("\n[interrupted]")
|
|
180
|
+
interrupted = True
|
|
181
|
+
break
|
|
182
|
+
except OllamaError as error:
|
|
183
|
+
print(json.dumps(_backend_error_payload(error), ensure_ascii=False, indent=2))
|
|
184
|
+
return 2
|
|
185
|
+
reply = (resp.get("message") or {}).get("content") or ""
|
|
186
|
+
if cinfo["compacted"]:
|
|
187
|
+
print("[context compacted]")
|
|
188
|
+
log.record_model_turn(content=reply, model=profile_model_name(args))
|
|
189
|
+
print(f"assistant> {reply}")
|
|
190
|
+
|
|
191
|
+
log.record_completed(
|
|
192
|
+
"interrupted" if interrupted else "success",
|
|
193
|
+
summary="repl closed by user",
|
|
194
|
+
)
|
|
195
|
+
print(f"[Session saved: {log.log_path}]")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def profile_model_name(args: argparse.Namespace) -> str:
|
|
200
|
+
overrides = {"model": args.model} if getattr(args, "model", None) else {}
|
|
201
|
+
return get_profile(args.profile, **overrides).model
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _handle_chat(args: argparse.Namespace) -> int:
|
|
205
|
+
if getattr(args, "list_sessions", False):
|
|
206
|
+
return _handle_sessions_list(getattr(args, "json", False), getattr(args, "limit", 50) or 50)
|
|
207
|
+
|
|
208
|
+
if getattr(args, "repl", False):
|
|
209
|
+
return _run_chat_repl(args)
|
|
210
|
+
|
|
211
|
+
if not args.prompt or not args.prompt.strip():
|
|
212
|
+
if getattr(args, "json", False):
|
|
213
|
+
print(json.dumps({"status": "failed", "message": "prompt is required"}, ensure_ascii=False, indent=2))
|
|
214
|
+
else:
|
|
215
|
+
print("Error: prompt is required", file=sys.stderr)
|
|
216
|
+
return 1
|
|
217
|
+
|
|
218
|
+
if args.mode == "hybrid":
|
|
219
|
+
try:
|
|
220
|
+
# ponytail: router uses the small default profile, not the main one.
|
|
221
|
+
router = build_mode_router()
|
|
222
|
+
mode = classify_mode(
|
|
223
|
+
args.prompt,
|
|
224
|
+
router=router,
|
|
225
|
+
counter=0,
|
|
226
|
+
recent_prompts=[args.prompt],
|
|
227
|
+
)
|
|
228
|
+
except Exception:
|
|
229
|
+
mode = classify_mode(args.prompt, router=None)
|
|
230
|
+
else:
|
|
231
|
+
mode = args.mode
|
|
232
|
+
# A question never needs a patch: route it to chat even when the user
|
|
233
|
+
# explicitly picked build (mirrors the desktop handler's classifier gate).
|
|
234
|
+
if mode != "plan" and classify_fast(args.prompt) == "chat":
|
|
235
|
+
mode = "chat"
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
from ..controller import Controller
|
|
239
|
+
|
|
240
|
+
if mode == "plan":
|
|
241
|
+
# Read-only Controller path mirrors desktop: real allowlisted files,
|
|
242
|
+
# mutation tools blocked, output shaped like PlanArtifact.to_dict().
|
|
243
|
+
profile = _profile_for_args(args)
|
|
244
|
+
client = build_client(profile)
|
|
245
|
+
workspace = Path(args.workspace)
|
|
246
|
+
files = _detect_files_or_raise(workspace, args.prompt)
|
|
247
|
+
checks = _detect_test_checks(workspace)
|
|
248
|
+
task = TaskEnvelope(
|
|
249
|
+
id="chat-plan",
|
|
250
|
+
goal=args.prompt,
|
|
251
|
+
files=tuple(files),
|
|
252
|
+
checks=tuple(checks),
|
|
253
|
+
)
|
|
254
|
+
run = Controller(
|
|
255
|
+
client,
|
|
256
|
+
args.workspace,
|
|
257
|
+
max_turns=4,
|
|
258
|
+
blocked_tools={"propose_patch", "run_tests"},
|
|
259
|
+
).run(task)
|
|
260
|
+
summary = run.get("summary") or ""
|
|
261
|
+
risks = run.get("risks") or []
|
|
262
|
+
files_to_modify = list(files)
|
|
263
|
+
plan = {
|
|
264
|
+
"goal": args.prompt,
|
|
265
|
+
"steps": [summary] if summary else [],
|
|
266
|
+
"risks": [
|
|
267
|
+
str(r.get("message", "")) if isinstance(r, dict) else str(r)
|
|
268
|
+
for r in risks
|
|
269
|
+
],
|
|
270
|
+
"files_to_modify": [str(f) for f in files_to_modify],
|
|
271
|
+
}
|
|
272
|
+
result = {
|
|
273
|
+
"status": "completed" if run.get("status") == "accepted" else "failed",
|
|
274
|
+
"mode": mode,
|
|
275
|
+
"message": summary or f"Plan generated for '{args.prompt}'.",
|
|
276
|
+
"patch": "",
|
|
277
|
+
"plan": plan,
|
|
278
|
+
"checks": run.get("checks") or [],
|
|
279
|
+
}
|
|
280
|
+
elif mode == "chat":
|
|
281
|
+
client = build_client(_profile_for_args(args))
|
|
282
|
+
resp = client.chat([
|
|
283
|
+
{"role": "system", "content": _CHAT_SYSTEM_PROMPT},
|
|
284
|
+
{"role": "user", "content": args.prompt},
|
|
285
|
+
])
|
|
286
|
+
reply = (resp.get("message") or {}).get("content") or ""
|
|
287
|
+
result = {
|
|
288
|
+
"status": "completed",
|
|
289
|
+
"mode": mode,
|
|
290
|
+
"message": reply,
|
|
291
|
+
"patch": "",
|
|
292
|
+
"plan": None,
|
|
293
|
+
"checks": [],
|
|
294
|
+
}
|
|
295
|
+
else: # build
|
|
296
|
+
profile = _profile_for_args(args)
|
|
297
|
+
client = build_client(profile)
|
|
298
|
+
workspace = Path(args.workspace)
|
|
299
|
+
files = _detect_files_or_raise(workspace, args.prompt)
|
|
300
|
+
checks = _detect_test_checks(workspace)
|
|
301
|
+
task = TaskEnvelope(
|
|
302
|
+
id="chat-build",
|
|
303
|
+
goal=args.prompt,
|
|
304
|
+
files=tuple(files),
|
|
305
|
+
checks=tuple(checks),
|
|
306
|
+
)
|
|
307
|
+
run = Controller(client, args.workspace, max_turns=4).run(task)
|
|
308
|
+
result = {
|
|
309
|
+
"status": "completed" if run.get("status") == "accepted" else "failed",
|
|
310
|
+
"mode": mode,
|
|
311
|
+
"message": run.get("summary") or "",
|
|
312
|
+
"patch": run.get("patch") or "",
|
|
313
|
+
"plan": None,
|
|
314
|
+
"checks": run.get("checks") or [],
|
|
315
|
+
}
|
|
316
|
+
except OllamaError as error:
|
|
317
|
+
payload = _backend_error_payload(error)
|
|
318
|
+
if getattr(args, "json", False):
|
|
319
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
320
|
+
else:
|
|
321
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
322
|
+
hint = (payload.get("error") or {}).get("hint")
|
|
323
|
+
if hint:
|
|
324
|
+
print(f"Hint: {hint}", file=sys.stderr)
|
|
325
|
+
return 2
|
|
326
|
+
except Exception as error:
|
|
327
|
+
if getattr(args, "json", False):
|
|
328
|
+
print(json.dumps({"status": "failed", "mode": mode, "message": str(error), "patch": "", "plan": None, "checks": []}, ensure_ascii=False, indent=2))
|
|
329
|
+
else:
|
|
330
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
331
|
+
return 1
|
|
332
|
+
|
|
333
|
+
if getattr(args, "json", False):
|
|
334
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
335
|
+
else:
|
|
336
|
+
print(f"[{mode.upper()}]")
|
|
337
|
+
print(result.get("message") or "(no response)")
|
|
338
|
+
if result.get("plan"):
|
|
339
|
+
print(json.dumps(result["plan"], ensure_ascii=False, indent=2))
|
|
340
|
+
return 0 if result["status"] == "completed" else 1
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _iter_session_logs():
|
|
344
|
+
from ..session_events import SessionLog
|
|
345
|
+
|
|
346
|
+
if not _SESSIONS_DIR.is_dir():
|
|
347
|
+
return
|
|
348
|
+
for path in sorted(_SESSIONS_DIR.glob("*.jsonl")):
|
|
349
|
+
try:
|
|
350
|
+
yield SessionLog.load_from_jsonl(path)
|
|
351
|
+
except Exception:
|
|
352
|
+
continue # ponytail: skip corrupt/partial session files.
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _index_sessions(engine) -> None:
|
|
356
|
+
for log in _iter_session_logs():
|
|
357
|
+
try:
|
|
358
|
+
record = engine.get_session_record(log.session_id)
|
|
359
|
+
if record is None or record["event_count"] != len(log):
|
|
360
|
+
engine.index_session_log(log)
|
|
361
|
+
except Exception:
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _handle_sessions_list(as_json: bool, limit: int = 50) -> int:
|
|
366
|
+
from ..session_query import get_default_engine
|
|
367
|
+
|
|
368
|
+
engine = get_default_engine(_SESSIONS_DIR / "index.db")
|
|
369
|
+
_index_sessions(engine)
|
|
370
|
+
sessions = engine.list_sessions(limit=limit)
|
|
371
|
+
if as_json:
|
|
372
|
+
print(json.dumps({"sessions": sessions}, ensure_ascii=False, indent=2))
|
|
373
|
+
return 0
|
|
374
|
+
if not sessions:
|
|
375
|
+
print("No persisted sessions found. Start one with: local-agent chat --repl")
|
|
376
|
+
return 0
|
|
377
|
+
print(f"{'SESSION ID':<32} {'EVENTS':<8} {'UPDATED':<25} STATUS")
|
|
378
|
+
print("-" * 84)
|
|
379
|
+
for s in sessions:
|
|
380
|
+
print(f"{s['session_id']:<32} {s['event_count']:<8} {str(s['updated_at']):<25} {s['status'] or '-'}")
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _handle_sessions(args: argparse.Namespace) -> int:
|
|
385
|
+
from ..session_query import get_default_engine
|
|
386
|
+
|
|
387
|
+
action = getattr(args, "session_action", "list") or "list"
|
|
388
|
+
if action == "show":
|
|
389
|
+
if not args.session_id:
|
|
390
|
+
print(json.dumps({"status": "failed", "error": "session id required for 'show'"}, ensure_ascii=False))
|
|
391
|
+
return 2
|
|
392
|
+
engine = get_default_engine(_SESSIONS_DIR / "index.db")
|
|
393
|
+
_index_sessions(engine)
|
|
394
|
+
stored_id = _sanitize_session_id(args.session_id)
|
|
395
|
+
trace = engine.get_session_trace(stored_id)
|
|
396
|
+
record = engine.get_session_record(stored_id)
|
|
397
|
+
if not trace and record is None:
|
|
398
|
+
print(json.dumps({"status": "failed", "error": f"unknown session: {args.session_id!r}"}, ensure_ascii=False))
|
|
399
|
+
return 1
|
|
400
|
+
payload = {"record": record, "events": trace}
|
|
401
|
+
if getattr(args, "json", False):
|
|
402
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
403
|
+
else:
|
|
404
|
+
print(f"Session {stored_id} — {len(trace)} events")
|
|
405
|
+
for ev in trace:
|
|
406
|
+
kind = ev.get("event_type")
|
|
407
|
+
ts = str(ev.get("timestamp", ""))[:19]
|
|
408
|
+
if kind == "user_prompt":
|
|
409
|
+
print(f"[{ts}] you> {ev.get('content', '')}")
|
|
410
|
+
elif kind == "model_turn":
|
|
411
|
+
print(f"[{ts}] assistant> {ev.get('content', '')}")
|
|
412
|
+
elif kind == "session_completed":
|
|
413
|
+
print(f"[{ts}] (completed: {ev.get('status')})")
|
|
414
|
+
elif kind == "session_created":
|
|
415
|
+
print(f"[{ts}] (session started)")
|
|
416
|
+
else:
|
|
417
|
+
print(f"[{ts}] ({kind})")
|
|
418
|
+
return 0
|
|
419
|
+
|
|
420
|
+
return _handle_sessions_list(getattr(args, "json", False), getattr(args, "limit", 50) or 50)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _handle_skill(args: argparse.Namespace) -> int:
|
|
424
|
+
dry_run = not args.write or args.dry_run
|
|
425
|
+
res = integrate_skill_config(
|
|
426
|
+
client=args.client,
|
|
427
|
+
workspace=args.workspace,
|
|
428
|
+
target_path=args.path,
|
|
429
|
+
dry_run=dry_run,
|
|
430
|
+
print_content=args.print_content,
|
|
431
|
+
)
|
|
432
|
+
if args.print_content:
|
|
433
|
+
content = res.get("content", "")
|
|
434
|
+
try:
|
|
435
|
+
print(content)
|
|
436
|
+
except UnicodeEncodeError:
|
|
437
|
+
if hasattr(sys.stdout, "buffer"):
|
|
438
|
+
sys.stdout.buffer.write(content.encode("utf-8", errors="replace"))
|
|
439
|
+
sys.stdout.buffer.write(b"\n")
|
|
440
|
+
sys.stdout.buffer.flush()
|
|
441
|
+
else:
|
|
442
|
+
print(content.encode("ascii", errors="replace").decode("ascii"))
|
|
443
|
+
return 0
|
|
444
|
+
|
|
445
|
+
if args.json:
|
|
446
|
+
print(json.dumps(res, indent=2, ensure_ascii=False))
|
|
447
|
+
else:
|
|
448
|
+
if "results" in res:
|
|
449
|
+
print(f"--- Multi-Agent Skill Installation ({'Preview' if dry_run else 'Installed'}) ---")
|
|
450
|
+
for sub_res in res["results"]:
|
|
451
|
+
status = "[DRY-RUN]" if dry_run else "[OK]"
|
|
452
|
+
print(f"{status} {sub_res['client'].upper()}: {sub_res['path']}")
|
|
453
|
+
if dry_run:
|
|
454
|
+
print("\nUse --write to write SKILL.md into these directories.")
|
|
455
|
+
else:
|
|
456
|
+
status = "[DRY-RUN]" if dry_run else "[OK]"
|
|
457
|
+
print(f"{status} {res.get('client', 'custom').upper()}: {res.get('path')}")
|
|
458
|
+
if dry_run:
|
|
459
|
+
print("\nUse --write to write SKILL.md into this path.")
|
|
460
|
+
return 0
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _handle_doctor(args: argparse.Namespace) -> int:
|
|
464
|
+
if getattr(args, "fix", False):
|
|
465
|
+
from ..doctor import remediate_environment
|
|
466
|
+
|
|
467
|
+
write = not getattr(args, "dry_run", False)
|
|
468
|
+
fix_rep = remediate_environment(endpoint=args.endpoint, write=write)
|
|
469
|
+
if args.json:
|
|
470
|
+
print(json.dumps(fix_rep.to_dict(), indent=2, ensure_ascii=False))
|
|
471
|
+
else:
|
|
472
|
+
print(fix_rep.render_text())
|
|
473
|
+
return 0 if fix_rep.success else 1
|
|
474
|
+
|
|
475
|
+
report = diagnose_environment(endpoint=args.endpoint)
|
|
476
|
+
if args.json:
|
|
477
|
+
print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False))
|
|
478
|
+
else:
|
|
479
|
+
print(report.render_text())
|
|
480
|
+
if getattr(args, "strict", False):
|
|
481
|
+
return 0 if report.healthy else 1
|
|
482
|
+
return 0
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _handle_init_mcp(args: argparse.Namespace) -> int:
|
|
486
|
+
dry_run = not args.write or args.dry_run
|
|
487
|
+
res = integrate_mcp_config(
|
|
488
|
+
client=args.client,
|
|
489
|
+
workspace=args.workspace,
|
|
490
|
+
profile=args.profile,
|
|
491
|
+
target_path=args.path,
|
|
492
|
+
dry_run=dry_run,
|
|
493
|
+
)
|
|
494
|
+
if "results" in res:
|
|
495
|
+
print(f"--- Multi-Client MCP Configuration ({'Preview' if dry_run else 'Applied'}) ---")
|
|
496
|
+
for sub_res in res["results"]:
|
|
497
|
+
status = "[DRY-RUN]" if dry_run else "[OK]"
|
|
498
|
+
print(f"{status} {sub_res['client'].upper()}: {sub_res['path']}")
|
|
499
|
+
if dry_run:
|
|
500
|
+
print("\nUse --write to automatically merge these configs into the files.")
|
|
501
|
+
else:
|
|
502
|
+
if dry_run:
|
|
503
|
+
print(f"--- MCP Configuration Preview ({args.client}) ---")
|
|
504
|
+
print(f"Target Path: {res['path']}")
|
|
505
|
+
config = res["config"]
|
|
506
|
+
print(config if isinstance(config, str) else json.dumps(config, indent=2, ensure_ascii=False))
|
|
507
|
+
print("\nUse --write to automatically merge this config into the file.")
|
|
508
|
+
else:
|
|
509
|
+
print(f"[OK] Successfully integrated MCP server into: {res['path']}")
|
|
510
|
+
return 0
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _handle_smoke(args: argparse.Namespace) -> int:
|
|
514
|
+
res = run_smoke_test(
|
|
515
|
+
profile_name=args.profile,
|
|
516
|
+
use_mock=args.mock,
|
|
517
|
+
fallback_to_mock=getattr(args, "allow_mock_fallback", False),
|
|
518
|
+
verbose=True,
|
|
519
|
+
)
|
|
520
|
+
return 0 if res["success"] and (res.get("backend_verified") or res.get("synthetic")) else 1
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _handle_serve_mcp(args: argparse.Namespace) -> int:
|
|
524
|
+
from ..mcp_server import build_server
|
|
525
|
+
from ..service import DelegationService
|
|
526
|
+
|
|
527
|
+
ws_path = str(Path(args.workspace).resolve())
|
|
528
|
+
service = DelegationService({args.workspace_ref: ws_path})
|
|
529
|
+
server = build_server(service, enable_tasks=args.enable_tasks)
|
|
530
|
+
server.run(transport="stdio")
|
|
531
|
+
return 0
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _handle_serve_acp(args: argparse.Namespace) -> int:
|
|
535
|
+
from ..acp_server import AcpServer
|
|
536
|
+
|
|
537
|
+
server = AcpServer(
|
|
538
|
+
default_workspace=args.workspace,
|
|
539
|
+
default_profile=args.profile,
|
|
540
|
+
framing=args.framing,
|
|
541
|
+
)
|
|
542
|
+
server.serve()
|
|
543
|
+
return 0
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _handle_monitor(args: argparse.Namespace) -> int:
|
|
547
|
+
from ..monitor import MonitorServer
|
|
548
|
+
from ..stats import DelegationStats, default_stats_path
|
|
549
|
+
|
|
550
|
+
if args.subcommand in ("ui", "app"):
|
|
551
|
+
print("=" * 72)
|
|
552
|
+
print(" [EXPERIMENTAL PREVIEW] Web Workbench UI is experimental incubation.")
|
|
553
|
+
print(" Full standalone Desktop Harness redesign is currently in progress.")
|
|
554
|
+
print("=" * 72)
|
|
555
|
+
|
|
556
|
+
stats = DelegationStats()
|
|
557
|
+
server = MonitorServer(host=args.host, port=args.port, stats=stats, stats_path=default_stats_path())
|
|
558
|
+
path_name = "workbench" if args.subcommand in ("ui", "app") else "dashboard"
|
|
559
|
+
print(f"Starting server on {server.url}/{path_name} (Press Ctrl+C to stop)...")
|
|
560
|
+
server.start()
|
|
561
|
+
try:
|
|
562
|
+
while True:
|
|
563
|
+
import time
|
|
564
|
+
|
|
565
|
+
time.sleep(1)
|
|
566
|
+
except KeyboardInterrupt:
|
|
567
|
+
server.stop()
|
|
568
|
+
print("\nServer stopped.")
|
|
569
|
+
return 0
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _handle_desktop(args: argparse.Namespace) -> int:
|
|
573
|
+
from ..desktop import launch_desktop_app
|
|
574
|
+
|
|
575
|
+
return launch_desktop_app(
|
|
576
|
+
host=args.host,
|
|
577
|
+
port=args.port,
|
|
578
|
+
workspace=args.workspace,
|
|
579
|
+
default_profile=args.profile,
|
|
580
|
+
browser=getattr(args, "browser", False),
|
|
581
|
+
headless=getattr(args, "headless", False),
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _handle_scan_models(args: argparse.Namespace) -> int:
|
|
586
|
+
from ..model_scanner import get_model_registry
|
|
587
|
+
|
|
588
|
+
registry = get_model_registry()
|
|
589
|
+
if getattr(args, "add_dir", None):
|
|
590
|
+
added = registry.add_custom_directory(args.add_dir)
|
|
591
|
+
res = {"status": "added" if added else "already_present", "directory": args.add_dir}
|
|
592
|
+
print(json.dumps(res, indent=2, ensure_ascii=False))
|
|
593
|
+
return 0
|
|
594
|
+
if getattr(args, "remove_dir", None):
|
|
595
|
+
removed = registry.remove_custom_directory(args.remove_dir)
|
|
596
|
+
res = {"status": "removed" if removed else "not_found", "directory": args.remove_dir}
|
|
597
|
+
print(json.dumps(res, indent=2, ensure_ascii=False))
|
|
598
|
+
return 0
|
|
599
|
+
if getattr(args, "list_dirs", False):
|
|
600
|
+
dirs = registry.list_custom_directories()
|
|
601
|
+
res = {"custom_directories": dirs}
|
|
602
|
+
print(json.dumps(res, indent=2, ensure_ascii=False))
|
|
603
|
+
return 0
|
|
604
|
+
|
|
605
|
+
target_drives = [d.strip() for d in args.drives.split(",") if d.strip()] if getattr(args, "drives", None) else None
|
|
606
|
+
discovered = registry.scan(deep=getattr(args, "deep", False), target_drives=target_drives)
|
|
607
|
+
models_data = [m.to_dict() for m in discovered]
|
|
608
|
+
if getattr(args, "json", False):
|
|
609
|
+
print(json.dumps({"total_models": len(models_data), "models": models_data}, indent=2, ensure_ascii=False))
|
|
610
|
+
else:
|
|
611
|
+
print(f"--- Discovered Local GGUF Models ({len(models_data)}) ---")
|
|
612
|
+
for m in models_data:
|
|
613
|
+
print(f"[{m['backend'].upper()}] {m['name']} ({m['size_gb']} GB) -> {m['path']}")
|
|
614
|
+
return 0
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _handle_benchmark(args: argparse.Namespace) -> int:
|
|
618
|
+
overrides = {}
|
|
619
|
+
if args.endpoint:
|
|
620
|
+
overrides["endpoint"] = args.endpoint
|
|
621
|
+
names = tuple(args.benchmark_models or (
|
|
622
|
+
"qwen3-8b-q6k",
|
|
623
|
+
"qwen3.8-27b-q4",
|
|
624
|
+
"qwen2.5-coder",
|
|
625
|
+
"ornith-9b",
|
|
626
|
+
"qwen3-coder-30b",
|
|
627
|
+
"devstral-small-2-24b",
|
|
628
|
+
))
|
|
629
|
+
artifact = {
|
|
630
|
+
"schema": "local-coding-agent/benchmark-v1",
|
|
631
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
632
|
+
"repeats": args.benchmark_repeats,
|
|
633
|
+
"models": [],
|
|
634
|
+
}
|
|
635
|
+
for name in names:
|
|
636
|
+
benchmark_profile = get_profile(
|
|
637
|
+
name,
|
|
638
|
+
**overrides,
|
|
639
|
+
timeout_seconds=args.benchmark_timeout_seconds,
|
|
640
|
+
)
|
|
641
|
+
benchmark_client = build_client(benchmark_profile)
|
|
642
|
+
try:
|
|
643
|
+
available = benchmark_client.available_models()
|
|
644
|
+
available_names = {
|
|
645
|
+
item.get("name")
|
|
646
|
+
for item in available.get("models", [])
|
|
647
|
+
if isinstance(item, dict) and isinstance(item.get("name"), str)
|
|
648
|
+
}
|
|
649
|
+
if benchmark_profile.model not in available_names:
|
|
650
|
+
artifact["models"].append(
|
|
651
|
+
{
|
|
652
|
+
"profile": name,
|
|
653
|
+
"model": benchmark_profile.model,
|
|
654
|
+
"status": "unavailable",
|
|
655
|
+
"error": f"model is not present in Ollama /api/tags: {benchmark_profile.model}",
|
|
656
|
+
}
|
|
657
|
+
)
|
|
658
|
+
continue
|
|
659
|
+
model_info = next(
|
|
660
|
+
(
|
|
661
|
+
item
|
|
662
|
+
for item in available.get("models", [])
|
|
663
|
+
if isinstance(item, dict) and item.get("name") == benchmark_profile.model
|
|
664
|
+
),
|
|
665
|
+
{},
|
|
666
|
+
)
|
|
667
|
+
memory = ModelMemoryManager(benchmark_client)
|
|
668
|
+
memory_before = memory.snapshot().as_dict()
|
|
669
|
+
if memory_before["models"]:
|
|
670
|
+
memory.unload_all()
|
|
671
|
+
if getattr(args, "ladder", False):
|
|
672
|
+
from ..capability import CapabilityLadder
|
|
673
|
+
ladder = CapabilityLadder()
|
|
674
|
+
ladder_vec = ladder.evaluate(name, benchmark_client, max_turns=args.max_turns)
|
|
675
|
+
run = {
|
|
676
|
+
"profile": benchmark_profile.__dict__,
|
|
677
|
+
"status": "completed",
|
|
678
|
+
"capability_ladder": ladder_vec.as_dict(),
|
|
679
|
+
}
|
|
680
|
+
else:
|
|
681
|
+
run = run_benchmark(
|
|
682
|
+
name,
|
|
683
|
+
benchmark_client,
|
|
684
|
+
repeats=args.benchmark_repeats,
|
|
685
|
+
max_turns=args.max_turns,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
run["profile"] = benchmark_profile.__dict__
|
|
689
|
+
run["status"] = "completed"
|
|
690
|
+
run["memory_before"] = memory_before
|
|
691
|
+
run["memory_after"] = memory.snapshot().as_dict()
|
|
692
|
+
run["ollama_model_info"] = {
|
|
693
|
+
key: model_info.get(key)
|
|
694
|
+
for key in ("name", "size", "digest", "details", "capabilities")
|
|
695
|
+
if key in model_info
|
|
696
|
+
}
|
|
697
|
+
artifact["models"].append(run)
|
|
698
|
+
except OllamaError as error:
|
|
699
|
+
artifact["models"].append(
|
|
700
|
+
{
|
|
701
|
+
"profile": name,
|
|
702
|
+
"model": benchmark_profile.model,
|
|
703
|
+
"status": "unavailable",
|
|
704
|
+
"error": {"kind": error.kind, "message": str(error)},
|
|
705
|
+
}
|
|
706
|
+
)
|
|
707
|
+
write_artifact(args.benchmark_output, artifact)
|
|
708
|
+
if getattr(args, "ladder", False) and not getattr(args, "json", False):
|
|
709
|
+
for m in artifact["models"]:
|
|
710
|
+
if m.get("status") == "completed" and "capability_ladder" in m:
|
|
711
|
+
lad = m["capability_ladder"]
|
|
712
|
+
print("\n" + "=" * 64)
|
|
713
|
+
print(f" Capability Ladder — {lad['model']}")
|
|
714
|
+
print(f" Overall Tier {lad['overall_tier']}: {lad['tier_label']}")
|
|
715
|
+
print("=" * 64)
|
|
716
|
+
print(f" Granularity: {lad['granularity_tolerance']} | Gen Speed: {lad['tps_generation']} tok/s")
|
|
717
|
+
print(f" Confidence CI95: {lad['confidence_95_ci']} | Correctness: {lad['correctness_percent']}%")
|
|
718
|
+
print("-" * 64)
|
|
719
|
+
for t_idx, t_data in sorted(lad.get("tested_tiers", {}).items()):
|
|
720
|
+
mark = "[PASS]" if t_data.get("status") == "passed" else "[FAIL]"
|
|
721
|
+
print(f" Tier {t_idx} ({t_data.get('label')}): {mark} {t_data.get('passed_cases')}/{t_data.get('total_cases')} ({t_data.get('score_percent')}%)")
|
|
722
|
+
print("=" * 64 + "\n")
|
|
723
|
+
else:
|
|
724
|
+
print(json.dumps(artifact, ensure_ascii=False, indent=2))
|
|
725
|
+
return 0 if artifact["models"] and all(item["status"] == "completed" for item in artifact["models"]) else 1
|