localcode 0.3.6__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 CHANGED
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.3.6"
3
+ __version__ = "0.3.7"
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 actually
1145
- # done, but the user watching a frozen screen:
1146
- #
1147
- # (A) Empty round — long reasoning, then server closes the
1148
- # stream with NO content and NO tool_calls. Images 107/108.
1149
- #
1150
- # (B) Intent-without-action model emits ONE sentence of
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 _edit_sequence_stub is not None:
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:
@@ -127,25 +127,12 @@ def detect_stall(
127
127
  if not content:
128
128
  return StallMode.EMPTY
129
129
 
130
- # NARRATION. Real failures showed the model
131
- # emitting hundreds of chars describing the edit it was about to
132
- # make, then ending the round with zero tool calls.
133
- #
134
- # Important: do NOT classify every short answer after a tool call as
135
- # narration. A question like "weather in ny?" legitimately uses one
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
+ )
@@ -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
- return f"File not found: {args['path']}"
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localcode
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: High-performance AI coding on consumer hardware.
5
5
  Author: LocalCode contributors
6
6
  License-Expression: Apache-2.0
@@ -1,4 +1,4 @@
1
- localcode/__init__.py,sha256=uPn5eZXztoMjXC5UlstTHEEu-a6qbzw_jfhmRaY1Qn4,49
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,10 +71,10 @@ 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=2O2iKFsCQmr1CoQq40Ls_NeBs7tFgcDVfkU8YhqXXdM,99864
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=oCghDm9BdHhvl98Kx8EikNuxSk4Geh-0cgAEhTpH-sw,16738
77
+ localcode/agent/recovery.py,sha256=-2vnYhhUGZD_reR6CaMOYNsG5XRf6YDsjTqF-1P-St4,16831
78
78
  localcode/agent/sections.py,sha256=9hnwaNritQHgn7qIGFBZ3ChZgMQXQcqtmpdbR1wafA0,8998
79
79
  localcode/agent/streaming.py,sha256=tW3LxUJlUyh3vRzeUsEo8lQcbc3DBvRtWF_2VLLQ9H8,11331
80
80
  localcode/agent/tool_execution.py,sha256=yfWZwOO3f5ICd2eqw6keg8sOKOIa3vFt4peFinmu5nM,14462
@@ -109,7 +109,7 @@ localcode/tools/launch_app.py,sha256=TVoq1ZQEIm2yQ607LGkJwbIC3TkvaLsBpTJgYIGX1gM
109
109
  localcode/tools/list_files.py,sha256=wN2dXU01rrKB_HGh6i0GfM9qvBwx7Qoa3S5WZlArma0,1205
110
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=u8ZySkvSj-ZzPVSdi1ybZ6yTgZ-yXiqAW54ZVdZWf-g,3017
112
+ localcode/tools/read_file.py,sha256=gH852nnPiVD2Go9VcdS_EFbtnqYaGOPyAk5jo1cNbX8,4556
113
113
  localcode/tools/skill_tool.py,sha256=vTVPyRWxICtDCVYWMiAMInGnc-wGAk2L1EZn-6e79nE,1478
114
114
  localcode/tools/syntax_check.py,sha256=wojE6Ke6nMe3ptyh9T7zLp5roGKYaez3TwSpnp8nE8A,4869
115
115
  localcode/tools/todo_write.py,sha256=8A4k9MzoqOtzJ6y2Gd3lEGkINcpV3JVCd2lYBmdd6qI,5801
@@ -132,9 +132,9 @@ localcode/tui/widgets/chat_log.py,sha256=D5DUkrQXL_ilxyC8N1xIPLw4Pqeh0jSWt1mPgho
132
132
  localcode/tui/widgets/voice_visualizer.py,sha256=zOhrIxaMcg-F2Y8LvHJTddq227phmiTjtEyRbkx1XxU,4709
133
133
  localcode/tui/widgets/messages/__init__.py,sha256=952AZ1qVMGUIqPIry7mwxnYbMkgPlcY5FAwZtEa5YPg,967
134
134
  localcode/tui/widgets/messages/diff.py,sha256=khDf_MThrQz0S62-t-m7i3OU5cdNolUk1TCeKMRPDQI,10910
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,,
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,,