localcode 0.3.4__py3-none-any.whl → 0.3.6__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 +12 -12
- localcode/agent/prompts.py +6 -3
- localcode/agent/streaming.py +8 -59
- localcode/memory_guard.py +58 -0
- localcode/server_manager.py +23 -23
- localcode/tools/edit_file.py +10 -2
- localcode/tools/multi_edit.py +10 -2
- localcode/tools/syntax_check.py +133 -0
- localcode/tools/write_file.py +27 -2
- {localcode-0.3.4.dist-info → localcode-0.3.6.dist-info}/METADATA +1 -1
- {localcode-0.3.4.dist-info → localcode-0.3.6.dist-info}/RECORD +16 -15
- {localcode-0.3.4.dist-info → localcode-0.3.6.dist-info}/WHEEL +0 -0
- {localcode-0.3.4.dist-info → localcode-0.3.6.dist-info}/entry_points.txt +0 -0
- {localcode-0.3.4.dist-info → localcode-0.3.6.dist-info}/licenses/LICENSE +0 -0
- {localcode-0.3.4.dist-info → localcode-0.3.6.dist-info}/top_level.txt +0 -0
localcode/__init__.py
CHANGED
localcode/agent/loop.py
CHANGED
|
@@ -715,22 +715,22 @@ def run_agent_loop(
|
|
|
715
715
|
list(getattr(_tool_exec_state, "files_read", {}) or {}),
|
|
716
716
|
progress_ledger_budget_chars(_win_tokens),
|
|
717
717
|
)
|
|
718
|
-
if _ledger:
|
|
719
|
-
model_messages = list(model_messages) + [
|
|
720
|
-
{"role": "user", "content": _ledger}
|
|
721
|
-
]
|
|
722
718
|
except Exception:
|
|
723
|
-
|
|
724
|
-
#
|
|
725
|
-
|
|
726
|
-
# advances instead of repeating. Appended LAST, ephemeral, empty-safe.
|
|
719
|
+
_ledger = ""
|
|
720
|
+
# Working-memory checklist (todo_write): model sees its own plan.
|
|
721
|
+
_todo_note = ""
|
|
727
722
|
try:
|
|
728
723
|
from ..tools.todo_write import render_todo_reminder
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
724
|
+
_todo_note = render_todo_reminder(list(getattr(app.session, "todos", []) or []))
|
|
725
|
+
except Exception:
|
|
726
|
+
_todo_note = ""
|
|
727
|
+
# Inject ledger + todo as ONE trailing SYSTEM message (not role:user
|
|
728
|
+
# — as a user turn the model re-greeted every round). Ephemeral.
|
|
729
|
+
try:
|
|
730
|
+
_ctx_block = "\n\n".join(b for b in (_ledger, _todo_note) if b)
|
|
731
|
+
if _ctx_block:
|
|
732
732
|
model_messages = list(model_messages) + [
|
|
733
|
-
{"role": "
|
|
733
|
+
{"role": "system", "content": _ctx_block}
|
|
734
734
|
]
|
|
735
735
|
except Exception:
|
|
736
736
|
pass
|
localcode/agent/prompts.py
CHANGED
|
@@ -164,9 +164,12 @@ def model_identity_line(model: str) -> str:
|
|
|
164
164
|
# to its training priors otherwise answers "I'm Gemma by Google". So: lead
|
|
165
165
|
# with LocalCode, disclose the model only on request.
|
|
166
166
|
return (
|
|
167
|
-
f"
|
|
168
|
-
f"
|
|
169
|
-
f"
|
|
167
|
+
f"Your name is LocalCode (never lead with the underlying model name). "
|
|
168
|
+
f"Do NOT introduce or re-introduce yourself. Never begin a message with "
|
|
169
|
+
f"\"I'm LocalCode\", a greeting, or a restatement of what you're doing — "
|
|
170
|
+
f"the user already knows who you are and what the task is. Just continue "
|
|
171
|
+
f"the work: take the next action directly. Only state your name if the "
|
|
172
|
+
f"user explicitly asks who you are. "
|
|
170
173
|
f"The model currently powering you is \"{friendly}\"; report it as EXACTLY "
|
|
171
174
|
f"\"{friendly}\" — and only — when the user specifically asks which "
|
|
172
175
|
f"model / version / quant you are, or asks to name something (a file, "
|
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/memory_guard.py
CHANGED
|
@@ -209,3 +209,61 @@ def recommended_jetsam_limit_mb() -> int:
|
|
|
209
209
|
total_mb = 16 * 1024
|
|
210
210
|
reserve = 3500 if total_mb < 24 * 1024 else 6000
|
|
211
211
|
return max(2048, total_mb - reserve)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def should_disable_metal_residency(model_path: str, ctx_size: int) -> bool:
|
|
215
|
+
"""Whether to set GGML_METAL_NO_RESIDENCY=1 for this launch.
|
|
216
|
+
|
|
217
|
+
Root cause of the recurring mid-stream SIGKILL (-9, no crash report, tens
|
|
218
|
+
of GB of system RAM still free): on Apple Silicon, llama.cpp's Metal
|
|
219
|
+
residency sets keep the model weights + KV cache + compute buffers WIRED
|
|
220
|
+
for ~3 minutes (ggml-metal-device.m). During a long generation the wired
|
|
221
|
+
total grows, and when a decode-step spike crosses the GPU's
|
|
222
|
+
`recommendedMaxWorkingSetSize` (~75% of RAM — a hard ceiling independent of
|
|
223
|
+
free system RAM), the kernel kills the process outright. This is the exact
|
|
224
|
+
bug class in ggml-org/llama.cpp#16646 on the same M4 Max / 128 GB hardware.
|
|
225
|
+
|
|
226
|
+
Setting GGML_METAL_NO_RESIDENCY=1 makes those buffers EVICTABLE instead of
|
|
227
|
+
wired, so the OS pages under pressure rather than killing — turning a hard
|
|
228
|
+
crash into (at worst) mild slowdown, and only when actually near the cap.
|
|
229
|
+
|
|
230
|
+
Gated to at-risk launches so the common small-context fast path is
|
|
231
|
+
untouched: large context (>=128K, where KV + compute-buffer growth is the
|
|
232
|
+
trigger) OR a model whose weights alone are a large fraction of the wired
|
|
233
|
+
cap. No user action, no sudo — we control the spawn env.
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
r = subprocess.run(["sysctl", "-n", "hw.memsize"],
|
|
237
|
+
capture_output=True, text=True, timeout=2)
|
|
238
|
+
total_mb = int(r.stdout.strip()) // (1024 * 1024)
|
|
239
|
+
except Exception:
|
|
240
|
+
return False
|
|
241
|
+
# Metal's wired ceiling is ~75% of physical RAM.
|
|
242
|
+
wired_cap_mb = int(total_mb * 0.75)
|
|
243
|
+
if ctx_size >= 131072:
|
|
244
|
+
return True
|
|
245
|
+
try:
|
|
246
|
+
import os as _os
|
|
247
|
+
model_mb = _os.path.getsize(model_path) // (1024 * 1024)
|
|
248
|
+
except Exception:
|
|
249
|
+
model_mb = 0
|
|
250
|
+
# Weights alone over ~55% of the wired cap → KV + compute will breach it.
|
|
251
|
+
return model_mb > int(wired_cap_mb * 0.55)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def metal_residency_env(model_path: str, cmd: list) -> dict:
|
|
255
|
+
"""Return {GGML_METAL_NO_RESIDENCY: '1'} when this launch is at risk of the
|
|
256
|
+
wired-cap SIGKILL, else {}. macOS-only; parses --ctx-size from the command.
|
|
257
|
+
"""
|
|
258
|
+
import sys as _sys
|
|
259
|
+
if _sys.platform != "darwin":
|
|
260
|
+
return {}
|
|
261
|
+
ctx = 0
|
|
262
|
+
for i, tok in enumerate(cmd):
|
|
263
|
+
if tok == "--ctx-size" and i + 1 < len(cmd):
|
|
264
|
+
try:
|
|
265
|
+
ctx = int(cmd[i + 1])
|
|
266
|
+
except Exception:
|
|
267
|
+
ctx = 0
|
|
268
|
+
break
|
|
269
|
+
return {"GGML_METAL_NO_RESIDENCY": "1"} if should_disable_metal_residency(model_path, ctx) else {}
|
localcode/server_manager.py
CHANGED
|
@@ -299,12 +299,10 @@ class ServerManager:
|
|
|
299
299
|
self._lock = threading.Lock()
|
|
300
300
|
self._last_exit_code: Optional[int] = None # disconnect diagnostics
|
|
301
301
|
self._last_death_was_pressure: bool = False
|
|
302
|
-
# Idle auto-suspend:
|
|
303
|
-
#
|
|
304
|
-
#
|
|
305
|
-
#
|
|
306
|
-
# The next chat call transparently respawns via _restart_server.
|
|
307
|
-
# 0 disables; default 10 min. Override with env LOCALCODE_IDLE_SUSPEND_S.
|
|
302
|
+
# Idle auto-suspend: a watchdog shuts the server down after
|
|
303
|
+
# `_idle_timeout_s` of inactivity (stops the GPU cooking the laptop
|
|
304
|
+
# while the user reads); the next chat call respawns via
|
|
305
|
+
# _restart_server. 0 disables; default 10 min. Env: LOCALCODE_IDLE_SUSPEND_S.
|
|
308
306
|
import os as _os
|
|
309
307
|
try:
|
|
310
308
|
self._idle_timeout_s: float = float(
|
|
@@ -394,10 +392,8 @@ class ServerManager:
|
|
|
394
392
|
_prev_model = self._model_path # capture before shutdown clears it
|
|
395
393
|
self._shutdown_locked()
|
|
396
394
|
# Wait for the OLD server's memory to release before spawning the
|
|
397
|
-
# new one
|
|
398
|
-
#
|
|
399
|
-
# trips the pressure monitor on a 16 GB Mac. Best-effort — targets
|
|
400
|
-
# ~11 GB free, times out at 8 s, proceeds anyway.
|
|
395
|
+
# new one (kernel takes 1-3 s to free wired Metal memory; spawning
|
|
396
|
+
# into that window double-commits). Best-effort: ~11 GB target, 8 s.
|
|
401
397
|
if had_prior:
|
|
402
398
|
# Only a genuine model change is "model_switch"; a same-model
|
|
403
399
|
# relaunch (crash/disconnect recovery) is "server_restart".
|
|
@@ -422,6 +418,15 @@ class ServerManager:
|
|
|
422
418
|
# AGX driver may read the env at first Metal touch — setting it in
|
|
423
419
|
# the parent env before spawn is the belt-and-suspenders approach.
|
|
424
420
|
env.setdefault("AGX_RELAX_CDM_CTXSTORE_TIMEOUT", "1")
|
|
421
|
+
# Disable Metal residency sets on at-risk launches → prevents the wired-cap SIGKILL.
|
|
422
|
+
try:
|
|
423
|
+
from .memory_guard import metal_residency_env
|
|
424
|
+
_renv = metal_residency_env(str(model_path), cmd)
|
|
425
|
+
if _renv:
|
|
426
|
+
env.update(_renv)
|
|
427
|
+
_lifecycle_log("metal_no_residency", model=str(model_path))
|
|
428
|
+
except Exception:
|
|
429
|
+
pass
|
|
425
430
|
# Capture the server's OWN stdout/stderr to server.log (this runtime
|
|
426
431
|
# path used to send both to /dev/null, discarding llama-server's
|
|
427
432
|
# final ggml/Metal error on the recurring -9). Append per-spawn.
|
|
@@ -461,11 +466,10 @@ class ServerManager:
|
|
|
461
466
|
except Exception:
|
|
462
467
|
pass
|
|
463
468
|
|
|
464
|
-
# Layer 2: userspace
|
|
465
|
-
# kern.memorystatus_vm_pressure_level every 500 ms
|
|
466
|
-
#
|
|
467
|
-
#
|
|
468
|
-
# pressure (everything else swelling) not just our own RSS.
|
|
469
|
+
# Layer 2: userspace pressure monitor — polls
|
|
470
|
+
# kern.memorystatus_vm_pressure_level every 500 ms and SIGTERMs our
|
|
471
|
+
# server on WARN. Complements the jetsam ceiling (acts on system-
|
|
472
|
+
# wide pressure, not just our own RSS).
|
|
469
473
|
try:
|
|
470
474
|
self._pressure_thread = start_pressure_monitor(
|
|
471
475
|
self._process,
|
|
@@ -474,10 +478,8 @@ class ServerManager:
|
|
|
474
478
|
except Exception:
|
|
475
479
|
self._pressure_thread = None
|
|
476
480
|
self._write_pid_file(self._process.pid)
|
|
477
|
-
# Log the FULL launch command
|
|
478
|
-
#
|
|
479
|
-
# cause repetition pathologies that look like model bugs. Truncated
|
|
480
|
-
# to 4000 chars (~80 args) to keep the event small.
|
|
481
|
+
# Log the FULL launch command (which flags was the server using?);
|
|
482
|
+
# truncated to 4000 chars (~80 args) to keep the event small.
|
|
481
483
|
_flags_str = " ".join(str(c) for c in cmd)[:4000]
|
|
482
484
|
_lifecycle_log("server_started", pid=self._process.pid, port=port,
|
|
483
485
|
model=Path(model_path).name,
|
|
@@ -698,10 +700,8 @@ class ServerManager:
|
|
|
698
700
|
_lifecycle_log("pressure_kill", level=level, pid=killed_pid,
|
|
699
701
|
guard="memory_pressure", exit_code=self._last_exit_code,
|
|
700
702
|
free_mb=_system_free_memory_mb())
|
|
701
|
-
# Clear internal state so
|
|
702
|
-
#
|
|
703
|
-
# the pressure-monitor thread because `_process` reads/writes
|
|
704
|
-
# are atomic in CPython for the assignment.
|
|
703
|
+
# Clear internal state so callers see "no server" not a phantom Popen.
|
|
704
|
+
# Safe from the pressure-monitor thread (assignment is atomic in CPython).
|
|
705
705
|
self._process = None
|
|
706
706
|
self._model_path = None
|
|
707
707
|
|
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]:
|
|
@@ -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
|
@@ -173,6 +173,19 @@ def execute(ctx: ToolContext, args: dict) -> str:
|
|
|
173
173
|
# catches the data.py-style "model copies in-memory stub back into
|
|
174
174
|
# write_file" disaster, which was the one case where rejecting
|
|
175
175
|
# was load-bearing.
|
|
176
|
+
# Log the EXACT content the model sent (post-JSON-parse) so a "the file is
|
|
177
|
+
# corrupted / write_file isn't writing what I sent" report is provable from
|
|
178
|
+
# logs in one grep, instead of a mystery. Best-effort, capped, append-only.
|
|
179
|
+
try:
|
|
180
|
+
from ..paths import global_state_dir
|
|
181
|
+
_wl = global_state_dir() / "write_content.log"
|
|
182
|
+
with open(_wl, "a", encoding="utf-8", errors="replace") as _fh:
|
|
183
|
+
_fh.write(f"\n===== write_file {args['path']} ({len(content)} chars) =====\n")
|
|
184
|
+
_fh.write(content[:20000])
|
|
185
|
+
_fh.write("\n")
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
176
189
|
existed = path.is_file()
|
|
177
190
|
# Capture the prior content so an in-place rewrite renders as a diff card
|
|
178
191
|
# (like edit_file/multi_edit), not a bare "Rewrote (N lines)" one-liner.
|
|
@@ -186,6 +199,18 @@ def execute(ctx: ToolContext, args: dict) -> str:
|
|
|
186
199
|
lines = content.count("\n") + 1
|
|
187
200
|
verb = "Rewrote" if existed else "Created"
|
|
188
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
|
+
|
|
189
214
|
# Emit a unified diff for rewrites of existing files so the TUI draws the
|
|
190
215
|
# diff card. New files (or unchanged rewrites) keep the concise summary —
|
|
191
216
|
# a full new-file dump as "+" lines is noise, and the summary reads fine.
|
|
@@ -197,5 +222,5 @@ def execute(ctx: ToolContext, args: dict) -> str:
|
|
|
197
222
|
fromfile=args["path"], tofile=args["path"], lineterm="",
|
|
198
223
|
))
|
|
199
224
|
if diff:
|
|
200
|
-
return "\n".join(diff[:120])
|
|
201
|
-
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=uPn5eZXztoMjXC5UlstTHEEu-a6qbzw_jfhmRaY1Qn4,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
|
|
@@ -30,7 +30,7 @@ localcode/injection_defense.py,sha256=sggmoowHEAKYV3v-1PTWtv8KjekEL0kZ0-O03hb3Dm
|
|
|
30
30
|
localcode/launcher.py,sha256=uDsIy66q-VexbvdIb7iwzYwDmmYhWRP10SNX4GiEWQ4,11462
|
|
31
31
|
localcode/logging_utils.py,sha256=mFn_LO26IkT7OLxKs1ke4mND8BJeDLf-UULCYSSxyz0,920
|
|
32
32
|
localcode/lsp.py,sha256=WTtdbdgforZdWMRqlzCQp2ndkKRbfIE1f5GfGeRH1xU,5939
|
|
33
|
-
localcode/memory_guard.py,sha256=
|
|
33
|
+
localcode/memory_guard.py,sha256=9OBpvzOGP_gzYvG3sXhiYr6vzTJRW_YtRDaccouSIPU,11390
|
|
34
34
|
localcode/model_config.py,sha256=4iPw5M6tnjMvdILtWlRkOdE6QMFRJCAIYBmadc82ZjQ,14261
|
|
35
35
|
localcode/model_families.py,sha256=mEBIoEMe-qL93Twxm-4mYv76dWEkKVp5CiQlY43mkWA,11203
|
|
36
36
|
localcode/models.py,sha256=fetDQSmOR3GrMMv_H9ecAZSmHa2TPhsYw8eSYKDl5YA,8243
|
|
@@ -48,7 +48,7 @@ localcode/recommendations.py,sha256=HVh8u2L3xbwc2qxH7Syec2cEtlEoFV9n1c33SqWF3ng,
|
|
|
48
48
|
localcode/recovery.py,sha256=QUSsBA9IS5AJ6W4m4nIv2RaktTO9TJeL0RcDeUSWHJk,5237
|
|
49
49
|
localcode/runtime.py,sha256=UifjZAe8kPgnMCk4N2V2eX4VY6Us6IYmDwbvVwMhwC0,115816
|
|
50
50
|
localcode/runtime_diffusion.py,sha256=bVopikSdQeaV9mAuzWYoXtoXpUb7U0yMXU5VFTpbsBc,39741
|
|
51
|
-
localcode/server_manager.py,sha256=
|
|
51
|
+
localcode/server_manager.py,sha256=9xHqShGB9rVr62qhTwWkYZDX4KOuxACvujhFC0ztYdM,34305
|
|
52
52
|
localcode/session.py,sha256=taEyOOiCcNXEr8oOeszyWvzvn-oAZ7knXQ7BxRew08Q,11970
|
|
53
53
|
localcode/shell.py,sha256=2QcXk0NB6d8t2gpbbKOB5nGZ_xBJ7VVsLtF4hnUknVM,2300
|
|
54
54
|
localcode/skills.py,sha256=TGdy63dQTlfa6U2l7wjg3zSatKyLAYdu_wVO-J2BrHY,25800
|
|
@@ -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=2O2iKFsCQmr1CoQq40Ls_NeBs7tFgcDVfkU8YhqXXdM,99864
|
|
75
75
|
localcode/agent/prompt_context.py,sha256=abJPLMhTzuVcsW4AfMBIW-SEqmrU69DhI-0vGZllZVU,13707
|
|
76
|
-
localcode/agent/prompts.py,sha256=
|
|
76
|
+
localcode/agent/prompts.py,sha256=LCJBM2mV_SKc1OXtH0-ih9OPQnhrTh3vTlrOgLUiVDA,14845
|
|
77
77
|
localcode/agent/recovery.py,sha256=oCghDm9BdHhvl98Kx8EikNuxSk4Geh-0cgAEhTpH-sw,16738
|
|
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
112
|
localcode/tools/read_file.py,sha256=u8ZySkvSj-ZzPVSdi1ybZ6yTgZ-yXiqAW54ZVdZWf-g,3017
|
|
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.6.dist-info/licenses/LICENSE,sha256=Ahg0cteZ-6bIRB7KC15WEtdqWnzy8HY_yzsZBoVRql0,11294
|
|
136
|
+
localcode-0.3.6.dist-info/METADATA,sha256=oYaWgEmdnKljAQVT3xl5F7EQXe9GO3hkLusS6joWwMg,7813
|
|
137
|
+
localcode-0.3.6.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
138
|
+
localcode-0.3.6.dist-info/entry_points.txt,sha256=rE7elTGUvMsBd-Quy22cuTVJ-zGVOmx0nEuC6ni9Tg4,87
|
|
139
|
+
localcode-0.3.6.dist-info/top_level.txt,sha256=58eL3Rw8v0OGmYNxjx8uS4ERWatrzUbyCL5inlukkTo,10
|
|
140
|
+
localcode-0.3.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|