localcode 0.3.5__py3-none-any.whl → 0.3.7__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.
- localcode/__init__.py +1 -1
- localcode/agent/loop.py +21 -28
- localcode/agent/recovery.py +22 -19
- localcode/agent/streaming.py +8 -59
- localcode/tools/edit_file.py +10 -2
- localcode/tools/multi_edit.py +10 -2
- localcode/tools/read_file.py +41 -1
- localcode/tools/syntax_check.py +133 -0
- localcode/tools/write_file.py +14 -2
- {localcode-0.3.5.dist-info → localcode-0.3.7.dist-info}/METADATA +1 -1
- {localcode-0.3.5.dist-info → localcode-0.3.7.dist-info}/RECORD +15 -14
- {localcode-0.3.5.dist-info → localcode-0.3.7.dist-info}/WHEEL +0 -0
- {localcode-0.3.5.dist-info → localcode-0.3.7.dist-info}/entry_points.txt +0 -0
- {localcode-0.3.5.dist-info → localcode-0.3.7.dist-info}/licenses/LICENSE +0 -0
- {localcode-0.3.5.dist-info → localcode-0.3.7.dist-info}/top_level.txt +0 -0
localcode/__init__.py
CHANGED
localcode/agent/loop.py
CHANGED
|
@@ -40,6 +40,7 @@ __all__ = ["run_agent_loop"]
|
|
|
40
40
|
|
|
41
41
|
from .constants import (
|
|
42
42
|
MAX_ROUNDS,
|
|
43
|
+
CHURN_FILE_WRITE_LIMIT,
|
|
43
44
|
MAX_OUTPUT_TOKENS,
|
|
44
45
|
MAX_THINKING_SECONDS,
|
|
45
46
|
MAX_THINKING_CHARS,
|
|
@@ -70,6 +71,7 @@ from .prompts import (
|
|
|
70
71
|
)
|
|
71
72
|
from .prompt_context import build_agent_system_prompt
|
|
72
73
|
from .recovery import (
|
|
74
|
+
rewrite_hard_stop,
|
|
73
75
|
StallMode,
|
|
74
76
|
detect_stall,
|
|
75
77
|
nudge_for,
|
|
@@ -1141,33 +1143,13 @@ def run_agent_loop(
|
|
|
1141
1143
|
_ephemeral_nudge_indices.append(len(messages) - 1)
|
|
1142
1144
|
|
|
1143
1145
|
# ── Stalled-round recovery ──
|
|
1144
|
-
# Three failure modes end a round with the model NOT
|
|
1145
|
-
#
|
|
1146
|
-
#
|
|
1147
|
-
# (
|
|
1148
|
-
#
|
|
1149
|
-
#
|
|
1150
|
-
# (
|
|
1151
|
-
# forward-looking narration ("I'll build the web app now.
|
|
1152
|
-
# Let me create this.") and stops, with no tool call. Image
|
|
1153
|
-
# 112. Happens because IQ2/IQ3 sometimes exits the reasoning
|
|
1154
|
-
# channel with just a sign-off instead of an action.
|
|
1155
|
-
#
|
|
1156
|
-
# (C) Gave-up-after-rejection — most recent tool call returned
|
|
1157
|
-
# a "REJECTED" / "Error" string AND the round ended with no
|
|
1158
|
-
# follow-up tool call. The model hit a snag and bailed
|
|
1159
|
-
# instead of reading the rejection's actionable feedback
|
|
1160
|
-
# and retrying. This was image 123: write_file got
|
|
1161
|
-
# REJECTED for syntax error, model stopped completely.
|
|
1162
|
-
#
|
|
1163
|
-
# Stall detection + nudge. Logic lives in agent/recovery.py
|
|
1164
|
-
# (T0.1-d split). `stall` is None for a productive round,
|
|
1165
|
-
# otherwise a `StallMode` enum that drives both the telemetry
|
|
1166
|
-
# label and the per-mode nudge text.
|
|
1167
|
-
# Gated on Feature.AUTO_NUDGE_RECOVERY. Disabled → stalls end
|
|
1168
|
-
# the turn silently (no synthetic SYSTEM: nudge), which is the
|
|
1169
|
-
# pre-recovery behaviour and what eval wants when A/B-ing the
|
|
1170
|
-
# nudge feature's actual contribution to task completion.
|
|
1146
|
+
# Three failure modes end a round with the model NOT done: (A) empty
|
|
1147
|
+
# round (long reasoning, stream closes with no content/tools); (B)
|
|
1148
|
+
# intent-without-action (one narration sentence, no tool call); (C)
|
|
1149
|
+
# gave-up-after-rejection (last tool returned REJECTED/Error, round
|
|
1150
|
+
# ended with no retry). Detection + nudge live in agent/recovery.py;
|
|
1151
|
+
# `stall` is None for a productive round else a StallMode. Gated on
|
|
1152
|
+
# Feature.AUTO_NUDGE_RECOVERY (off → stalls end the turn silently).
|
|
1171
1153
|
from ..features import Feature, is_enabled as _is_enabled
|
|
1172
1154
|
stall = detect_stall(
|
|
1173
1155
|
tool_calls=tool_calls,
|
|
@@ -1508,11 +1490,22 @@ def run_agent_loop(
|
|
|
1508
1490
|
"smallest targeted edit and verify it."
|
|
1509
1491
|
)
|
|
1510
1492
|
|
|
1493
|
+
# HARD rewrite-stop: the churn NUDGE (limit 3) only advises — logs
|
|
1494
|
+
# showed a model rewrite one file 16x while 25-34 nudges fired.
|
|
1495
|
+
# Past 2x the nudge limit, REJECT further full rewrites (see
|
|
1496
|
+
# recovery.rewrite_hard_stop). Key on the RAW path like the counter.
|
|
1497
|
+
_rewrite_limit_stub = None
|
|
1498
|
+
if tool_name in ("write_file", "append_file", "multi_edit") and isinstance(args, dict):
|
|
1499
|
+
_rw_path = args.get("path") or args.get("file_path") or ""
|
|
1500
|
+
_rewrite_limit_stub = rewrite_hard_stop(_rw_path, _file_write_counts)
|
|
1511
1501
|
# Execute (timed — wall-clock added to _round_tool_exec_ms
|
|
1512
1502
|
# so round_end can show the model what fraction of its
|
|
1513
1503
|
# round time was spent waiting on tools vs LLM).
|
|
1514
1504
|
_tool_started_at = time.monotonic()
|
|
1515
|
-
if
|
|
1505
|
+
if _rewrite_limit_stub is not None:
|
|
1506
|
+
from ..tools import ToolResult as _ToolResult
|
|
1507
|
+
_tool_result_obj = _ToolResult(text=_rewrite_limit_stub, ok=False, facts={"tool": tool_name, "ok": False, "repeated_failed_call": True, "rewrite_hard_stop": True})
|
|
1508
|
+
elif _edit_sequence_stub is not None:
|
|
1516
1509
|
from ..tools import ToolResult as _ToolResult
|
|
1517
1510
|
_tool_result_obj = _ToolResult(text=_edit_sequence_stub, ok=False, facts={"tool": tool_name, "ok": False, "edit_sequence": "missing_context"})
|
|
1518
1511
|
elif _oversize_stub is not None:
|
localcode/agent/recovery.py
CHANGED
|
@@ -127,25 +127,12 @@ def detect_stall(
|
|
|
127
127
|
if not content:
|
|
128
128
|
return StallMode.EMPTY
|
|
129
129
|
|
|
130
|
-
# NARRATION.
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
# tool, then answers in ~200 chars. The old broad `<400 chars after
|
|
137
|
-
# tools` rule deleted that answer, injected an auto-nudge, and made
|
|
138
|
-
# the model call the same API repeatedly. Only intent phrases count
|
|
139
|
-
# as narration.
|
|
140
|
-
#
|
|
141
|
-
# Two complementary detectors:
|
|
142
|
-
# • _NARRATION_INTENT_RE — "let me X" / "I'll Y" / "going to Z"
|
|
143
|
-
# anywhere in the content (full scan, not just tail) catches
|
|
144
|
-
# case (1) and similar variants where the commit phrase may
|
|
145
|
-
# be mid-content rather than at the end.
|
|
146
|
-
# • _NARRATION_PRESENT_PARTICIPLE_RE — last sentence starts with
|
|
147
|
-
# "Updating X / Changing Y / Now writing Z" + target. Past
|
|
148
|
-
# tense "Updated X" / "Changed Y" deliberately excluded.
|
|
130
|
+
# NARRATION detection. Only INTENT phrases count — not every short answer
|
|
131
|
+
# after a tool call (a "weather in ny?" answer legitimately runs one tool
|
|
132
|
+
# then replies in ~200 chars; the old broad rule deleted such answers and
|
|
133
|
+
# looped). Two detectors: _NARRATION_INTENT_RE ("let me X"/"I'll Y" anywhere)
|
|
134
|
+
# and _NARRATION_PRESENT_PARTICIPLE_RE (last sentence "Updating X/Now writing
|
|
135
|
+
# Z" + target; past tense "Updated X" excluded).
|
|
149
136
|
stripped = content.strip()
|
|
150
137
|
if bool(tools_called_prior) and stripped:
|
|
151
138
|
if _NARRATION_INTENT_RE.search(stripped):
|
|
@@ -398,3 +385,19 @@ def churn_nudge_for(signal: ChurnSignal) -> str:
|
|
|
398
385
|
"disk that you did not create are not part of your task. If you truly "
|
|
399
386
|
"lack information only the user has, ask ONE focused question."
|
|
400
387
|
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def rewrite_hard_stop(path, file_write_counts) -> str | None:
|
|
391
|
+
"""Circuit-breaker for rewrite drift: reject once a path is rewritten past
|
|
392
|
+
2x the nudge limit (the nudge only advises; logs showed 16x on one file)."""
|
|
393
|
+
if not path or not isinstance(path, str):
|
|
394
|
+
return None
|
|
395
|
+
n = file_write_counts.get(path, 0)
|
|
396
|
+
if n < CHURN_FILE_WRITE_LIMIT * 2:
|
|
397
|
+
return None
|
|
398
|
+
return (
|
|
399
|
+
f"REJECTED — HARD STOP: you have rewritten {path} {n} times this turn "
|
|
400
|
+
"and it's still not right — full rewrites are drifting. Make ONE "
|
|
401
|
+
"targeted edit_file change to the broken line, or leave this file and "
|
|
402
|
+
"move on. Do NOT call write_file on this path again."
|
|
403
|
+
)
|
localcode/agent/streaming.py
CHANGED
|
@@ -89,58 +89,6 @@ def _decode_streaming_content(args: str, start_pos: int) -> tuple[str, int]:
|
|
|
89
89
|
return ("".join(out), i)
|
|
90
90
|
|
|
91
91
|
|
|
92
|
-
def _maybe_incremental_write(
|
|
93
|
-
app: "LocalCodeApp",
|
|
94
|
-
result: "StreamRoundResult",
|
|
95
|
-
*,
|
|
96
|
-
tool_name: str,
|
|
97
|
-
tool_idx: int,
|
|
98
|
-
snippet: str,
|
|
99
|
-
) -> None:
|
|
100
|
-
"""Write the streaming `content` to disk incrementally for write_file
|
|
101
|
-
/ append_file. Tracks per-tool-index state on `result.live_writes`
|
|
102
|
-
so we only write NEW bytes each tick.
|
|
103
|
-
|
|
104
|
-
Best-effort: any failure (path not yet decoded, decode error, FS
|
|
105
|
-
error) silently no-ops — the round-end atomic write_file.execute()
|
|
106
|
-
is still authoritative."""
|
|
107
|
-
if tool_name not in {"write_file", "append_file"}:
|
|
108
|
-
return
|
|
109
|
-
if not snippet:
|
|
110
|
-
return
|
|
111
|
-
state = result.live_writes.get(tool_idx)
|
|
112
|
-
if state is None:
|
|
113
|
-
# Need a path before we can write anywhere. Extract it from the
|
|
114
|
-
# leading args; tool schemas put it FIRST so it shows up in the
|
|
115
|
-
# first ~256 chars.
|
|
116
|
-
m = _PATH_FIELD_RE.search(snippet)
|
|
117
|
-
if not m:
|
|
118
|
-
return
|
|
119
|
-
path_str = m.group(1)
|
|
120
|
-
try:
|
|
121
|
-
target = Path(path_str)
|
|
122
|
-
if not target.is_absolute():
|
|
123
|
-
target = Path(app.repo_root) / target
|
|
124
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
-
# write_file truncates; append_file extends an existing file.
|
|
126
|
-
mode = "w" if tool_name == "write_file" else "a"
|
|
127
|
-
target.open(mode).close() # truncate / touch
|
|
128
|
-
except Exception:
|
|
129
|
-
return
|
|
130
|
-
state = {"path": str(target), "decoded_pos": 0}
|
|
131
|
-
result.live_writes[tool_idx] = state
|
|
132
|
-
decoded, new_pos = _decode_streaming_content(snippet, state["decoded_pos"])
|
|
133
|
-
if not decoded and new_pos == state["decoded_pos"]:
|
|
134
|
-
return
|
|
135
|
-
if decoded:
|
|
136
|
-
try:
|
|
137
|
-
with open(state["path"], "a", encoding="utf-8") as f:
|
|
138
|
-
f.write(decoded)
|
|
139
|
-
except Exception:
|
|
140
|
-
return
|
|
141
|
-
state["decoded_pos"] = new_pos
|
|
142
|
-
|
|
143
|
-
|
|
144
92
|
@dataclass
|
|
145
93
|
class StreamRoundResult:
|
|
146
94
|
content_parts: list[str] = field(default_factory=list)
|
|
@@ -253,18 +201,19 @@ def stream_model_round(
|
|
|
253
201
|
out.stream(chunk)
|
|
254
202
|
return
|
|
255
203
|
if typ == "tool_preview":
|
|
204
|
+
# Live file preview is driven from THIS event (the TUI decodes
|
|
205
|
+
# args_snippet). We deliberately do NOT write to the target file
|
|
206
|
+
# incrementally: if the round was cut short (token/context cap, a
|
|
207
|
+
# mid-stream server SIGKILL, Ctrl-C), the partial bytes orphaned on
|
|
208
|
+
# disk while the loop discarded the tool call — leaving a truncated
|
|
209
|
+
# file (e.g. "autop") that the model then read back and churned on
|
|
210
|
+
# forever. The authoritative full-overwrite write_file.execute() at
|
|
211
|
+
# round-commit is the ONLY thing that touches disk.
|
|
256
212
|
out.tool_preview(
|
|
257
213
|
event.get("name", ""),
|
|
258
214
|
int(event.get("args_chars", 0) or 0),
|
|
259
215
|
event.get("args_snippet", "") or "",
|
|
260
216
|
)
|
|
261
|
-
_maybe_incremental_write(
|
|
262
|
-
app,
|
|
263
|
-
result,
|
|
264
|
-
tool_name=event.get("name", "") or "",
|
|
265
|
-
tool_idx=int(event.get("index", -1) or -1),
|
|
266
|
-
snippet=event.get("args_snippet", "") or "",
|
|
267
|
-
)
|
|
268
217
|
return
|
|
269
218
|
if typ == "tool_calls":
|
|
270
219
|
result.tool_calls = event.get("tool_calls") or []
|
localcode/tools/edit_file.py
CHANGED
|
@@ -258,14 +258,22 @@ def execute(ctx: ToolContext, args: dict) -> str:
|
|
|
258
258
|
f"file may already be in the state you want."
|
|
259
259
|
)
|
|
260
260
|
path.write_text(new_content)
|
|
261
|
+
_sw = ""
|
|
262
|
+
try:
|
|
263
|
+
from .syntax_check import check_syntax
|
|
264
|
+
_e = check_syntax(str(path), new_content)
|
|
265
|
+
if _e:
|
|
266
|
+
_sw = f"\n\n⚠ SYNTAX ERROR after this edit — fix it now: {_e}"
|
|
267
|
+
except Exception:
|
|
268
|
+
_sw = ""
|
|
261
269
|
diff = list(difflib.unified_diff(
|
|
262
270
|
old_content.splitlines(keepends=True),
|
|
263
271
|
new_content.splitlines(keepends=True),
|
|
264
272
|
fromfile=args["path"], tofile=args["path"], lineterm="",
|
|
265
273
|
))
|
|
266
274
|
if diff:
|
|
267
|
-
return "\n".join(diff[:60])
|
|
268
|
-
return f"Edited {args['path']} ({count} replacement{'s' if count != 1 else ''})"
|
|
275
|
+
return "\n".join(diff[:60]) + _sw
|
|
276
|
+
return f"Edited {args['path']} ({count} replacement{'s' if count != 1 else ''}){_sw}"
|
|
269
277
|
|
|
270
278
|
# ── Tier 2: strip Read-output line-number prefixes from old_string ──
|
|
271
279
|
stripped = _strip_read_prefix(old)
|
localcode/tools/multi_edit.py
CHANGED
|
@@ -98,6 +98,14 @@ def execute(ctx: ToolContext, args: dict) -> str:
|
|
|
98
98
|
return f"Error: no-op multi_edit on {args['path']}; applied 0/{len(edits)}."
|
|
99
99
|
|
|
100
100
|
path.write_text(content)
|
|
101
|
+
_sw = ""
|
|
102
|
+
try:
|
|
103
|
+
from .syntax_check import check_syntax
|
|
104
|
+
_e = check_syntax(str(path), content)
|
|
105
|
+
if _e:
|
|
106
|
+
_sw = f"\n\n⚠ SYNTAX ERROR after these edits — fix it now: {_e}"
|
|
107
|
+
except Exception:
|
|
108
|
+
_sw = ""
|
|
101
109
|
diff = list(difflib.unified_diff(
|
|
102
110
|
original.splitlines(keepends=True),
|
|
103
111
|
content.splitlines(keepends=True),
|
|
@@ -105,8 +113,8 @@ def execute(ctx: ToolContext, args: dict) -> str:
|
|
|
105
113
|
))
|
|
106
114
|
head = f"Applied {len(prepared)}/{len(edits)} edits to {args['path']}"
|
|
107
115
|
if diff:
|
|
108
|
-
return head + "\n" + "\n".join(diff[:120])
|
|
109
|
-
return head
|
|
116
|
+
return head + "\n" + "\n".join(diff[:120]) + _sw
|
|
117
|
+
return head + _sw
|
|
110
118
|
|
|
111
119
|
|
|
112
120
|
def _find_all(text: str, needle: str) -> list[int]:
|
localcode/tools/read_file.py
CHANGED
|
@@ -32,12 +32,52 @@ SCHEMA = {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _suggest_path(missing) -> str:
|
|
36
|
+
"""Suggest the real file when the model gave a typo'd/wrong-case path.
|
|
37
|
+
|
|
38
|
+
Walk up to the nearest existing ancestor dir and fuzzy-match the missing
|
|
39
|
+
filename (and its path segments) against what's actually there.
|
|
40
|
+
"""
|
|
41
|
+
import difflib
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
try:
|
|
44
|
+
missing = Path(missing)
|
|
45
|
+
name = missing.name
|
|
46
|
+
# Nearest existing ancestor to search in.
|
|
47
|
+
base = missing.parent
|
|
48
|
+
for _ in range(6):
|
|
49
|
+
if base.exists():
|
|
50
|
+
break
|
|
51
|
+
base = base.parent
|
|
52
|
+
if not base.exists():
|
|
53
|
+
return ""
|
|
54
|
+
candidates = []
|
|
55
|
+
for p in base.rglob("*"):
|
|
56
|
+
if p.is_file():
|
|
57
|
+
candidates.append(p)
|
|
58
|
+
if len(candidates) > 4000:
|
|
59
|
+
break
|
|
60
|
+
names = [p.name for p in candidates]
|
|
61
|
+
close = difflib.get_close_matches(name, names, n=1, cutoff=0.6)
|
|
62
|
+
if close:
|
|
63
|
+
match = next(p for p in candidates if p.name == close[0])
|
|
64
|
+
return f"Did you mean: {match} — read THAT exact path (don't guess variants)."
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
|
|
35
70
|
def execute(ctx: ToolContext, args: dict) -> str:
|
|
36
71
|
if "path" not in args:
|
|
37
72
|
return "Error: 'path' argument is required for read_file."
|
|
38
73
|
path = ctx.repo / args["path"]
|
|
39
74
|
if not path.exists():
|
|
40
|
-
|
|
75
|
+
# A small model invents misspelled/wrong-case paths ("Aki" for "Anki",
|
|
76
|
+
# gitHub/github/Github) and, getting a bare "not found", retries with
|
|
77
|
+
# ANOTHER wrong variant forever (dedup can't collapse differing typos).
|
|
78
|
+
# Point it at the real nearby file so it corrects instead of guessing.
|
|
79
|
+
hint = _suggest_path(path)
|
|
80
|
+
return f"File not found: {args['path']}" + (f"\n{hint}" if hint else "")
|
|
41
81
|
content = path.read_text(errors="replace")
|
|
42
82
|
lines = content.splitlines()
|
|
43
83
|
offset = args.get("offset", 0)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""One-shot syntax validation for files the agent writes.
|
|
2
|
+
|
|
3
|
+
Poor-man's LSP. The big agents (Claude Code, opencode) run a persistent
|
|
4
|
+
language server and feed its diagnostics back after each edit; codex uses
|
|
5
|
+
verified structured patches. A persistent LSP is too heavy for localcode's
|
|
6
|
+
target — a 16 GB Mac already running a large local model. So instead we run a
|
|
7
|
+
FAST, ONE-SHOT syntax check after each write and, if it fails, append the exact
|
|
8
|
+
error to the tool result.
|
|
9
|
+
|
|
10
|
+
Why this matters for a LOCAL model specifically: a small quantized model drifts
|
|
11
|
+
into structural typos (`useState(0]`, an unterminated string, a missing bracket,
|
|
12
|
+
`getCardsForCard`). With no verification the model has to NOTICE its own typo —
|
|
13
|
+
which weak models are bad at — so it re-reads the broken file, the broken code
|
|
14
|
+
enters its context, and it reproduces the error (self-conditioning loop). A
|
|
15
|
+
deterministic check gives it GROUND TRUTH ("line 37: unexpected token") so it
|
|
16
|
+
fixes the exact line instead of guessing.
|
|
17
|
+
|
|
18
|
+
Best-effort and non-blocking: the write ALWAYS succeeds; this only appends a
|
|
19
|
+
warning. Any checker failure/absence returns None (no false alarms). Disable
|
|
20
|
+
with LOCALCODE_SYNTAX_CHECK=0.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
|
|
28
|
+
_TIMEOUT = 5.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_syntax(path: str, content: str) -> str | None:
|
|
32
|
+
"""Return a one-line syntax-error string, or None if OK / not checkable.
|
|
33
|
+
|
|
34
|
+
In-process for JSON/Python (instant, zero deps); a fast one-shot subprocess
|
|
35
|
+
for JS (node --check) and TS/JSX (esbuild, when the project has it).
|
|
36
|
+
"""
|
|
37
|
+
if os.environ.get("LOCALCODE_SYNTAX_CHECK") == "0":
|
|
38
|
+
return None
|
|
39
|
+
ext = os.path.splitext(path)[1].lower()
|
|
40
|
+
try:
|
|
41
|
+
if ext == ".json":
|
|
42
|
+
return _check_json(content)
|
|
43
|
+
if ext in (".py", ".pyi"):
|
|
44
|
+
return _check_python(content, path)
|
|
45
|
+
if ext in (".js", ".mjs", ".cjs"):
|
|
46
|
+
return _check_node(path)
|
|
47
|
+
if ext in (".ts", ".tsx", ".jsx", ".mts", ".cts"):
|
|
48
|
+
return _check_esbuild(path)
|
|
49
|
+
except Exception:
|
|
50
|
+
return None
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_json(content: str) -> str | None:
|
|
55
|
+
try:
|
|
56
|
+
json.loads(content)
|
|
57
|
+
return None
|
|
58
|
+
except json.JSONDecodeError as e:
|
|
59
|
+
return f"JSON syntax error at line {e.lineno} col {e.colno}: {e.msg}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_python(content: str, path: str) -> str | None:
|
|
63
|
+
try:
|
|
64
|
+
compile(content, path, "exec")
|
|
65
|
+
return None
|
|
66
|
+
except SyntaxError as e:
|
|
67
|
+
loc = f"line {e.lineno}" + (f" col {e.offset}" if e.offset else "")
|
|
68
|
+
return f"Python syntax error at {loc}: {e.msg}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check_node(path: str) -> str | None:
|
|
72
|
+
import shutil
|
|
73
|
+
if not shutil.which("node"):
|
|
74
|
+
return None
|
|
75
|
+
r = subprocess.run(
|
|
76
|
+
["node", "--check", path],
|
|
77
|
+
capture_output=True, text=True, timeout=_TIMEOUT,
|
|
78
|
+
)
|
|
79
|
+
if r.returncode != 0:
|
|
80
|
+
return _first_error_line(r.stderr)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _find_bin(start: str, name: str) -> str | None:
|
|
85
|
+
"""Walk up from the file's dir to find node_modules/.bin/<name>."""
|
|
86
|
+
d = os.path.dirname(os.path.abspath(start))
|
|
87
|
+
for _ in range(25):
|
|
88
|
+
cand = os.path.join(d, "node_modules", ".bin", name)
|
|
89
|
+
if os.path.exists(cand):
|
|
90
|
+
return cand
|
|
91
|
+
parent = os.path.dirname(d)
|
|
92
|
+
if parent == d:
|
|
93
|
+
break
|
|
94
|
+
d = parent
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_esbuild(path: str) -> str | None:
|
|
99
|
+
# esbuild parses TS/TSX/JSX and reports syntax errors instantly. Only used
|
|
100
|
+
# when the project has it (Vite/most JS projects do post-install); if not
|
|
101
|
+
# present we skip rather than risk a false alarm from a wrong parser.
|
|
102
|
+
esbuild = _find_bin(path, "esbuild")
|
|
103
|
+
if not esbuild:
|
|
104
|
+
return None
|
|
105
|
+
loader = "tsx" if path.endswith((".tsx", ".jsx")) else "ts"
|
|
106
|
+
r = subprocess.run(
|
|
107
|
+
[esbuild, path, f"--loader={loader}", "--log-level=error"],
|
|
108
|
+
capture_output=True, text=True, timeout=_TIMEOUT,
|
|
109
|
+
)
|
|
110
|
+
if r.returncode != 0 and r.stderr.strip():
|
|
111
|
+
return _first_error_line(r.stderr)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _first_error_line(stderr: str) -> str | None:
|
|
116
|
+
"""Pull the human-meaningful error out of a node/esbuild stderr dump.
|
|
117
|
+
|
|
118
|
+
Prefer the actual `SyntaxError: …` / `error: …` message, skipping node's
|
|
119
|
+
internal loader/stack frames (`node:internal/…`, ` at …`).
|
|
120
|
+
"""
|
|
121
|
+
lines = [l.rstrip() for l in stderr.splitlines()]
|
|
122
|
+
# First choice: the real error message line.
|
|
123
|
+
for s in lines:
|
|
124
|
+
t = s.strip()
|
|
125
|
+
if t.startswith(("SyntaxError", "TypeError", "ReferenceError")) or " error:" in t.lower() or t.lower().startswith("error"):
|
|
126
|
+
return t[:200]
|
|
127
|
+
# Fallback: the "file:line" location node prints first (skip internals/stack).
|
|
128
|
+
for s in lines:
|
|
129
|
+
t = s.strip()
|
|
130
|
+
if not t or t.startswith(("node:internal", "at ", "^")):
|
|
131
|
+
continue
|
|
132
|
+
return t[:200]
|
|
133
|
+
return None
|
localcode/tools/write_file.py
CHANGED
|
@@ -199,6 +199,18 @@ def execute(ctx: ToolContext, args: dict) -> str:
|
|
|
199
199
|
lines = content.count("\n") + 1
|
|
200
200
|
verb = "Rewrote" if existed else "Created"
|
|
201
201
|
|
|
202
|
+
# Deterministic syntax check (poor-man's LSP): if the just-written file has
|
|
203
|
+
# a syntax error, surface the EXACT error so the model fixes that line next
|
|
204
|
+
# round instead of re-reading the broken file and drifting. See syntax_check.
|
|
205
|
+
_syntax_warning = ""
|
|
206
|
+
try:
|
|
207
|
+
from .syntax_check import check_syntax
|
|
208
|
+
_err = check_syntax(str(path), content)
|
|
209
|
+
if _err:
|
|
210
|
+
_syntax_warning = f"\n\n⚠ SYNTAX ERROR in this file — fix it now: {_err}"
|
|
211
|
+
except Exception:
|
|
212
|
+
_syntax_warning = ""
|
|
213
|
+
|
|
202
214
|
# Emit a unified diff for rewrites of existing files so the TUI draws the
|
|
203
215
|
# diff card. New files (or unchanged rewrites) keep the concise summary —
|
|
204
216
|
# a full new-file dump as "+" lines is noise, and the summary reads fine.
|
|
@@ -210,5 +222,5 @@ def execute(ctx: ToolContext, args: dict) -> str:
|
|
|
210
222
|
fromfile=args["path"], tofile=args["path"], lineterm="",
|
|
211
223
|
))
|
|
212
224
|
if diff:
|
|
213
|
-
return "\n".join(diff[:120])
|
|
214
|
-
return f"{verb} {args['path']} ({lines} lines)"
|
|
225
|
+
return "\n".join(diff[:120]) + _syntax_warning
|
|
226
|
+
return f"{verb} {args['path']} ({lines} lines){_syntax_warning}"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
localcode/__init__.py,sha256=
|
|
1
|
+
localcode/__init__.py,sha256=7scw5ecMd3F-dCMkmY63HI0FKsdj4ANkgybevu64WuQ,49
|
|
2
2
|
localcode/__main__.py,sha256=_4Tblte9F1KOpB-oGBQuTlaSN-IncLmhihqhBkpH3pU,69
|
|
3
3
|
localcode/_subproc_env.py,sha256=eltq_kKvqGj22pIFivB5BZft9-s5fziBW0lsV9FhEV4,2383
|
|
4
4
|
localcode/app.py,sha256=FSwpce6Jfn7zJmhL1ipaqTq0xKh0MxCMnEnMQuFV8L8,67820
|
|
@@ -71,12 +71,12 @@ localcode/agent/context.py,sha256=adV_WlFBHMDHsWdndRgrkZqRweDD9plyqMP_2X74WAw,37
|
|
|
71
71
|
localcode/agent/goal.py,sha256=X87Q3rsdp70wOfkpbh35M4qFWSNyklX3RuRWjv-pCXs,6264
|
|
72
72
|
localcode/agent/helpers.py,sha256=vutFyCtnca0UnF0wgk7LdsqVWdiqyYm4YnFOxtnCJ_A,15004
|
|
73
73
|
localcode/agent/hooks.py,sha256=XadEbSM7gHtKlLrqSKnvcQFcoWoNUXdlMdIPS-EwBGA,15841
|
|
74
|
-
localcode/agent/loop.py,sha256=
|
|
74
|
+
localcode/agent/loop.py,sha256=VcD7fjkoJtx8pECsL2tp-pMSom5qOkec1fy9nDgj3EE,99760
|
|
75
75
|
localcode/agent/prompt_context.py,sha256=abJPLMhTzuVcsW4AfMBIW-SEqmrU69DhI-0vGZllZVU,13707
|
|
76
76
|
localcode/agent/prompts.py,sha256=LCJBM2mV_SKc1OXtH0-ih9OPQnhrTh3vTlrOgLUiVDA,14845
|
|
77
|
-
localcode/agent/recovery.py,sha256
|
|
77
|
+
localcode/agent/recovery.py,sha256=-2vnYhhUGZD_reR6CaMOYNsG5XRf6YDsjTqF-1P-St4,16831
|
|
78
78
|
localcode/agent/sections.py,sha256=9hnwaNritQHgn7qIGFBZ3ChZgMQXQcqtmpdbR1wafA0,8998
|
|
79
|
-
localcode/agent/streaming.py,sha256=
|
|
79
|
+
localcode/agent/streaming.py,sha256=tW3LxUJlUyh3vRzeUsEo8lQcbc3DBvRtWF_2VLLQ9H8,11331
|
|
80
80
|
localcode/agent/tool_execution.py,sha256=yfWZwOO3f5ICd2eqw6keg8sOKOIa3vFt4peFinmu5nM,14462
|
|
81
81
|
localcode/agent/tool_orchestration.py,sha256=LVKNNHg59XLBlakz8J3zCwubXoWDiaXum8AS7HXB0sE,1711
|
|
82
82
|
localcode/agent/turn_finalization.py,sha256=n5h0aA_EPDS8ZY6TBvbUo7qu2IkgEwnwcO0PKBd6930,4884
|
|
@@ -101,20 +101,21 @@ localcode/tools/append_file.py,sha256=GNjPBnnV7hpD32u0Jx6YxRmsOSr4uwvJgIcInH1qhS
|
|
|
101
101
|
localcode/tools/base.py,sha256=2QV-q1DzXWM4KL9mFKNPzlBiLOS2uKLXhlgGyeWEKCI,2064
|
|
102
102
|
localcode/tools/bash.py,sha256=Yuuxo0lsX0JuFbHQX8OflmgZgq7uqBc1FUSGJRHlsB8,43380
|
|
103
103
|
localcode/tools/edit_diff.py,sha256=eJ1FyK3HecAmJNiM-XdHOQ7WCkyOuD6hwAlTVgYQGGk,3017
|
|
104
|
-
localcode/tools/edit_file.py,sha256=
|
|
104
|
+
localcode/tools/edit_file.py,sha256=YiUuw08kVw1qiClgsTmfi8W-RkH42cFqV9BvYfvzVcA,13750
|
|
105
105
|
localcode/tools/facts.py,sha256=OqTLYOclKy6lpPQiU3aDrzY-Ho-jhWc8mvzLx6TFRCk,5576
|
|
106
106
|
localcode/tools/glob_tool.py,sha256=8Gilud4U6lDZ_bASRbBeVndWzNImtf-jydNl3KQkpmY,1084
|
|
107
107
|
localcode/tools/grep.py,sha256=OP2P_J8fRz3qIJWp3--t1avq2K4HXjVENsOVXJDhLEU,1695
|
|
108
108
|
localcode/tools/launch_app.py,sha256=TVoq1ZQEIm2yQ607LGkJwbIC3TkvaLsBpTJgYIGX1gM,3357
|
|
109
109
|
localcode/tools/list_files.py,sha256=wN2dXU01rrKB_HGh6i0GfM9qvBwx7Qoa3S5WZlArma0,1205
|
|
110
|
-
localcode/tools/multi_edit.py,sha256=
|
|
110
|
+
localcode/tools/multi_edit.py,sha256=WkGibiGkr_J-Ld4CtxR24icdy1kGQuPEh6kKDrKe8Ss,4780
|
|
111
111
|
localcode/tools/plan_mode.py,sha256=2RU6QH1IJoOTKuoAr7Ga7uTaebAS8v7OFt5v1zLEXFw,2886
|
|
112
|
-
localcode/tools/read_file.py,sha256=
|
|
112
|
+
localcode/tools/read_file.py,sha256=gH852nnPiVD2Go9VcdS_EFbtnqYaGOPyAk5jo1cNbX8,4556
|
|
113
113
|
localcode/tools/skill_tool.py,sha256=vTVPyRWxICtDCVYWMiAMInGnc-wGAk2L1EZn-6e79nE,1478
|
|
114
|
+
localcode/tools/syntax_check.py,sha256=wojE6Ke6nMe3ptyh9T7zLp5roGKYaez3TwSpnp8nE8A,4869
|
|
114
115
|
localcode/tools/todo_write.py,sha256=8A4k9MzoqOtzJ6y2Gd3lEGkINcpV3JVCd2lYBmdd6qI,5801
|
|
115
116
|
localcode/tools/web_fetch.py,sha256=UPhMrLipwN5xJx5-zr634gL4p6nIRqYI2_7tbUw_8Go,2770
|
|
116
117
|
localcode/tools/web_search.py,sha256=ILfBBx50p00OjA939HzdDmdao4QLL8sRuxlBoUDjmC8,1122
|
|
117
|
-
localcode/tools/write_file.py,sha256=
|
|
118
|
+
localcode/tools/write_file.py,sha256=gjlNkVy9xw9q9eGWc5jY0l3iFN3zciKTztk8W25EHa0,10308
|
|
118
119
|
localcode/tui/__init__.py,sha256=Yfy9bvw3l1iY4FDhRX8qZVgnZLuYiq8lc9SDYl4cWwc,61
|
|
119
120
|
localcode/tui/app.py,sha256=KjQzpGKnxkgh-0kVc3zcb63Q6a5LBuGreBqTUrasiqM,17700
|
|
120
121
|
localcode/tui/bridge.py,sha256=urPPCqC_nisAYawWk3juvMI8IaoJfv4g7USpGOZOqQc,2803
|
|
@@ -131,9 +132,9 @@ localcode/tui/widgets/chat_log.py,sha256=D5DUkrQXL_ilxyC8N1xIPLw4Pqeh0jSWt1mPgho
|
|
|
131
132
|
localcode/tui/widgets/voice_visualizer.py,sha256=zOhrIxaMcg-F2Y8LvHJTddq227phmiTjtEyRbkx1XxU,4709
|
|
132
133
|
localcode/tui/widgets/messages/__init__.py,sha256=952AZ1qVMGUIqPIry7mwxnYbMkgPlcY5FAwZtEa5YPg,967
|
|
133
134
|
localcode/tui/widgets/messages/diff.py,sha256=khDf_MThrQz0S62-t-m7i3OU5cdNolUk1TCeKMRPDQI,10910
|
|
134
|
-
localcode-0.3.
|
|
135
|
-
localcode-0.3.
|
|
136
|
-
localcode-0.3.
|
|
137
|
-
localcode-0.3.
|
|
138
|
-
localcode-0.3.
|
|
139
|
-
localcode-0.3.
|
|
135
|
+
localcode-0.3.7.dist-info/licenses/LICENSE,sha256=Ahg0cteZ-6bIRB7KC15WEtdqWnzy8HY_yzsZBoVRql0,11294
|
|
136
|
+
localcode-0.3.7.dist-info/METADATA,sha256=Qg7wa61uxkFvD8SkVHWHQ1kEzZmnlK6H8e_SFlzm-0o,7813
|
|
137
|
+
localcode-0.3.7.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
138
|
+
localcode-0.3.7.dist-info/entry_points.txt,sha256=rE7elTGUvMsBd-Quy22cuTVJ-zGVOmx0nEuC6ni9Tg4,87
|
|
139
|
+
localcode-0.3.7.dist-info/top_level.txt,sha256=58eL3Rw8v0OGmYNxjx8uS4ERWatrzUbyCL5inlukkTo,10
|
|
140
|
+
localcode-0.3.7.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|