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,276 @@
|
|
|
1
|
+
"""Rolling context window: compaction of long chat histories for bounded models.
|
|
2
|
+
|
|
3
|
+
Local models run with a fixed ``num_ctx``; a long multi-turn session eventually
|
|
4
|
+
produces a prompt larger than that window and the request dies. This module
|
|
5
|
+
implements the borrowed "rolling window" pattern:
|
|
6
|
+
|
|
7
|
+
- Evict WHOLE turns oldest-first. A turn starts at a ``user`` message and
|
|
8
|
+
includes everything up to (not including) the next ``user`` message. The
|
|
9
|
+
newest message is never dropped.
|
|
10
|
+
- Insert a single visible system notice describing the omission, so the model
|
|
11
|
+
knows earlier context was truncated rather than never existed.
|
|
12
|
+
- Support a STICKY boundary: callers persist ``next_sticky_boundary`` per
|
|
13
|
+
session and pass it back on the next call, so repeated compactions keep
|
|
14
|
+
dropping from the same frontier instead of creeping forward turn-by-turn
|
|
15
|
+
(which would shred llama-server's prefix cache).
|
|
16
|
+
- Leave headroom below the budget (see
|
|
17
|
+
``ROLLING_COMPACTION_HEADROOM_RATIO``) so the next turn does not immediately
|
|
18
|
+
re-trigger compaction.
|
|
19
|
+
|
|
20
|
+
All functions are pure: no I/O and no global state is mutated. Token counts
|
|
21
|
+
are honest character-based estimates (see :func:`estimate_tokens`), never
|
|
22
|
+
self-reported model numbers.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
ROLLING_COMPACTION_HEADROOM_RATIO = 0.9
|
|
29
|
+
"""Fraction of ``token_budget`` that :func:`fit_history` aims to fit under.
|
|
30
|
+
|
|
31
|
+
Compaction triggers when the history exceeds the full budget, but evicts only
|
|
32
|
+
until the compacted history fits ``int(token_budget * 0.9)``. The reserved
|
|
33
|
+
10% headroom absorbs the next user/assistant exchange so compaction does not
|
|
34
|
+
re-trigger on every single turn.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_CHARS_PER_TOKEN = 4
|
|
38
|
+
_UNBROKEN_ASCII_CHARS_PER_TOKEN = 2
|
|
39
|
+
_UNBROKEN_ASCII_RUN_MIN = 64
|
|
40
|
+
_MESSAGE_OVERHEAD_TOKENS = 4
|
|
41
|
+
|
|
42
|
+
_CJK_RANGES = (
|
|
43
|
+
(0x3000, 0x303F), # CJK symbols and punctuation
|
|
44
|
+
(0x3040, 0x309F), # Hiragana
|
|
45
|
+
(0x30A0, 0x30FF), # Katakana
|
|
46
|
+
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
|
|
47
|
+
(0x4E00, 0x9FFF), # CJK Unified Ideographs
|
|
48
|
+
(0xAC00, 0xD7AF), # Hangul syllables
|
|
49
|
+
(0xF900, 0xFAFF), # CJK Compatibility Ideographs
|
|
50
|
+
(0xFF00, 0xFFEF), # Fullwidth forms
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_cjk(ch: str) -> bool:
|
|
55
|
+
cp = ord(ch)
|
|
56
|
+
return any(lo <= cp <= hi for lo, hi in _CJK_RANGES)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def estimate_tokens(text: str) -> int:
|
|
60
|
+
"""Honest char-based token estimate for a single string.
|
|
61
|
+
|
|
62
|
+
- Normal text (short ASCII runs, non-CJK characters): ~4 chars/token.
|
|
63
|
+
- CJK ideographs/kana/hangul/fullwidth punctuation: ~1 token/char.
|
|
64
|
+
- An unbroken ASCII run (no whitespace) of >= 64 chars — hashes, base64,
|
|
65
|
+
minified blobs — is priced at ~2 chars/token.
|
|
66
|
+
|
|
67
|
+
Runs are broken by whitespace, so ordinary prose with spaces stays at
|
|
68
|
+
~4 chars/token. Returns the ceiling of the accumulated weight; empty
|
|
69
|
+
strings cost 0.
|
|
70
|
+
"""
|
|
71
|
+
if not text:
|
|
72
|
+
return 0
|
|
73
|
+
total_weight = 0.0
|
|
74
|
+
n = len(text)
|
|
75
|
+
i = 0
|
|
76
|
+
while i < n:
|
|
77
|
+
ch = text[i]
|
|
78
|
+
if ch.isascii() and not ch.isspace():
|
|
79
|
+
j = i
|
|
80
|
+
while j < n and text[j].isascii() and not text[j].isspace():
|
|
81
|
+
j += 1
|
|
82
|
+
run_len = j - i
|
|
83
|
+
if run_len >= _UNBROKEN_ASCII_RUN_MIN:
|
|
84
|
+
total_weight += run_len / _UNBROKEN_ASCII_CHARS_PER_TOKEN
|
|
85
|
+
else:
|
|
86
|
+
total_weight += run_len / _CHARS_PER_TOKEN
|
|
87
|
+
i = j
|
|
88
|
+
elif _is_cjk(ch):
|
|
89
|
+
total_weight += 1.0
|
|
90
|
+
i += 1
|
|
91
|
+
else: # whitespace and other non-CJK characters
|
|
92
|
+
total_weight += 1.0 / _CHARS_PER_TOKEN
|
|
93
|
+
i += 1
|
|
94
|
+
return math.ceil(total_weight)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def estimate_messages_tokens(messages: list[dict[str, Any]]) -> int:
|
|
98
|
+
"""Estimate the prompt tokens for an OpenAI-style message list.
|
|
99
|
+
|
|
100
|
+
Sum of :func:`estimate_tokens` over each message's content plus a small
|
|
101
|
+
per-message overhead constant (~4 tokens) covering role framing. Non-str
|
|
102
|
+
content is coerced with ``str(content)`` for estimation only; callers must
|
|
103
|
+
coerce identically at send time or treat this as a lower bound.
|
|
104
|
+
"""
|
|
105
|
+
return sum(
|
|
106
|
+
estimate_tokens(str(message.get("content", ""))) + _MESSAGE_OVERHEAD_TOKENS
|
|
107
|
+
for message in messages
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _compaction_notice(dropped_count: int) -> str:
|
|
112
|
+
return (
|
|
113
|
+
f"[context compacted: {dropped_count} earlier message(s) omitted "
|
|
114
|
+
f"to fit the model context window]"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _message_cost(message: dict[str, Any]) -> int:
|
|
119
|
+
return estimate_tokens(str(message.get("content", ""))) + _MESSAGE_OVERHEAD_TOKENS
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _plan_eviction(
|
|
123
|
+
messages: list[dict[str, Any]], sticky_boundary: int
|
|
124
|
+
) -> tuple[set[int], list[tuple[int, int]]]:
|
|
125
|
+
"""Plan this run's eviction.
|
|
126
|
+
|
|
127
|
+
Returns ``(dead_indices, units)``:
|
|
128
|
+
|
|
129
|
+
- ``dead_indices``: messages at ``[1, frontier)`` already compacted away by
|
|
130
|
+
earlier runs (sticky replay of the full transcript). They are omitted
|
|
131
|
+
from the output for free and are NOT counted in ``dropped_count``.
|
|
132
|
+
- ``units``: half-open index ranges evictable THIS run, oldest first. A
|
|
133
|
+
unit is a whole turn (starts at a ``user`` message, runs to the next
|
|
134
|
+
``user`` message) with ``start >= max(1, sticky_boundary)``; a turn
|
|
135
|
+
containing the last message contributes only its prefix
|
|
136
|
+
``[start, last_index)`` so the final message is never touched. Index 0
|
|
137
|
+
is always protected.
|
|
138
|
+
|
|
139
|
+
If nothing is droppable at/after the sticky boundary, the boundary is
|
|
140
|
+
abandoned: ``dead_indices`` is emptied and units are rebuilt from index 1
|
|
141
|
+
(the protected region becomes ordinary evictable content).
|
|
142
|
+
"""
|
|
143
|
+
last_idx = len(messages) - 1
|
|
144
|
+
unit_floor = max(1, sticky_boundary)
|
|
145
|
+
frontier = min(unit_floor, max(last_idx, 1))
|
|
146
|
+
first_user = next(
|
|
147
|
+
(i for i, m in enumerate(messages) if m.get("role") == "user"), None
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def collect(floor: int) -> list[tuple[int, int]]:
|
|
151
|
+
if first_user is None:
|
|
152
|
+
return []
|
|
153
|
+
starts = [first_user] + [
|
|
154
|
+
i
|
|
155
|
+
for i in range(first_user + 1, len(messages))
|
|
156
|
+
if messages[i].get("role") == "user"
|
|
157
|
+
]
|
|
158
|
+
units: list[tuple[int, int]] = []
|
|
159
|
+
for pos, start in enumerate(starts):
|
|
160
|
+
end = starts[pos + 1] if pos + 1 < len(starts) else len(messages)
|
|
161
|
+
if end <= last_idx:
|
|
162
|
+
if start >= floor:
|
|
163
|
+
units.append((start, end))
|
|
164
|
+
else: # turn contains the last message: only its prefix is evictable
|
|
165
|
+
p_start = max(start, floor)
|
|
166
|
+
if p_start < last_idx:
|
|
167
|
+
units.append((p_start, last_idx))
|
|
168
|
+
units.sort()
|
|
169
|
+
return units
|
|
170
|
+
|
|
171
|
+
units = collect(unit_floor)
|
|
172
|
+
if units:
|
|
173
|
+
return set(range(1, frontier)), units
|
|
174
|
+
return set(), collect(1)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def fit_history(
|
|
178
|
+
messages: list[dict[str, Any]],
|
|
179
|
+
token_budget: int,
|
|
180
|
+
sticky_boundary: int = 0,
|
|
181
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
182
|
+
"""Compact a chat history to fit ``token_budget`` estimated tokens.
|
|
183
|
+
|
|
184
|
+
Semantics:
|
|
185
|
+
- ``token_budget <= 0`` raises ``ValueError``. If even system+last exceed
|
|
186
|
+
the budget, still returns the best-effort result (system?, notice, last)
|
|
187
|
+
with an honest (possibly over-budget) ``estimated_tokens``; never raises
|
|
188
|
+
beyond the budget-validation error.
|
|
189
|
+
- Never drops ``messages[0]`` when its role is ``"system"`` (index 0 is
|
|
190
|
+
protected unconditionally) and never drops the last message.
|
|
191
|
+
- If the whole history already fits the budget, returns ``(copy, info)``
|
|
192
|
+
unchanged with ``compacted=False``.
|
|
193
|
+
- Otherwise drops whole turns (a turn starts at a ``user`` message and runs
|
|
194
|
+
to the next ``user`` message) oldest-first, beginning after index 0 and,
|
|
195
|
+
when ``sticky_boundary > 0``, only from indices ``>= sticky_boundary``;
|
|
196
|
+
if nothing droppable exists at/after the sticky boundary, falls back to
|
|
197
|
+
dropping from index 1. Eviction stops once the projected result fits
|
|
198
|
+
``int(token_budget * ROLLING_COMPACTION_HEADROOM_RATIO)``; if everything
|
|
199
|
+
evictable was dropped and it still does not fit, the best-effort result
|
|
200
|
+
is returned anyway.
|
|
201
|
+
- Sticky replay: messages at indices ``[1, sticky_frontier)`` of the input
|
|
202
|
+
were already compacted away by earlier runs (the caller replays the full
|
|
203
|
+
transcript together with the persisted boundary). They are omitted from
|
|
204
|
+
the output for free — not counted in ``dropped_count`` and not mentioned
|
|
205
|
+
by this run's notice. The frontier never covers the last message.
|
|
206
|
+
- When messages were dropped, exactly one system-role notice is inserted
|
|
207
|
+
right after the surviving system message (or at index 0 if there is none).
|
|
208
|
+
|
|
209
|
+
Non-str content is coerced with ``str(content)`` for estimation; original
|
|
210
|
+
message objects are preserved untouched (the returned list is a shallow
|
|
211
|
+
copy plus, if compacting, one new notice dict).
|
|
212
|
+
|
|
213
|
+
``info`` keys:
|
|
214
|
+
- ``dropped_count``: number of messages evicted THIS run (whole turns /
|
|
215
|
+
final-turn prefixes), excluding free omissions below the sticky frontier.
|
|
216
|
+
- ``next_sticky_boundary``: defined EXACTLY as
|
|
217
|
+
``sticky_boundary + dropped_count``. It is an index into the ORIGINAL
|
|
218
|
+
message list marking where future compactions should start dropping:
|
|
219
|
+
every index below it has been omitted or evicted by some run so far.
|
|
220
|
+
Persist it per session (e.g. alongside the stored transcript) and pass
|
|
221
|
+
it back as ``sticky_boundary`` on the next call; without it the eviction
|
|
222
|
+
frontier creeps forward each turn and destroys llama-server's prefix
|
|
223
|
+
cache.
|
|
224
|
+
- ``estimated_tokens``: honest estimate of the returned list (notice
|
|
225
|
+
included), recomputed on the final result.
|
|
226
|
+
- ``compacted``: True iff any message was dropped or omitted.
|
|
227
|
+
"""
|
|
228
|
+
if token_budget <= 0:
|
|
229
|
+
raise ValueError("token_budget must be a positive integer")
|
|
230
|
+
costs = [_message_cost(m) for m in messages]
|
|
231
|
+
total = sum(costs)
|
|
232
|
+
if total <= token_budget:
|
|
233
|
+
return list(messages), {
|
|
234
|
+
"dropped_count": 0,
|
|
235
|
+
"next_sticky_boundary": sticky_boundary,
|
|
236
|
+
"estimated_tokens": total,
|
|
237
|
+
"compacted": False,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
dead, units = _plan_eviction(messages, sticky_boundary)
|
|
241
|
+
live_total = total - sum(costs[i] for i in dead)
|
|
242
|
+
target = int(token_budget * ROLLING_COMPACTION_HEADROOM_RATIO)
|
|
243
|
+
dropped_indices: set[int] = set()
|
|
244
|
+
dropped_tokens = 0
|
|
245
|
+
dropped_count = 0
|
|
246
|
+
for start, end in units:
|
|
247
|
+
dropped_indices.update(range(start, end))
|
|
248
|
+
dropped_tokens += sum(costs[start:end])
|
|
249
|
+
dropped_count += end - start
|
|
250
|
+
notice_cost = (
|
|
251
|
+
estimate_tokens(_compaction_notice(dropped_count))
|
|
252
|
+
+ _MESSAGE_OVERHEAD_TOKENS
|
|
253
|
+
)
|
|
254
|
+
if live_total - dropped_tokens + notice_cost <= target:
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
if not dead and not dropped_indices:
|
|
258
|
+
# Over budget but nothing is evictable (e.g. [system, last] alone).
|
|
259
|
+
return list(messages), {
|
|
260
|
+
"dropped_count": 0,
|
|
261
|
+
"next_sticky_boundary": sticky_boundary,
|
|
262
|
+
"estimated_tokens": total,
|
|
263
|
+
"compacted": False,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
skip = dead | dropped_indices
|
|
267
|
+
result = [m for i, m in enumerate(messages) if i not in skip]
|
|
268
|
+
notice_msg = {"role": "system", "content": _compaction_notice(dropped_count)}
|
|
269
|
+
insert_at = 1 if messages[0].get("role") == "system" else 0
|
|
270
|
+
result.insert(insert_at, notice_msg)
|
|
271
|
+
return result, {
|
|
272
|
+
"dropped_count": dropped_count,
|
|
273
|
+
"next_sticky_boundary": sticky_boundary + dropped_count,
|
|
274
|
+
"estimated_tokens": estimate_messages_tokens(result),
|
|
275
|
+
"compacted": True,
|
|
276
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Command-line entry point for local-coding-agent.
|
|
2
|
+
|
|
3
|
+
Refactored from a single module into a package. The full public API is
|
|
4
|
+
re-exported here so existing imports keep working.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._main import main
|
|
8
|
+
from ._parser import build_parser
|
|
9
|
+
from ._dispatch import handle_subcommand
|
|
10
|
+
from ._input import load_task_input, load_task_file
|
|
11
|
+
|
|
12
|
+
__all__ = ["main", "build_parser", "handle_subcommand", "load_task_input", "load_task_file"]
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Subcommand dispatch and remaining handlers for the local-coding-agent CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ._handlers import (
|
|
11
|
+
_handle_apply,
|
|
12
|
+
_handle_calibrate,
|
|
13
|
+
_handle_decompose,
|
|
14
|
+
_handle_delegate,
|
|
15
|
+
_handle_memory,
|
|
16
|
+
_handle_profiles,
|
|
17
|
+
)
|
|
18
|
+
from ._handlers2 import (
|
|
19
|
+
_handle_benchmark,
|
|
20
|
+
_handle_chat,
|
|
21
|
+
_handle_desktop,
|
|
22
|
+
_handle_doctor,
|
|
23
|
+
_handle_init_mcp,
|
|
24
|
+
_handle_monitor,
|
|
25
|
+
_handle_scan_models,
|
|
26
|
+
_handle_serve_acp,
|
|
27
|
+
_handle_serve_mcp,
|
|
28
|
+
_handle_sessions,
|
|
29
|
+
_handle_skill,
|
|
30
|
+
_handle_smoke,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _handle_skeletonize(args: argparse.Namespace) -> int:
|
|
35
|
+
from ..ast_compactor import skeletonize_file
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
skeleton = skeletonize_file(str(args.file), target_symbols=args.symbols)
|
|
39
|
+
if getattr(args, "json", False):
|
|
40
|
+
print(
|
|
41
|
+
json.dumps(
|
|
42
|
+
{"file": str(args.file), "symbols": args.symbols, "skeleton": skeleton},
|
|
43
|
+
ensure_ascii=False,
|
|
44
|
+
indent=2,
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
try:
|
|
49
|
+
print(skeleton)
|
|
50
|
+
except UnicodeEncodeError:
|
|
51
|
+
if hasattr(sys.stdout, "buffer"):
|
|
52
|
+
sys.stdout.buffer.write(skeleton.encode("utf-8", errors="replace"))
|
|
53
|
+
sys.stdout.buffer.write(b"\n")
|
|
54
|
+
sys.stdout.buffer.flush()
|
|
55
|
+
else:
|
|
56
|
+
print(skeleton.encode("ascii", errors="replace").decode("ascii"))
|
|
57
|
+
return 0
|
|
58
|
+
except Exception as error:
|
|
59
|
+
if getattr(args, "json", False):
|
|
60
|
+
print(
|
|
61
|
+
json.dumps(
|
|
62
|
+
{"status": "failed", "error": str(error)},
|
|
63
|
+
ensure_ascii=False,
|
|
64
|
+
indent=2,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
print(f"Error skeletonizing file: {error}", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _handle_lint_patch(args: argparse.Namespace) -> int:
|
|
73
|
+
from ..semantic_linter import lint_patch_in_memory
|
|
74
|
+
|
|
75
|
+
patch_str = args.patch
|
|
76
|
+
if args.patch_file:
|
|
77
|
+
patch_str = Path(args.patch_file).read_text(encoding="utf-8-sig", errors="replace")
|
|
78
|
+
if not patch_str:
|
|
79
|
+
if getattr(args, "json", False):
|
|
80
|
+
print(
|
|
81
|
+
json.dumps(
|
|
82
|
+
{"valid": False, "error": "No patch specified"},
|
|
83
|
+
ensure_ascii=False,
|
|
84
|
+
indent=2,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
print("Error: No patch specified via --patch or --patch-file", file=sys.stderr)
|
|
89
|
+
return 2
|
|
90
|
+
report = lint_patch_in_memory(str(args.workspace), patch_str)
|
|
91
|
+
if getattr(args, "json", False):
|
|
92
|
+
print(
|
|
93
|
+
json.dumps(
|
|
94
|
+
{
|
|
95
|
+
"valid": report.valid,
|
|
96
|
+
"diagnostics": [
|
|
97
|
+
{"file": d.file, "line": d.line, "message": d.message, "rule": d.rule}
|
|
98
|
+
for d in report.diagnostics
|
|
99
|
+
],
|
|
100
|
+
"prescriptions": list(report.prescriptions),
|
|
101
|
+
},
|
|
102
|
+
ensure_ascii=False,
|
|
103
|
+
indent=2,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
if report.valid:
|
|
108
|
+
print("[OK] Patch passed fast static linter gates without issues.")
|
|
109
|
+
else:
|
|
110
|
+
print("[FAIL] Patch failed static linter gates:")
|
|
111
|
+
for p in report.prescriptions:
|
|
112
|
+
print(f" - {p}")
|
|
113
|
+
return 0 if report.valid else 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _handle_spill_read(args: argparse.Namespace) -> int:
|
|
117
|
+
from ..spill import read_spill
|
|
118
|
+
|
|
119
|
+
loc = args.opt_locator or args.locator
|
|
120
|
+
if not loc:
|
|
121
|
+
if getattr(args, "json", False):
|
|
122
|
+
print(json.dumps({"status": "failed", "error": "Spill locator is required"}, ensure_ascii=False, indent=2))
|
|
123
|
+
else:
|
|
124
|
+
print("Error: spill locator is required", file=sys.stderr)
|
|
125
|
+
return 2
|
|
126
|
+
try:
|
|
127
|
+
content = read_spill(loc, offset_line=args.offset, limit_lines=args.limit)
|
|
128
|
+
if getattr(args, "json", False):
|
|
129
|
+
print(
|
|
130
|
+
json.dumps(
|
|
131
|
+
{"locator": loc, "offset": args.offset, "limit": args.limit, "content": content},
|
|
132
|
+
ensure_ascii=False,
|
|
133
|
+
indent=2,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
try:
|
|
138
|
+
print(content)
|
|
139
|
+
except UnicodeEncodeError:
|
|
140
|
+
if hasattr(sys.stdout, "buffer"):
|
|
141
|
+
sys.stdout.buffer.write(content.encode("utf-8", errors="replace"))
|
|
142
|
+
sys.stdout.buffer.write(b"\n")
|
|
143
|
+
sys.stdout.buffer.flush()
|
|
144
|
+
else:
|
|
145
|
+
print(content.encode("ascii", errors="replace").decode("ascii"))
|
|
146
|
+
return 0
|
|
147
|
+
except Exception as error:
|
|
148
|
+
if getattr(args, "json", False):
|
|
149
|
+
print(json.dumps({"status": "failed", "error": str(error)}, ensure_ascii=False, indent=2))
|
|
150
|
+
else:
|
|
151
|
+
print(f"Error reading spill: {error}", file=sys.stderr)
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _handle_grep(args: argparse.Namespace) -> int:
|
|
156
|
+
from ..ripgrep import ripgrep_search
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
globs = args.paths if args.paths else None
|
|
160
|
+
matches = ripgrep_search(
|
|
161
|
+
args.query,
|
|
162
|
+
root=args.workspace,
|
|
163
|
+
globs=globs,
|
|
164
|
+
is_regex=args.regex,
|
|
165
|
+
case_sensitive=args.case_sensitive,
|
|
166
|
+
max_results=args.max_results,
|
|
167
|
+
)
|
|
168
|
+
if getattr(args, "json", False):
|
|
169
|
+
print(
|
|
170
|
+
json.dumps(
|
|
171
|
+
{
|
|
172
|
+
"query": args.query,
|
|
173
|
+
"count": len(matches),
|
|
174
|
+
"matches": [
|
|
175
|
+
{"file": m.file, "line": m.line_number, "text": m.line_content}
|
|
176
|
+
for m in matches
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
ensure_ascii=False,
|
|
180
|
+
indent=2,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
for m in matches:
|
|
185
|
+
print(f"{m.file}:{m.line_number}: {m.line_content}")
|
|
186
|
+
return 0
|
|
187
|
+
except Exception as error:
|
|
188
|
+
if getattr(args, "json", False):
|
|
189
|
+
print(json.dumps({"status": "failed", "error": str(error)}, ensure_ascii=False, indent=2))
|
|
190
|
+
else:
|
|
191
|
+
print(f"Error during grep: {error}", file=sys.stderr)
|
|
192
|
+
return 1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _handle_lsp(args: argparse.Namespace) -> int:
|
|
196
|
+
from ..lsp import LspManager, LspPosition
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
manager = LspManager(workspace_root=args.workspace)
|
|
200
|
+
if args.operation == "definition":
|
|
201
|
+
res = manager.go_to_definition(args.file, line=args.line, character=args.char, workspace_root=args.workspace)
|
|
202
|
+
data = [r.to_dict() for r in res]
|
|
203
|
+
elif args.operation == "references":
|
|
204
|
+
res = manager.find_references(args.file, line=args.line, character=args.char, workspace_root=args.workspace)
|
|
205
|
+
data = [r.to_dict() for r in res]
|
|
206
|
+
elif args.operation == "hover":
|
|
207
|
+
h_res = manager.hover(args.file, line=args.line, character=args.char, workspace_root=args.workspace)
|
|
208
|
+
data = h_res.to_dict() if h_res else None
|
|
209
|
+
elif args.operation == "symbols":
|
|
210
|
+
syms = manager.document_symbols(args.file, workspace_root=args.workspace)
|
|
211
|
+
data = [s.to_dict() for s in syms]
|
|
212
|
+
else:
|
|
213
|
+
data = None
|
|
214
|
+
|
|
215
|
+
if getattr(args, "json", False):
|
|
216
|
+
print(
|
|
217
|
+
json.dumps(
|
|
218
|
+
{"operation": args.operation, "file": str(args.file), "result": data},
|
|
219
|
+
ensure_ascii=False,
|
|
220
|
+
indent=2,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
225
|
+
return 0
|
|
226
|
+
except Exception as error:
|
|
227
|
+
if getattr(args, "json", False):
|
|
228
|
+
print(json.dumps({"status": "failed", "error": str(error)}, ensure_ascii=False, indent=2))
|
|
229
|
+
else:
|
|
230
|
+
print(f"Error during LSP query: {error}", file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _handle_init_agent(args: argparse.Namespace) -> int:
|
|
235
|
+
import os
|
|
236
|
+
|
|
237
|
+
from ..profiles import get_profile
|
|
238
|
+
|
|
239
|
+
base_url = args.desktop_url.rstrip("/")
|
|
240
|
+
model = getattr(args, "model", None)
|
|
241
|
+
if not model:
|
|
242
|
+
try:
|
|
243
|
+
profile = get_profile(os.environ.get("LCA_PROFILE") or "qwen2.5-coder")
|
|
244
|
+
raw = str(getattr(profile, "model", "") or "qwen2.5-coder")
|
|
245
|
+
model = raw if ":" in raw else f"{raw}:latest"
|
|
246
|
+
except Exception:
|
|
247
|
+
model = "qwen2.5-coder:latest"
|
|
248
|
+
|
|
249
|
+
def emit(payload: dict, human: list[str], code: int = 0) -> int:
|
|
250
|
+
if getattr(args, "json", False):
|
|
251
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
252
|
+
else:
|
|
253
|
+
print("\n".join(human))
|
|
254
|
+
return code
|
|
255
|
+
|
|
256
|
+
if args.agent == "codex":
|
|
257
|
+
toml_block = (
|
|
258
|
+
"[model_providers.local_agent]\n"
|
|
259
|
+
f'name = "Local Coding Agent (Desktop Harness)"\n'
|
|
260
|
+
f'base_url = "{base_url}/v1"\n'
|
|
261
|
+
'env_key = "LOCAL_AGENT_API_KEY"\n'
|
|
262
|
+
'wire_api = "chat"\n'
|
|
263
|
+
)
|
|
264
|
+
launch_cmd = (
|
|
265
|
+
f'codex -c model_provider=local_agent -m "{model}" '
|
|
266
|
+
"(set LOCAL_AGENT_API_KEY first; the Desktop Harness ignores its value)"
|
|
267
|
+
)
|
|
268
|
+
env_hint = (
|
|
269
|
+
f'PowerShell: $env:LOCAL_AGENT_API_KEY="local" | sh/bash: export LOCAL_AGENT_API_KEY=local'
|
|
270
|
+
)
|
|
271
|
+
config_path = None
|
|
272
|
+
written = False
|
|
273
|
+
error_msg = None
|
|
274
|
+
if args.write:
|
|
275
|
+
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
276
|
+
config_path = codex_home / "config.toml"
|
|
277
|
+
try:
|
|
278
|
+
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
|
279
|
+
if "[model_providers.local_agent]" in existing:
|
|
280
|
+
return emit(
|
|
281
|
+
{"status": "failed", "error": f"{config_path} already defines [model_providers.local_agent]; edit it manually."},
|
|
282
|
+
[f"[REFUSED] {config_path} already defines [model_providers.local_agent]. Edit it manually instead of duplicating."],
|
|
283
|
+
1,
|
|
284
|
+
)
|
|
285
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
with open(config_path, "a", encoding="utf-8") as fh:
|
|
287
|
+
if existing and not existing.endswith("\n"):
|
|
288
|
+
fh.write("\n")
|
|
289
|
+
fh.write(toml_block)
|
|
290
|
+
written = True
|
|
291
|
+
except OSError as os_error:
|
|
292
|
+
return emit(
|
|
293
|
+
{"status": "failed", "error": str(os_error)},
|
|
294
|
+
[f"[FAILED] Could not write {config_path}: {os_error}"],
|
|
295
|
+
1,
|
|
296
|
+
)
|
|
297
|
+
payload = {
|
|
298
|
+
"status": "ok",
|
|
299
|
+
"agent": "codex",
|
|
300
|
+
"model": model,
|
|
301
|
+
"base_url": f"{base_url}/v1",
|
|
302
|
+
"config_path": str(config_path) if config_path else None,
|
|
303
|
+
"written": written,
|
|
304
|
+
"config_snippet": toml_block,
|
|
305
|
+
"launch_command": launch_cmd,
|
|
306
|
+
}
|
|
307
|
+
human = [
|
|
308
|
+
f"--- Codex -> Local Agent ({'written' if written else 'preview'}) ---",
|
|
309
|
+
toml_block,
|
|
310
|
+
f"Launch: {launch_cmd}",
|
|
311
|
+
f"Auth: {env_hint}",
|
|
312
|
+
f"Prereq: start the harness first (`local-agent desktop`), then load a model in Local Inference Servers.",
|
|
313
|
+
]
|
|
314
|
+
if config_path:
|
|
315
|
+
human.insert(1, f"Config: {config_path}")
|
|
316
|
+
return emit(payload, human)
|
|
317
|
+
|
|
318
|
+
# generic OpenAI-compatible target
|
|
319
|
+
payload = {
|
|
320
|
+
"status": "ok",
|
|
321
|
+
"agent": "generic",
|
|
322
|
+
"model": model,
|
|
323
|
+
"base_url": f"{base_url}/v1",
|
|
324
|
+
"env": {
|
|
325
|
+
"OPENAI_BASE_URL": f"{base_url}/v1",
|
|
326
|
+
"OPENAI_API_KEY": "local",
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
human = [
|
|
330
|
+
"--- Generic OpenAI-compatible client -> Local Agent ---",
|
|
331
|
+
f" OPENAI_BASE_URL={base_url}/v1",
|
|
332
|
+
" OPENAI_API_KEY=local # value ignored by the Desktop Harness",
|
|
333
|
+
f" Model id to request: {model}",
|
|
334
|
+
"Prereq: start the harness first (`local-agent desktop`), then load a model in Local Inference Servers.",
|
|
335
|
+
]
|
|
336
|
+
return emit(payload, human)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
_HANDLERS = {
|
|
340
|
+
"delegate": _handle_delegate,
|
|
341
|
+
"run": _handle_delegate,
|
|
342
|
+
"decompose": _handle_decompose,
|
|
343
|
+
"atomize": _handle_decompose,
|
|
344
|
+
"profiles": _handle_profiles,
|
|
345
|
+
"memory": _handle_memory,
|
|
346
|
+
"calibrate": _handle_calibrate,
|
|
347
|
+
"apply": _handle_apply,
|
|
348
|
+
"init-skill": _handle_skill,
|
|
349
|
+
"skill": _handle_skill,
|
|
350
|
+
"doctor": _handle_doctor,
|
|
351
|
+
"init-mcp": _handle_init_mcp,
|
|
352
|
+
"test-run": _handle_smoke,
|
|
353
|
+
"smoke": _handle_smoke,
|
|
354
|
+
"serve-mcp": _handle_serve_mcp,
|
|
355
|
+
"serve-acp": _handle_serve_acp,
|
|
356
|
+
"monitor": _handle_monitor,
|
|
357
|
+
"ui": _handle_monitor,
|
|
358
|
+
"app": _handle_monitor,
|
|
359
|
+
"desktop": _handle_desktop,
|
|
360
|
+
"chat": _handle_chat,
|
|
361
|
+
"scan-models": _handle_scan_models,
|
|
362
|
+
"benchmark": _handle_benchmark,
|
|
363
|
+
"skeletonize": _handle_skeletonize,
|
|
364
|
+
"lint-patch": _handle_lint_patch,
|
|
365
|
+
"spill-read": _handle_spill_read,
|
|
366
|
+
"grep": _handle_grep,
|
|
367
|
+
"lsp": _handle_lsp,
|
|
368
|
+
"sessions": _handle_sessions,
|
|
369
|
+
"init-agent": _handle_init_agent,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def handle_subcommand(args: argparse.Namespace) -> int:
|
|
374
|
+
handler = _HANDLERS.get(args.subcommand)
|
|
375
|
+
if handler is None:
|
|
376
|
+
return -1
|
|
377
|
+
return handler(args)
|