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.
Files changed (41) hide show
  1. localcode/__init__.py +1 -1
  2. localcode/agent/context.py +62 -14
  3. localcode/agent/loop.py +35 -3
  4. localcode/agent/prompts.py +5 -2
  5. localcode/agent/tool_orchestration.py +2 -1
  6. localcode/app.py +0 -393
  7. localcode/bootstrap.py +2 -572
  8. localcode/cache.py +5 -3
  9. localcode/checkpoint.py +18 -2
  10. localcode/composer.py +12 -29
  11. localcode/config.py +28 -18
  12. localcode/entrypoint.py +70 -59
  13. localcode/errors.py +3 -3
  14. localcode/headless_json.py +3 -0
  15. localcode/history.py +15 -0
  16. localcode/models.py +0 -13
  17. localcode/models_catalog.py +13 -0
  18. localcode/performance.py +14 -21
  19. localcode/runtime.py +141 -426
  20. localcode/runtime_diffusion.py +31 -2
  21. localcode/server_manager.py +15 -18
  22. localcode/session.py +6 -0
  23. localcode/skills.py +5 -0
  24. localcode/tools/__init__.py +7 -0
  25. localcode/tools/todo_write.py +140 -0
  26. localcode/tools/write_file.py +21 -0
  27. localcode/tui/app.py +35 -63
  28. localcode/tui/screens/chat.py +288 -66
  29. localcode/tui/screens/model_picker.py +3 -2
  30. localcode/tui/screens/setup.py +17 -45
  31. localcode/tui/styles/app.tcss +13 -25
  32. localcode/tui/widgets/chat_log.py +58 -9
  33. localcode/tui/widgets/messages/diff.py +56 -0
  34. localcode/voice.py +4 -2
  35. {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/METADATA +1 -1
  36. {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/RECORD +40 -40
  37. {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/WHEEL +1 -1
  38. localcode/tool_parsing.py +0 -367
  39. {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/entry_points.txt +0 -0
  40. {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/licenses/LICENSE +0 -0
  41. {localcode-0.3.0.dist-info → localcode-0.3.2.dist-info}/top_level.txt +0 -0
localcode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -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
- tail = "\n".join([ln for ln in lines[-12:] if ln.strip()])[:1200]
209
- return (
210
- f"[older tool error preserved: {len(content)} chars, "
211
- f"{len(lines)} lines]\n{first_line}\n{tail}"
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
- args = json.loads((tc.get("function") or {}).get("arguments") or "{}")
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
- args = json.loads(fn.get("arguments") or "{}")
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
- args = json.loads(fn.get("arguments", "{}"))
773
- except json.JSONDecodeError:
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
- if not tool_calls:
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
- args = json.loads(fn.get("arguments", "{}"))
1332
- except json.JSONDecodeError:
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).
@@ -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
- args = json.loads(fn.get("arguments", "{}"))
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):