localcode 0.3.0__py3-none-any.whl → 0.3.2__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/context.py +62 -14
- localcode/agent/loop.py +35 -3
- localcode/agent/prompts.py +5 -2
- localcode/agent/tool_orchestration.py +2 -1
- localcode/app.py +0 -393
- localcode/bootstrap.py +2 -572
- localcode/cache.py +5 -3
- localcode/checkpoint.py +18 -2
- localcode/composer.py +12 -29
- localcode/config.py +28 -18
- localcode/entrypoint.py +70 -59
- localcode/errors.py +3 -3
- localcode/headless_json.py +3 -0
- localcode/history.py +15 -0
- localcode/models.py +0 -13
- localcode/models_catalog.py +13 -0
- localcode/performance.py +14 -21
- localcode/runtime.py +141 -426
- localcode/runtime_diffusion.py +31 -2
- localcode/server_manager.py +15 -18
- localcode/session.py +6 -0
- localcode/skills.py +5 -0
- localcode/tools/__init__.py +7 -0
- localcode/tools/todo_write.py +140 -0
- localcode/tools/write_file.py +21 -0
- localcode/tui/app.py +35 -63
- localcode/tui/screens/chat.py +288 -66
- localcode/tui/screens/model_picker.py +3 -2
- localcode/tui/screens/setup.py +17 -45
- localcode/tui/styles/app.tcss +13 -25
- localcode/tui/widgets/chat_log.py +58 -9
- localcode/tui/widgets/messages/diff.py +56 -0
- localcode/voice.py +4 -2
- {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/METADATA +1 -1
- {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/RECORD +40 -40
- {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/WHEEL +1 -1
- localcode/tool_parsing.py +0 -367
- {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/entry_points.txt +0 -0
- {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/top_level.txt +0 -0
localcode/__init__.py
CHANGED
localcode/agent/context.py
CHANGED
|
@@ -45,6 +45,37 @@ if TYPE_CHECKING:
|
|
|
45
45
|
__all__: list[str] = []
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def _spill_tool_output(result: str, tool_name: str) -> str | None:
|
|
49
|
+
"""Write an oversized tool result to a file and return its path.
|
|
50
|
+
|
|
51
|
+
Truncation alone makes a small model re-run the command "to see the rest".
|
|
52
|
+
Instead we keep the full output on disk and point the model at it (grep/
|
|
53
|
+
read the exact part). Best-effort: None on error → caller falls back to
|
|
54
|
+
plain truncation. Content-hashed filenames; old spills reaped after 7 days.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
import hashlib
|
|
58
|
+
import time as _time
|
|
59
|
+
from ..paths import global_state_dir
|
|
60
|
+
spill_dir = global_state_dir() / "tool_output"
|
|
61
|
+
spill_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
# Reap spills older than 7 days so this never grows unbounded.
|
|
63
|
+
try:
|
|
64
|
+
cutoff = _time.time() - 7 * 86400
|
|
65
|
+
for old in spill_dir.glob("*.txt"):
|
|
66
|
+
if old.stat().st_mtime < cutoff:
|
|
67
|
+
old.unlink(missing_ok=True)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
digest = hashlib.sha1(result.encode("utf-8", "replace")).hexdigest()[:12]
|
|
71
|
+
path = spill_dir / f"{tool_name}_{digest}.txt"
|
|
72
|
+
if not path.exists():
|
|
73
|
+
path.write_text(result, encoding="utf-8", errors="replace")
|
|
74
|
+
return str(path)
|
|
75
|
+
except Exception:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
48
79
|
def _truncate_result(result: str, tool_name: str) -> str:
|
|
49
80
|
"""Truncate a tool result to its per-tool size limit with a
|
|
50
81
|
strategy tuned to what each tool's output actually looks like.
|
|
@@ -70,6 +101,15 @@ def _truncate_result(result: str, tool_name: str) -> str:
|
|
|
70
101
|
return result
|
|
71
102
|
|
|
72
103
|
dropped = len(result) - limit
|
|
104
|
+
# Spill the FULL output to disk and point the model at it, so it fetches
|
|
105
|
+
# the exact part it needs instead of re-running the command to "see the
|
|
106
|
+
# rest". Appended to every strategy's hint below.
|
|
107
|
+
_spill = _spill_tool_output(result, tool_name)
|
|
108
|
+
spill_hint = (
|
|
109
|
+
f" Full output saved to {_spill} — read_file (with offset) or grep it "
|
|
110
|
+
f"to see the rest; do NOT re-run the command."
|
|
111
|
+
if _spill else ""
|
|
112
|
+
)
|
|
73
113
|
|
|
74
114
|
if tool_name == "read_file":
|
|
75
115
|
# Keep the HEAD; if we can find line numbers in the content
|
|
@@ -85,7 +125,7 @@ def _truncate_result(result: str, tool_name: str) -> str:
|
|
|
85
125
|
last_numbered = int(line.split("\t", 1)[0])
|
|
86
126
|
hint = (
|
|
87
127
|
f"\n\n[truncated — {dropped} more chars not shown. "
|
|
88
|
-
f"{'Call read_file with offset=' + str(last_numbered) + ' to continue reading from there.' if last_numbered else 'Call read_file with a larger offset to continue.'}]"
|
|
128
|
+
f"{'Call read_file with offset=' + str(last_numbered) + ' to continue reading from there.' if last_numbered else 'Call read_file with a larger offset to continue.'}{spill_hint}]"
|
|
89
129
|
)
|
|
90
130
|
return head + hint
|
|
91
131
|
|
|
@@ -99,7 +139,7 @@ def _truncate_result(result: str, tool_name: str) -> str:
|
|
|
99
139
|
dropped_lines = result[limit:].count("\n") + 1
|
|
100
140
|
hint = (
|
|
101
141
|
f"\n\n[truncated — {dropped_lines} more match-lines not shown. "
|
|
102
|
-
"Narrow with a more specific pattern or `include=*.py` to see them.]"
|
|
142
|
+
f"Narrow with a more specific pattern or `include=*.py` to see them.{spill_hint}]"
|
|
103
143
|
)
|
|
104
144
|
return head + hint
|
|
105
145
|
|
|
@@ -114,7 +154,7 @@ def _truncate_result(result: str, tool_name: str) -> str:
|
|
|
114
154
|
first_sig = "\n".join(non_empty[:8])
|
|
115
155
|
last_sig = "\n".join(non_empty[-12:]) if len(non_empty) > 12 else ""
|
|
116
156
|
marker = (
|
|
117
|
-
f"\n\n[... {dropped} chars of bash output compressed ...]\n\n"
|
|
157
|
+
f"\n\n[... {dropped} chars of bash output compressed.{spill_hint} ...]\n\n"
|
|
118
158
|
)
|
|
119
159
|
if any(token in result for token in ("backend", "frontend", "src/", "node_modules", "package.json", "pyproject.toml")):
|
|
120
160
|
tree_hint = (
|
|
@@ -133,14 +173,14 @@ def _truncate_result(result: str, tool_name: str) -> str:
|
|
|
133
173
|
tail_size = limit - head_size - 64 # 64 chars for the marker line
|
|
134
174
|
head = result[:head_size]
|
|
135
175
|
tail = result[-tail_size:]
|
|
136
|
-
marker = f"\n\n[... {dropped} chars of middle output dropped ...]\n\n"
|
|
176
|
+
marker = f"\n\n[... {dropped} chars of middle output dropped.{spill_hint} ...]\n\n"
|
|
137
177
|
return head + marker + tail
|
|
138
178
|
|
|
139
179
|
# Default — middle-drop.
|
|
140
180
|
half = limit // 2
|
|
141
181
|
return (
|
|
142
182
|
result[:half]
|
|
143
|
-
+ f"\n\n[...{dropped} chars truncated...]\n\n"
|
|
183
|
+
+ f"\n\n[...{dropped} chars truncated.{spill_hint} ...]\n\n"
|
|
144
184
|
+ result[-half:]
|
|
145
185
|
)
|
|
146
186
|
|
|
@@ -205,11 +245,12 @@ def _semantic_tool_summary(content: str) -> str:
|
|
|
205
245
|
or "Traceback " in content
|
|
206
246
|
)
|
|
207
247
|
if is_error:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
248
|
+
# Self-conditioning (arXiv:2509.09677): echoing an OLD error's text
|
|
249
|
+
# pushes the model to repeat the mistake (scale-independent). Recent
|
|
250
|
+
# errors stay intact (kept above by keep_recent) so it can fix the
|
|
251
|
+
# immediate problem; older ones drop to a neutral note. The progress
|
|
252
|
+
# ledger still records the failure fact, minus the conditioning text.
|
|
253
|
+
return "[an earlier tool call errored and was handled — details dropped]"
|
|
213
254
|
if facts:
|
|
214
255
|
return (
|
|
215
256
|
f"[older successful tool result summarized: {len(content)} chars, "
|
|
@@ -412,7 +453,8 @@ def _redact_duplicate_reads(messages: list[dict]) -> list[dict]:
|
|
|
412
453
|
if not tc_id:
|
|
413
454
|
continue
|
|
414
455
|
try:
|
|
415
|
-
|
|
456
|
+
_raw = (tc.get("function") or {}).get("arguments") or "{}"
|
|
457
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
416
458
|
except Exception:
|
|
417
459
|
continue
|
|
418
460
|
path = args.get("path")
|
|
@@ -629,7 +671,8 @@ def _compact_history_summary(messages: list[dict]) -> str:
|
|
|
629
671
|
fn = tc.get("function") or {}
|
|
630
672
|
name = str(fn.get("name") or "").strip()
|
|
631
673
|
try:
|
|
632
|
-
|
|
674
|
+
_raw = fn.get("arguments") or "{}"
|
|
675
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
633
676
|
except Exception:
|
|
634
677
|
args = {}
|
|
635
678
|
path = args.get("path") or args.get("file_path")
|
|
@@ -769,8 +812,13 @@ def _compact_messages(messages: list[dict], out: "OutputManager") -> list[dict]:
|
|
|
769
812
|
fn = tc.get("function", {})
|
|
770
813
|
name = fn.get("name", "")
|
|
771
814
|
try:
|
|
772
|
-
|
|
773
|
-
|
|
815
|
+
# `arguments` may be a JSON string OR an already-decoded
|
|
816
|
+
# dict (some providers/parsers do that) — json.loads on a
|
|
817
|
+
# dict raises TypeError, which used to escape and kill the
|
|
818
|
+
# turn with E3102 during compaction.
|
|
819
|
+
_raw = fn.get("arguments", "{}")
|
|
820
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
821
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
774
822
|
args = {}
|
|
775
823
|
if name in ("write_file", "append_file", "edit_file"):
|
|
776
824
|
files_modified.add(args.get("path", "?"))
|
localcode/agent/loop.py
CHANGED
|
@@ -24,6 +24,7 @@ you hit them.
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
26
|
import json
|
|
27
|
+
import os
|
|
27
28
|
import re
|
|
28
29
|
import sys
|
|
29
30
|
import time
|
|
@@ -720,6 +721,19 @@ def run_agent_loop(
|
|
|
720
721
|
]
|
|
721
722
|
except Exception:
|
|
722
723
|
pass
|
|
724
|
+
# Inject the working-memory checklist (todo_write tool): the model
|
|
725
|
+
# always sees its own plan (done / in-progress / left) so it
|
|
726
|
+
# advances instead of repeating. Appended LAST, ephemeral, empty-safe.
|
|
727
|
+
try:
|
|
728
|
+
from ..tools.todo_write import render_todo_reminder
|
|
729
|
+
_todos = list(getattr(app.session, "todos", []) or [])
|
|
730
|
+
_todo_note = render_todo_reminder(_todos)
|
|
731
|
+
if _todo_note:
|
|
732
|
+
model_messages = list(model_messages) + [
|
|
733
|
+
{"role": "user", "content": _todo_note}
|
|
734
|
+
]
|
|
735
|
+
except Exception:
|
|
736
|
+
pass
|
|
723
737
|
round_tool_schemas = schemas_for_goal(
|
|
724
738
|
_goal_state.goal_type,
|
|
725
739
|
user_text,
|
|
@@ -1205,7 +1219,11 @@ def run_agent_loop(
|
|
|
1205
1219
|
break
|
|
1206
1220
|
|
|
1207
1221
|
# ── No tool calls = model is done ──
|
|
1208
|
-
|
|
1222
|
+
# Also require no pending tool call: the runtime promotes pending
|
|
1223
|
+
# tools into `tool_calls`, but if a future path doesn't, we keep
|
|
1224
|
+
# looping instead of ending early (small models often emit a stop
|
|
1225
|
+
# token with a call still pending).
|
|
1226
|
+
if not tool_calls and not _round_pending_tool_count:
|
|
1209
1227
|
# Match v0.2.12's no-tool-calls path exactly: render content,
|
|
1210
1228
|
# render any grounded file summary, break. Nothing else.
|
|
1211
1229
|
#
|
|
@@ -1328,8 +1346,12 @@ def run_agent_loop(
|
|
|
1328
1346
|
fn = tc.get("function", {})
|
|
1329
1347
|
tool_name = fn.get("name", "unknown")
|
|
1330
1348
|
try:
|
|
1331
|
-
|
|
1332
|
-
|
|
1349
|
+
_raw_args = fn.get("arguments", "{}")
|
|
1350
|
+
# `arguments` is normally a JSON string, but some providers/
|
|
1351
|
+
# parsers hand back an already-decoded dict — json.loads on a
|
|
1352
|
+
# dict raises TypeError, which used to escape and crash the loop.
|
|
1353
|
+
args = _raw_args if isinstance(_raw_args, dict) else json.loads(_raw_args)
|
|
1354
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
1333
1355
|
args = {}
|
|
1334
1356
|
out.print_info(f"Warning: malformed args for {tool_name}")
|
|
1335
1357
|
|
|
@@ -1337,6 +1359,16 @@ def run_agent_loop(
|
|
|
1337
1359
|
stage = _tool_stage_label(tool_name, args)
|
|
1338
1360
|
out.set_stage(stage)
|
|
1339
1361
|
idx = out.log_tool(tool_name, _summarize_args(args))
|
|
1362
|
+
# RL trace fidelity: the normal `tool_start` event carries only a
|
|
1363
|
+
# summarized + 200-char-truncated arg preview. When capturing
|
|
1364
|
+
# trajectories for fine-tuning we need the FULL arguments (e.g. the
|
|
1365
|
+
# whole write_file body). Env-gated so normal TUI/headless runs are
|
|
1366
|
+
# unaffected; dev/rl/collect sets LOCALCODE_TRACE_FULL_ARGS=1.
|
|
1367
|
+
if os.environ.get("LOCALCODE_TRACE_FULL_ARGS"):
|
|
1368
|
+
out._emit_event(
|
|
1369
|
+
"tool_call_full", index=str(idx),
|
|
1370
|
+
name=tool_name, arguments=args,
|
|
1371
|
+
)
|
|
1340
1372
|
|
|
1341
1373
|
# Safety: confirm destructive commands (honors current autonomy
|
|
1342
1374
|
# level so /permissions toggles take effect immediately).
|
localcode/agent/prompts.py
CHANGED
|
@@ -42,11 +42,12 @@ _STACK_MARKERS: tuple[tuple[str, str], ...] = (
|
|
|
42
42
|
SYSTEM_PROMPT = """\
|
|
43
43
|
You are LocalCode, a coding agent on the user's machine with full filesystem access.
|
|
44
44
|
|
|
45
|
-
Available tools: read_file, write_file, append_file, edit_file, bash, list_files.
|
|
45
|
+
Available tools: read_file, write_file, append_file, edit_file, bash, list_files, todo_write.
|
|
46
46
|
|
|
47
47
|
How to work:
|
|
48
48
|
- Match scope to the request. Answer plain questions plainly. Build what was asked when asked. Don't write a script when a one-line bash command will do.
|
|
49
49
|
- Cover every requirement the user named. If your chosen approach can't deliver one, change the approach — don't drop the requirement to fit the approach.
|
|
50
|
+
- PLAN MULTI-STEP WORK. For any task with 3+ steps, call `todo_write` FIRST to lay out the steps, then keep it current: mark exactly ONE item in_progress before you start it, and mark it completed the instant it's done. Your task list is shown back to you every round — read it to see what's already finished and what's left, so you advance to the next step instead of redoing one you've already done. If you catch yourself about to re-read a file or re-run a command you ran before, check the list: you've likely already done it.
|
|
50
51
|
- If the request is ambiguous in a way that affects your approach (stack, interface, scope), ask one short question before building, not after.
|
|
51
52
|
- ACT, DON'T NARRATE. Every "let me read", "let me fix", "now I'll write" MUST be followed by the actual tool call IN THE SAME TURN. If you say "let me write the fix" and then end your turn without calling edit_file or write_file, you have failed the user. The user has explicitly complained about this. Never describe a fix without immediately performing it.
|
|
52
53
|
- Forbidden patterns: "Let me read the file:" + end of turn. "I'll rewrite this:" + end of turn. "Now I'll apply the fix:" + end of turn. If the next thing out of your mouth is a verb of intent, the very next action MUST be the tool call that delivers on that intent.
|
|
@@ -57,7 +58,9 @@ How to work:
|
|
|
57
58
|
- MATCH THE PROJECT'S LANGUAGE AND CONVENTIONS. Code MUST be valid for the file's actual language. Never write Python syntax (triple-quoted docstrings, snake_case, `import x`/`from x import y`) in a .ts/.tsx/.js/.jsx/.go/.rs or other non-Python file. Use that language's real comment, naming, and import/module syntax, and mirror the patterns already present in the project (imports, formatting, framework idioms).
|
|
58
59
|
- For new projects, create a small multi-file structure by default. Keep entrypoints thin; move reusable logic, styles, data/config, templates, and assets into focused files. Use one large file only if requested.
|
|
59
60
|
- Prefer edit_file for existing files. Use write_file when creating a new file or doing a deliberate full rewrite.
|
|
60
|
-
- When a tool returns an error, read it, fix the specific problem, and retry. Don't give up after one failed call.
|
|
61
|
+
- When a tool returns an error, read it, fix the specific problem, and retry with DIFFERENT input. Never repeat an identical failing call: if the same call fails or returns the same result twice, change the arguments or the approach, or move on — do not loop. Don't give up after one failed call, but don't spin on it either.
|
|
62
|
+
- DON'T RE-READ OR RE-SEARCH what you already have. If you already read a file this turn and it hasn't changed, the content is still above — use it, don't read it again. If a search already gave you a useful result, act on it (open the match, make the edit); don't run the same search again. Repeating a read/search you've already done is the #1 way turns stall.
|
|
63
|
+
- After a tool result, CONTINUE from where you left off — don't restate what you just did or re-summarize the plan. Take the next concrete action.
|
|
61
64
|
- DON'T CONFABULATE. If the user names a person, song, place, term, library, command, or concept you do not recognize, your FIRST move is to say "I don't recognize that" — NOT to invent a plausible-sounding meaning by phonetic association. Phonetic fits ("Alombasi sounds Bantu so it must be a Zambian chant", "Pyfoo sounds like a Python library so it must do X") are exactly the failure mode. If a web search would help, run it BEFORE asserting facts; if results are empty, say so plainly. Never write paragraph-length cultural/etymological/technical descriptions of something you can't actually source — "I'd love to know more, what's it from?" is the correct answer. Doubling down when the user repeats the unknown term is also forbidden; repetition is not evidence.
|
|
62
65
|
|
|
63
66
|
Runtime facts (true today; rely on these instead of guessing):
|
|
@@ -31,7 +31,8 @@ def prefetch_parallel_tool_calls(
|
|
|
31
31
|
fn = tc.get("function", {}) or {}
|
|
32
32
|
name = (fn.get("name", "") or "").strip()
|
|
33
33
|
try:
|
|
34
|
-
|
|
34
|
+
_raw = fn.get("arguments", "{}")
|
|
35
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
35
36
|
except Exception:
|
|
36
37
|
continue
|
|
37
38
|
if not is_concurrency_safe(name, args):
|