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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,320 @@
1
+ """Subcommand dispatch for the local-coding-agent CLI (part 1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ..atomizer import TaskBudget, decompose, preflight
13
+ from ..calibration import calibrate_for_model
14
+ from ..controller import Controller, mediated_apply
15
+ from ..delegator import BY_FILES, PER_FILE
16
+ from ..memory import ModelMemoryManager
17
+ from ..ollama_adapter import BACKEND_OFFLINE_HINT, OllamaError, build_client, classify_backend_error
18
+ from ..profiles import get_profile, list_profiles
19
+ from ..stats import append_stats, default_stats_path
20
+ from ..task import TaskEnvelope
21
+
22
+ from ._input import load_task_input
23
+
24
+ _BACKEND_HINTS = {
25
+ "backend_offline": BACKEND_OFFLINE_HINT,
26
+ "backend_error": "The model backend returned an error. Check backend logs or verify the model is installed (`ollama list`).",
27
+ }
28
+
29
+
30
+ def _annotate_backend_error(result: dict) -> None:
31
+ error = result.get("error")
32
+ if isinstance(error, dict):
33
+ hint = _BACKEND_HINTS.get(error.get("kind"))
34
+ if hint:
35
+ error["hint"] = hint
36
+
37
+
38
+ def _persist_proposal(result: dict, task_id: str) -> None:
39
+ """Write an accepted/candidate patch to disk so it survives the process."""
40
+ patch = result.get("patch")
41
+ if not isinstance(patch, str) or not patch.strip():
42
+ return
43
+ safe_id = "".join(c if c.isalnum() or c in "._-" else "_" for c in (task_id or "proposal"))[:80]
44
+ try:
45
+ proposals_dir = Path(".local-run") / "proposals"
46
+ proposals_dir.mkdir(parents=True, exist_ok=True)
47
+ proposal_path = proposals_dir / f"{safe_id}.patch"
48
+ proposal_path.write_text(patch, encoding="utf-8")
49
+ result["patch_file"] = str(proposal_path)
50
+ except OSError:
51
+ pass # ponytail: persistence is best-effort; never fail a finished run.
52
+
53
+
54
+ def _handle_delegate(args: argparse.Namespace) -> int:
55
+ started_ns = time.monotonic_ns()
56
+ try:
57
+ task = load_task_input(args.task, getattr(args, "task_file", None))
58
+ if getattr(args, "speculative_drafts", 1) > 1:
59
+ if args.apply:
60
+ # Each racer runs against the same checkout. Applying here
61
+ # would let losing drafts mutate files before a winner is
62
+ # selected; speculative mode is proposal-only until a single
63
+ # result is chosen and explicitly mediated elsewhere.
64
+ print(
65
+ json.dumps(
66
+ {
67
+ "status": "rejected",
68
+ "error": {
69
+ "kind": "speculative_apply_unsupported",
70
+ "message": "--apply cannot be combined with --speculative-drafts; speculative drafts are proposal-only",
71
+ },
72
+ "applied": False,
73
+ },
74
+ ensure_ascii=False,
75
+ indent=2,
76
+ )
77
+ )
78
+ return 1
79
+ from threading import Event
80
+ from ..speculative_racing import SpeculativeRacer
81
+
82
+ def _make_runner(draft_idx: int):
83
+ def _run(cancel_ev: Event) -> dict[str, Any]:
84
+ overrides: dict[str, Any] = {}
85
+ if args.endpoint:
86
+ overrides["endpoint"] = args.endpoint
87
+ if args.num_ctx is not None:
88
+ overrides["num_ctx"] = args.num_ctx
89
+ if getattr(args, "model", None):
90
+ overrides["model"] = args.model
91
+ temp = 0.0 if draft_idx == 0 else min(0.15 * draft_idx, 0.7)
92
+ overrides["temperature"] = temp
93
+ prof = get_profile(args.profile, **overrides)
94
+ cl = build_client(prof)
95
+ return Controller(
96
+ cl,
97
+ args.workspace,
98
+ max_turns=args.max_turns,
99
+ cancel_event=cancel_ev,
100
+ ).run(task, apply=False)
101
+
102
+ return _run
103
+
104
+ runners = [_make_runner(i) for i in range(args.speculative_drafts)]
105
+ racer = SpeculativeRacer()
106
+ result = racer.run(runners)
107
+ else:
108
+ overrides = {}
109
+ if args.endpoint:
110
+ overrides["endpoint"] = args.endpoint
111
+ if args.num_ctx is not None:
112
+ overrides["num_ctx"] = args.num_ctx
113
+ if getattr(args, "model", None):
114
+ overrides["model"] = args.model
115
+ profile = get_profile(args.profile, **overrides)
116
+ client = build_client(profile)
117
+ result = Controller(
118
+ client,
119
+ args.workspace,
120
+ max_turns=args.max_turns,
121
+ ).run(task, apply=args.apply)
122
+ except OllamaError as error:
123
+ # Normalize to the same vocabulary the controller model boundary uses.
124
+ kind = {
125
+ "offline": "backend_offline",
126
+ "server_error": "backend_error",
127
+ }.get(classify_backend_error(error) or "", "backend_error")
128
+ payload: dict[str, Any] = {
129
+ "status": "failed",
130
+ "error": {
131
+ "kind": kind,
132
+ "message": str(error),
133
+ },
134
+ }
135
+ if kind == "offline":
136
+ payload["error"]["hint"] = BACKEND_OFFLINE_HINT
137
+ append_stats(
138
+ default_stats_path(),
139
+ {"status": "failed", "error": {"kind": kind}},
140
+ model=getattr(args, "model", None) or args.profile,
141
+ )
142
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
143
+ return 2
144
+ except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error:
145
+ print(json.dumps({"status": "failed", "error": {"kind": "input", "message": str(error)}}, ensure_ascii=False, indent=2))
146
+ return 2
147
+ append_stats(
148
+ default_stats_path(),
149
+ result,
150
+ model=getattr(args, "model", None) or args.profile,
151
+ latency_ns=time.monotonic_ns() - started_ns,
152
+ )
153
+ _annotate_backend_error(result)
154
+ _persist_proposal(result, task.id)
155
+ print(json.dumps(result, ensure_ascii=False, indent=2))
156
+ return 0 if result.get("status") == "accepted" else 1
157
+
158
+
159
+ def _handle_decompose(args: argparse.Namespace) -> int:
160
+ try:
161
+ task = load_task_input(args.task, getattr(args, "task_file", None))
162
+ budget = TaskBudget(
163
+ max_files=args.budget_files,
164
+ max_context_bytes=args.budget_bytes,
165
+ max_checks=args.budget_checks,
166
+ )
167
+ pre = preflight(task, budget)
168
+ if args.strategy == "per_file":
169
+ children = PER_FILE.split(task, budget)
170
+ else:
171
+ children = BY_FILES.split(task, budget)
172
+ payload = {
173
+ "task_id": task.id,
174
+ "preflight": {
175
+ "accepted": pre.accepted,
176
+ "reason": pre.reason,
177
+ "issues": list(pre.issues),
178
+ },
179
+ "strategy": args.strategy,
180
+ "count": len(children),
181
+ "children": [c.as_dict() for c in children],
182
+ }
183
+ except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error:
184
+ print(json.dumps({"status": "failed", "error": {"kind": "input", "message": str(error)}}, ensure_ascii=False, indent=2))
185
+ return 2
186
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
187
+ return 0 if payload["children"] else 1
188
+
189
+
190
+ def _handle_profiles(args: argparse.Namespace) -> int:
191
+ action = getattr(args, "profile_action", "list") or "list"
192
+ if action == "get":
193
+ if not args.name:
194
+ print(json.dumps({"error": "profile name required for 'get'"}, ensure_ascii=False))
195
+ return 2
196
+ try:
197
+ prof = get_profile(args.name)
198
+ print(json.dumps(prof.__dict__, ensure_ascii=False, indent=2))
199
+ return 0
200
+ except ValueError as error:
201
+ print(json.dumps({"error": str(error)}, ensure_ascii=False))
202
+ return 1
203
+
204
+ # list
205
+ profile_names = list_profiles()
206
+ installed_map: dict[str, bool] = {}
207
+ if args.check_ollama:
208
+ try:
209
+ c = build_client(get_profile("qwen2.5-1.5b", endpoint=args.endpoint))
210
+ tags = c.available_models().get("models", [])
211
+ tag_names = {t.get("name") for t in tags if isinstance(t, dict) and "name" in t}
212
+ for p_name in profile_names:
213
+ p_obj = get_profile(p_name)
214
+ installed_map[p_name] = p_obj.model in tag_names
215
+ except Exception:
216
+ pass
217
+
218
+ items = []
219
+ for p_name in profile_names:
220
+ p_obj = get_profile(p_name)
221
+ item = {
222
+ "name": p_name,
223
+ "model": p_obj.model,
224
+ "provider": getattr(p_obj, "provider", "ollama"),
225
+ "num_ctx": p_obj.num_ctx,
226
+ "num_predict": p_obj.num_predict,
227
+ "max_context_length": p_obj.max_context_length,
228
+ "policy_context_cap": p_obj.max_context_length,
229
+ "model_context_limit": p_obj.model_context_limit,
230
+ "think": p_obj.think,
231
+ }
232
+ if args.check_ollama:
233
+ item["installed_locally"] = installed_map.get(p_name, False)
234
+ items.append(item)
235
+
236
+ if args.json:
237
+ print(json.dumps({"profiles": items}, ensure_ascii=False, indent=2))
238
+ else:
239
+ print(f"{'PROFILE':<25} {'MODEL':<35} {'CTX':<8} {'THINK':<6}")
240
+ print("-" * 76)
241
+ for it in items:
242
+ print(f"{it['name']:<25} {it['model']:<35} {it['num_ctx']:<8} {str(it['think']):<6}")
243
+ return 0
244
+
245
+
246
+ def _handle_memory(args: argparse.Namespace) -> int:
247
+ action = getattr(args, "memory_action", "status")
248
+ try:
249
+ profile = get_profile(args.profile, endpoint=args.endpoint)
250
+ client = build_client(profile)
251
+ manager = ModelMemoryManager(client)
252
+ if action == "unload":
253
+ if not args.model:
254
+ print(json.dumps({"error": "model name required for 'unload'"}, ensure_ascii=False))
255
+ return 2
256
+ snapshot = manager.unload_model(args.model)
257
+ elif action == "unload-all":
258
+ snapshot = manager.unload_all()
259
+ elif action == "enforce":
260
+ if args.limit is None:
261
+ print(json.dumps({"error": "--limit required for 'enforce'"}, ensure_ascii=False))
262
+ return 2
263
+ snapshot = manager.enforce_limit(args.limit, keep=tuple(args.keep or []))
264
+ else:
265
+ snapshot = manager.snapshot()
266
+ res = {"status": "memory_reconciled", "action": action, "memory": snapshot.as_dict()}
267
+ print(json.dumps(res, ensure_ascii=False, indent=2))
268
+ return 0
269
+ except Exception as error:
270
+ print(json.dumps({"status": "failed", "error": str(error)}, ensure_ascii=False, indent=2))
271
+ return 1
272
+
273
+
274
+ def _handle_calibrate(args: argparse.Namespace) -> int:
275
+ try:
276
+ overrides = {}
277
+ if args.endpoint:
278
+ overrides["endpoint"] = args.endpoint
279
+ profile = get_profile(args.profile, **overrides)
280
+ client = build_client(profile)
281
+ report = calibrate_for_model(
282
+ client,
283
+ profile.model,
284
+ vram_budget_bytes=args.vram_bytes,
285
+ per_worker_context_bytes=args.parallel_context_bytes,
286
+ )
287
+ print(json.dumps({"status": "calibrated", "report": report}, ensure_ascii=False, indent=2))
288
+ return 0
289
+ except Exception as error:
290
+ print(json.dumps({"status": "failed", "error": str(error)}, ensure_ascii=False, indent=2))
291
+ return 1
292
+
293
+
294
+ def _handle_apply(args: argparse.Namespace) -> int:
295
+ if args.patch_file:
296
+ patch = Path(args.patch_file).read_text(encoding="utf-8-sig")
297
+ elif args.patch:
298
+ patch = args.patch
299
+ else:
300
+ print(json.dumps({"error": "--patch or --patch-file is required"}, ensure_ascii=False))
301
+ return 2
302
+
303
+ workspace = Path(args.workspace).resolve()
304
+ try:
305
+ # TaskEnvelope owns the policy checks (non-empty, workspace-relative
306
+ # scope), so a caller mistake surfaces as one JSON error, not a traceback.
307
+ task = TaskEnvelope(
308
+ id="cli-apply",
309
+ goal="apply reviewed patch",
310
+ files=tuple(args.files),
311
+ checks=tuple(args.checks),
312
+ )
313
+ except ValueError as error:
314
+ res = {"status": "rejected", "error": {"kind": "apply_policy", "message": str(error)}, "applied": False}
315
+ print(json.dumps(res, ensure_ascii=False, indent=2))
316
+ return 1
317
+
318
+ result = mediated_apply(workspace, patch, task)
319
+ print(json.dumps(result, ensure_ascii=False, indent=2))
320
+ return 0 if result.get("applied") is True else 1