localcode 0.4.0__py3-none-macosx_13_0_arm64.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 (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. localcode-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,297 @@
1
+ """Plateau detector — stop a model that has reached its ceiling.
2
+
3
+ Measured on HarnessBench 2026-09-08 (17 tasks, 27B model, 20 min cap): every
4
+ task that hit the cap had already reached the oracle score other frontends got
5
+ by STOPPING early. The model kept re-editing the same file and re-running the
6
+ same experiments instead of writing the remaining deliverables. None of the
7
+ existing breakers fire on that shape: each edit has different content, each
8
+ command a different output, and the calls all succeed.
9
+
10
+ "Progress" in a round is any of:
11
+ * a write/edit to a path not previously changed this turn (a NEW file or a
12
+ first touch of an existing one);
13
+ * a build/typecheck/test run that PASSED after the previous one this turn
14
+ failed, or the first pass this turn;
15
+ * a build/test run whose FAILURE SET moved on from the previous check this
16
+ turn: fewer failure lines, or the same count but a signature never seen
17
+ before this turn (a genuine debugging loop — the failure changes as the
18
+ model fixes things). A failure signature already seen this turn is NOT
19
+ progress: that is the true loop;
20
+ * a todo item transitioning to completed (or the list closing out).
21
+
22
+ Everything else — reads, greps, re-edits of already-edited files, identical
23
+ failing or repeat-passing test runs, throwaway experiments — is not progress.
24
+
25
+ Policy: after `nudge_after` consecutive no-progress rounds, ONE wrap-up nudge
26
+ naming the open plan items (finish the deliverables with what you have). After
27
+ `stop_after` more, the turn ends with an honest summary — unless the most
28
+ recent check this turn PASSED within the last `PLATEAU_PASS_WINDOW` rounds, in
29
+ which case the model is told ONCE to finish now and given
30
+ `PLATEAU_FINISH_ROUNDS` more rounds. Nothing fires in the first
31
+ `PLATEAU_GRACE_ROUNDS` rounds so a slow start is never mistaken for a plateau.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ from dataclasses import dataclass, field
37
+
38
+ __all__ = [
39
+ "PlateauTracker",
40
+ "PLATEAU_GRACE_ROUNDS",
41
+ "PLATEAU_NUDGE_AFTER",
42
+ "PLATEAU_STOP_AFTER",
43
+ "PLATEAU_PASS_WINDOW",
44
+ "PLATEAU_FINISH_ROUNDS",
45
+ "failure_signature",
46
+ ]
47
+
48
+ # Rounds (0-indexed) during which the detector never fires.
49
+ PLATEAU_GRACE_ROUNDS = 4
50
+ # Consecutive no-progress rounds before the wrap-up nudge.
51
+ PLATEAU_NUDGE_AFTER = 6
52
+ # Consecutive no-progress rounds AFTER the nudge before the turn ends.
53
+ PLATEAU_STOP_AFTER = 8
54
+ # A check that passed within this many rounds keeps the turn alive …
55
+ PLATEAU_PASS_WINDOW = 2
56
+ # … and the "finish now" nudge grants this many more rounds.
57
+ PLATEAU_FINISH_ROUNDS = 3
58
+
59
+
60
+ # ── failure-signature extraction ─────────────────────────────────────────
61
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
62
+ # A path with at least one directory component: keep only the basename.
63
+ _PATH_DIR_RE = re.compile(r"(?<![\w.])(?:[\w.~-]*/)+(?=[\w.-]+)")
64
+ _NUM_RE = re.compile(r"\d+(?:\.\d+)?")
65
+ _WS_RE = re.compile(r"\s+")
66
+ _FAILURE_LINE_RE = re.compile(
67
+ r"FAILED|AssertionError|error TS|\berror\b|Error\b|✕|●|\bFAIL\b|Traceback|panic:",
68
+ )
69
+ _NOISE_LINE_RE = re.compile(
70
+ # summaries that mention errors without BEING a failure line (numbers
71
+ # are already normalised to N when this runs)
72
+ r"^\s*(?:=+.*=+|-+.*-+|(?:N|\d+) (?:passed|failed|error)s?\b.*|found (?:N|\d+) errors?.*)\s*$",
73
+ re.IGNORECASE,
74
+ )
75
+
76
+
77
+ def normalise_check_output(text: str) -> str:
78
+ """Strip ANSI, path directories, numbers (durations, line numbers,
79
+ timestamps) and collapse whitespace so two runs of the same check with
80
+ the same *meaning* compare equal."""
81
+ text = _ANSI_RE.sub("", str(text or ""))
82
+ text = _PATH_DIR_RE.sub("", text)
83
+ text = _NUM_RE.sub("N", text)
84
+ return text
85
+
86
+
87
+ def failure_signature(output: str) -> frozenset[str]:
88
+ """The set of normalised failure lines of a build/test output.
89
+
90
+ Recognises pytest ("FAILED …", "AssertionError", "Error"), tsc
91
+ ("error TSNNNN"), jest ("✕", "●") and generic "FAIL"/"error" lines.
92
+ Count-style summary lines ("3 failed, 2 passed") are ignored so the set
93
+ reflects WHICH things fail, not how many the runner counted. A failed
94
+ run with no recognisable failure line falls back to its last non-empty
95
+ line so it still has a comparable identity."""
96
+ lines: set[str] = set()
97
+ last_nonempty = ""
98
+ for raw in normalise_check_output(output).splitlines():
99
+ line = _WS_RE.sub(" ", raw).strip()
100
+ if not line:
101
+ continue
102
+ last_nonempty = line
103
+ if line.startswith(("[exit code", "<UNTRUSTED_DATA", "</UNTRUSTED_DATA")):
104
+ # loop bookkeeping / the tool-result wrapper (whose `source=`
105
+ # attribute echoes the command) — not part of the check's output
106
+ continue
107
+ if _NOISE_LINE_RE.match(line):
108
+ continue
109
+ if _FAILURE_LINE_RE.search(line):
110
+ lines.add(line)
111
+ if not lines and last_nonempty:
112
+ lines.add(last_nonempty)
113
+ return frozenset(lines)
114
+
115
+
116
+ @dataclass
117
+ class PlateauTracker:
118
+ nudge_after: int = PLATEAU_NUDGE_AFTER
119
+ stop_after: int = PLATEAU_STOP_AFTER
120
+ grace_rounds: int = PLATEAU_GRACE_ROUNDS
121
+ pass_window: int = PLATEAU_PASS_WINDOW
122
+ finish_rounds: int = PLATEAU_FINISH_ROUNDS
123
+ no_progress_rounds: int = 0
124
+ total_no_progress: int = 0
125
+ nudged: bool = False
126
+ finish_nudged: bool = False
127
+ # Outcome of the most recent build/test this turn (None = none yet).
128
+ last_build_passed: bool | None = None
129
+ # Rounds since the most recent PASSING check (None = none this turn).
130
+ rounds_since_pass: int | None = None
131
+ # Failure-set tracking for the "debugging loop" progress signal.
132
+ last_failure_sig: frozenset[str] | None = None
133
+ seen_failure_sigs: set[frozenset[str]] = field(default_factory=set)
134
+ progress_log: list[str] = field(default_factory=list)
135
+
136
+ # ── progress classification ──────────────────────────────────────────
137
+ def round_made_progress(
138
+ self,
139
+ *,
140
+ changed_new_file: bool,
141
+ build_outcomes: list[bool],
142
+ todos_before: tuple[int, int],
143
+ todos_after: tuple[int, int],
144
+ build_outputs: list[str] | None = None,
145
+ ) -> bool:
146
+ """Classify one round. Also advances `last_build_passed` and the
147
+ failure-signature history. `build_outputs`, when given, is parallel
148
+ to `build_outcomes` (the raw output of each check)."""
149
+ progress = False
150
+ if changed_new_file:
151
+ progress = True
152
+ self.progress_log.append("new_file")
153
+ outputs = list(build_outputs or [])
154
+ for i, passed in enumerate(build_outcomes):
155
+ if passed:
156
+ if not self.last_build_passed:
157
+ # first pass this turn, or a pass after a failure
158
+ progress = True
159
+ self.progress_log.append("build_pass")
160
+ self.rounds_since_pass = 0
161
+ # a pass is an empty failure set: the next failure compares to it
162
+ self.last_failure_sig = frozenset()
163
+ else:
164
+ out = outputs[i] if i < len(outputs) else ""
165
+ sig = failure_signature(out)
166
+ prev = self.last_failure_sig
167
+ if prev is not None and len(sig) < len(prev):
168
+ progress = True
169
+ self.progress_log.append("failures_shrank")
170
+ elif prev is not None and sig != prev and sig not in self.seen_failure_sigs:
171
+ progress = True
172
+ self.progress_log.append("new_failure_set")
173
+ self.seen_failure_sigs.add(sig)
174
+ self.last_failure_sig = sig
175
+ self.last_build_passed = passed
176
+ open_before, done_before = todos_before
177
+ open_after, done_after = todos_after
178
+ if done_after > done_before or (open_before > 0 and open_after == 0):
179
+ progress = True
180
+ self.progress_log.append("todo_completed")
181
+ return progress
182
+
183
+ def recent_pass(self) -> bool:
184
+ """True if a check passed in this round or the last `pass_window`."""
185
+ return self.rounds_since_pass is not None and self.rounds_since_pass <= self.pass_window
186
+
187
+ def observe_round(
188
+ self,
189
+ *,
190
+ round_idx: int,
191
+ changed_new_file: bool,
192
+ build_outcomes: list[bool],
193
+ todos_before: tuple[int, int],
194
+ todos_after: tuple[int, int],
195
+ build_outputs: list[str] | None = None,
196
+ ) -> str:
197
+ """Feed one finished tool round. Returns "" (nothing to do),
198
+ "wrap_up" (inject the wrap-up nudge), "finish_now" (a check just
199
+ passed — tell the model to finish instead of stopping it) or
200
+ "exhausted" (end the turn)."""
201
+ # Age the last pass BEFORE classifying so a pass in this round is 0.
202
+ if self.rounds_since_pass is not None:
203
+ self.rounds_since_pass += 1
204
+ if self.round_made_progress(
205
+ changed_new_file=changed_new_file,
206
+ build_outcomes=build_outcomes,
207
+ build_outputs=build_outputs,
208
+ todos_before=todos_before,
209
+ todos_after=todos_after,
210
+ ):
211
+ self.no_progress_rounds = 0
212
+ return ""
213
+ self.no_progress_rounds += 1
214
+ self.total_no_progress += 1
215
+ if round_idx < self.grace_rounds:
216
+ return ""
217
+ if not self.nudged:
218
+ if self.no_progress_rounds < self.nudge_after:
219
+ return ""
220
+ self.nudged = True
221
+ self.no_progress_rounds = 0
222
+ return "wrap_up"
223
+ if self.no_progress_rounds < self.stop_after:
224
+ return ""
225
+ if self.recent_pass():
226
+ # Never end a turn right after a passing check: the model may be
227
+ # one write away from the deliverable. Say so once, grant a few
228
+ # rounds; if the pass is still fresh at the next threshold, keep
229
+ # waiting rather than cutting off a finishing model.
230
+ if not self.finish_nudged:
231
+ self.finish_nudged = True
232
+ # exactly `finish_rounds` more no-progress rounds before the
233
+ # next threshold (may go negative when stop_after is small)
234
+ self.no_progress_rounds = self.stop_after - self.finish_rounds
235
+ return "finish_now"
236
+ return ""
237
+ return "exhausted"
238
+
239
+ # ── text ─────────────────────────────────────────────────────────────
240
+ @staticmethod
241
+ def _open_items(open_todos: list[str] | None) -> str:
242
+ items = [str(t).strip() for t in (open_todos or []) if str(t).strip()]
243
+ if not items:
244
+ return ""
245
+ shown = items[:8]
246
+ bullets = "\n".join(f"- {t}" for t in shown)
247
+ more = "" if len(items) <= 8 else f"\n- … (+{len(items) - 8} more)"
248
+ return f"Open plan items:\n{bullets}{more}"
249
+
250
+ def wrap_up_nudge(self, open_todos: list[str] | None = None) -> str:
251
+ items = self._open_items(open_todos)
252
+ deliverables = (
253
+ "(1) write every open plan item below that does not exist yet, "
254
+ "using the best approach you have"
255
+ if items
256
+ else "(1) write every deliverable the user named that does not exist "
257
+ "yet, using the best approach you have"
258
+ )
259
+ text = (
260
+ f"SYSTEM: No new progress for {self.nudge_after} steps — you have "
261
+ "reached the limit of this approach. Stop experimenting. Now: "
262
+ f"{deliverables}; (2) where a requirement "
263
+ "cannot be met, say so explicitly in the deliverable or README "
264
+ "instead of retrying; (3) run the project's own check ONCE; "
265
+ "(4) finish."
266
+ )
267
+ if items:
268
+ text += "\n" + items
269
+ return text
270
+
271
+ def finish_now_nudge(self) -> str:
272
+ return (
273
+ "SYSTEM: Your check passed — finish now: write any remaining "
274
+ "deliverable and stop."
275
+ )
276
+
277
+ def exhausted_summary(self, *, changed_files: list[str], open_todos: list[str]) -> str:
278
+ delivered = (
279
+ "Delivered (files changed this turn): " + ", ".join(f"`{p}`" for p in changed_files[:20])
280
+ + ("" if len(changed_files) <= 20 else f" (+{len(changed_files) - 20} more)")
281
+ if changed_files
282
+ else "Delivered: no files were changed this turn."
283
+ )
284
+ items = self._open_items(open_todos)
285
+ if items:
286
+ not_done = "Not finished. " + items
287
+ else:
288
+ not_done = (
289
+ "Not finished: any remaining requirements the model did not write "
290
+ "out — check the deliverables above against the request."
291
+ )
292
+ return (
293
+ "LocalCode stopped this turn: the model made no new progress for "
294
+ f"{self.total_no_progress} steps (kept re-editing the same files and "
295
+ "re-running the same checks) and did not wrap up when asked. The "
296
+ f"task is incomplete.\n\n{delivered}\n{not_done}"
297
+ )
@@ -0,0 +1,91 @@
1
+ """Turn-level state for the project-typecheck completion gate.
2
+
3
+ The gate has to answer one question honestly: *did a checker actually prove this
4
+ project builds?* Three states, not two — green, red, and NEVER ANSWERED. The
5
+ third is the dangerous one: a timeout, a checker that could not execute, or a
6
+ TypeScript reference graph that was only partially covered all used to look
7
+ exactly like green.
8
+
9
+ Kept out of `loop.py` because the decision spans the whole turn (a failure in
10
+ round 3 must still block completion in round 6) and because the loop itself has
11
+ no test seam — this does.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from ..tools.project_check import CheckOutcome
16
+
17
+ __all__ = ["ProjectCheckGate", "RED", "UNVERIFIED", "OK"]
18
+
19
+ # What the caller should do with the round.
20
+ RED = "red" # real diagnostics — feed them back
21
+ UNVERIFIED = "unverified" # no verdict — retry, then block completion
22
+ OK = "ok" # a checker ran green, or none was applicable
23
+
24
+
25
+ class ProjectCheckGate:
26
+ """Remembers, for the whole turn, whether the project check ever answered."""
27
+
28
+ def __init__(self, max_retries: int = 2) -> None:
29
+ self.max_retries = max_retries
30
+ self.unverified = ""
31
+ self.red = ""
32
+ self.retries = 0
33
+
34
+ def observe(self, outcome: CheckOutcome) -> str:
35
+ """Record a completed check. Returns RED / UNVERIFIED / OK."""
36
+ if outcome.is_red:
37
+ # Red is a verdict: the checker worked. Clear any earlier no-verdict.
38
+ self.unverified = ""
39
+ # ...but REMEMBER it. The loop feeds the diagnostics back and asks
40
+ # for a fix, and that nudge is bounded so an unfixable project can't
41
+ # spin. Running out of nudges is not the project turning green: it
42
+ # used to drop the red on the floor and let the turn complete as
43
+ # verified with a broken build. Same rule the no-verdict path
44
+ # already states, applied to the verdict we actually have.
45
+ self.red = outcome.detail or outcome.label or "the project typecheck reported errors"
46
+ return RED
47
+ if outcome.status in ("timed_out", "failed"):
48
+ self.unverified = outcome.detail or outcome.status
49
+ return UNVERIFIED
50
+ # Only a checker that actually ran to GREEN clears an earlier verdict.
51
+ # `unavailable` reaches here too, and must NOT: "no checker available"
52
+ # is the absence of evidence, so treating it as clearing would let a
53
+ # project that went red in round 2 launder itself clean in round 3 the
54
+ # moment the checker stopped being detectable (node_modules removed
55
+ # mid-turn, a tsconfig edited into an unparseable state).
56
+ if outcome.is_verified:
57
+ self.unverified = ""
58
+ self.red = ""
59
+ return OK
60
+
61
+ def observe_exception(self, exc: BaseException) -> str:
62
+ """An unexpected failure is a FAILED verification, never a clean one."""
63
+ self.unverified = (
64
+ f"the project typecheck could not be run ({exc.__class__.__name__})")
65
+ return UNVERIFIED
66
+
67
+ def consume_retry(self) -> bool:
68
+ """True if another round should be forced. Bounded, so an unfixable
69
+ environment can't spin — but running out of retries does NOT make the
70
+ project verified; `blocks_completion` stays True."""
71
+ if self.retries >= self.max_retries:
72
+ return False
73
+ self.retries += 1
74
+ return True
75
+
76
+ def blocks_completion(self) -> bool:
77
+ """A check that never returned a verdict — or that returned errors
78
+ nobody fixed — is not evidence of a working build, whatever else the
79
+ verification registry happens to hold."""
80
+ return bool(self.unverified or self.red)
81
+
82
+ def result_note(self) -> str:
83
+ """Text for the FINAL RESULT, so the reason reaches the TUI and `--json`
84
+ (unlike print_info, which is invisible in the TUI and suppressed under
85
+ --json)."""
86
+ if self.unverified:
87
+ return f"\n\nProject typecheck was not verified: {self.unverified}"
88
+ if self.red:
89
+ return ("\n\nThe project typecheck is still reporting errors:\n"
90
+ f"{self.red}")
91
+ return ""
@@ -0,0 +1,264 @@
1
+ """Prompt assembly helpers for the agent loop."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .prompts import (
10
+ REASONING_RULES,
11
+ SYSTEM_PROMPT,
12
+ _load_project_instructions,
13
+ model_identity_line,
14
+ project_stack_line,
15
+ )
16
+ from .sections import Section, SectionContext, compose_system_prompt, default_sections
17
+ __all__ = [
18
+ "PromptBuildResult",
19
+ "build_agent_system_prompt",
20
+ "build_task_goal_block",
21
+ "build_dynamic_skills_block",
22
+ "build_target_grounding_block",
23
+ "build_incremental_milestones_block",
24
+ ]
25
+
26
+
27
+ @dataclass
28
+ class PromptBuildResult:
29
+ system_prompt: str
30
+ selected_skills: list[str]
31
+ selected_skill_origins: list[str]
32
+ selected_skill_chars: int
33
+ skill_candidates: list[dict[str, str]]
34
+
35
+
36
+ def build_agent_system_prompt(
37
+ *,
38
+ app: Any,
39
+ user_text: str,
40
+ goal_state: Any,
41
+ task_state: Any,
42
+ base_system_prompt: str,
43
+ network_status: str,
44
+ use_thinking: bool,
45
+ ) -> PromptBuildResult:
46
+ project_instructions = _load_project_instructions(app.repo_root)
47
+ task_goal_block = build_task_goal_block(user_text, goal_state, task_state)
48
+ target_grounding_block = build_target_grounding_block(app.repo_root, goal_state)
49
+ milestones_block = build_incremental_milestones_block(goal_state)
50
+ skills_block, skill_names, skill_origins, skill_chars, skill_candidates = (
51
+ build_dynamic_skills_block(app, user_text)
52
+ )
53
+ # Tell the model which local model/quant it's actually running on, so
54
+ # "which model are you using?" gets a correct answer instead of a guess.
55
+ # Derived per-session from the active model (config.runtime.model →
56
+ # catalog friendly name + quant). Folded into the front of the
57
+ # network_status slot so it renders right after the "Working directory"
58
+ # line, with no change to the section registry. Empty when no model is
59
+ # configured (keeps the prompt prefix byte-identical for that case).
60
+ identity_line = ""
61
+ try:
62
+ identity_line = model_identity_line(str(app.config.runtime.model or ""))
63
+ except Exception:
64
+ identity_line = ""
65
+ # Name the detected project stack (one line) so the model writes code
66
+ # in the project's actual language/conventions instead of falling back
67
+ # to Python idioms. Cheap, marker-file based, and best-effort: any
68
+ # failure leaves the slot empty so the cached prefix is unaffected.
69
+ stack_line = ""
70
+ try:
71
+ stack_line = project_stack_line(app.repo_root)
72
+ except Exception:
73
+ stack_line = ""
74
+ network_status_with_identity = identity_line + stack_line + network_status
75
+
76
+ def _render_caller_template(ctx: SectionContext) -> str:
77
+ return base_system_prompt.format(
78
+ cwd=ctx.cwd,
79
+ project_instructions=ctx.project_instructions,
80
+ network_status=ctx.network_status,
81
+ skills_block=ctx.skills_block,
82
+ reasoning_rules=ctx.reasoning_rules,
83
+ )
84
+
85
+ section_ctx = SectionContext(
86
+ cwd=app.repo_root,
87
+ project_instructions=project_instructions,
88
+ network_status=network_status_with_identity,
89
+ skills_block=skills_block,
90
+ reasoning_rules=REASONING_RULES if use_thinking else "",
91
+ )
92
+ using_default_prompt = base_system_prompt == SYSTEM_PROMPT
93
+ system = compose_system_prompt(
94
+ section_ctx,
95
+ sections=default_sections()
96
+ if using_default_prompt
97
+ else [Section(id="caller_template", render=_render_caller_template, cacheable=False)],
98
+ emit_cache_marker=using_default_prompt,
99
+ )
100
+ return PromptBuildResult(
101
+ system_prompt=system + task_goal_block + target_grounding_block + milestones_block,
102
+ selected_skills=skill_names,
103
+ selected_skill_origins=skill_origins,
104
+ selected_skill_chars=skill_chars,
105
+ skill_candidates=skill_candidates,
106
+ )
107
+
108
+
109
+ def build_target_grounding_block(repo_root: Any, goal_state: Any) -> str:
110
+ """One short block that pins the target directory for build-type turns.
111
+
112
+ Grounds the model so it stops thrashing between the repo root and a subdir.
113
+ Returned only for build_app-shaped goals; empty otherwise so the cached
114
+ prompt prefix is unaffected for ordinary turns.
115
+ """
116
+ goal_type = str(getattr(goal_state, "goal_type", "") or "")
117
+ if goal_type != "build_app":
118
+ return ""
119
+ try:
120
+ root = str(Path(repo_root).resolve())
121
+ except Exception:
122
+ root = str(repo_root or "")
123
+ if not root:
124
+ return ""
125
+ # Don't anchor to $HOME. When localcode is launched outside any project
126
+ # (no .git/package.json/etc. marker found), repo_root falls back to the
127
+ # user's home dir. Telling the model "the project root is /Users/<name>,
128
+ # create all files under that root" then makes it try to build in $HOME —
129
+ # or conflict with the path the user actually named in the task ("work at
130
+ # root vs. the Anki/... path they gave"). In that unanchored case, emit no
131
+ # directive and let the model follow the location from the user's request.
132
+ try:
133
+ if Path(root) == Path.home():
134
+ return ""
135
+ except Exception:
136
+ pass
137
+ return (
138
+ "\n\nTarget location (work here, do not drift):\n"
139
+ f"- The project root is: {root}\n"
140
+ "- Create and edit all files under that root. Do not switch between the "
141
+ "root and an unrelated subdirectory between steps; decide once and stay there.\n"
142
+ )
143
+
144
+
145
+ def build_incremental_milestones_block(goal_state: Any) -> str:
146
+ """Lightweight guidance to build a large app in verifiable milestones.
147
+
148
+ For a build_app goal, instruct the model to make incremental, verified
149
+ progress — scaffold first, then one feature at a time, building/running
150
+ between steps — instead of emitting an entire app in a single giant
151
+ write (which is what hits the per-round token wall and lands unverified
152
+ code). This is intentionally a prompt-context guidance block rather than
153
+ a mechanism: cheap, no per-round bookkeeping, and the build-verification
154
+ nudge in the loop already enforces the "build before finishing" half.
155
+
156
+ Empty for non-build_app goals so the cached prompt prefix is unaffected.
157
+ """
158
+ goal_type = str(getattr(goal_state, "goal_type", "") or "")
159
+ if goal_type != "build_app":
160
+ return ""
161
+ return (
162
+ "\n\nBuild in verifiable milestones (do not emit the whole app at once):\n"
163
+ "1. SCAFFOLD: create the minimal runnable skeleton first — entrypoint, "
164
+ "project/config file, and a placeholder that starts. Run it to confirm "
165
+ "it launches before adding features.\n"
166
+ "2. ONE FEATURE AT A TIME: add a single feature per step, in small files. "
167
+ "After each feature, build/typecheck or run the relevant check and FIX "
168
+ "any error before moving to the next.\n"
169
+ "3. KEEP IT RUNNABLE: never leave the project in a broken state between "
170
+ "steps. If a step is too large for one tool call, split it across calls; "
171
+ "don't drop scope.\n"
172
+ "4. FINISH: only report done after a final build/run passes with no "
173
+ "errors. Don't claim it works without having run it.\n"
174
+ )
175
+
176
+
177
+ def build_task_goal_block(user_text: str, goal_state: Any, task_state: Any) -> str:
178
+ # The general agentic goal block ("Current goal: X / Continue until
179
+ # complete and verified" plus stage guidance, port hints, build_app
180
+ # phase ladder, user-requested-features checklist) was removed
181
+ # 2026-04-29. None of it measurably prevented the failures it was
182
+ # added to mitigate (loops, over-verification, premature stops on
183
+ # Qwen3.6 IQ2_M); mainstream agentic CLIs don't do it; and the
184
+ # "Continue until complete" framing actively pushed the model into
185
+ # eager exploration on trivial inputs ("hi" → list_files + 3× read
186
+ # README). Only one piece survived: the question-type protective
187
+ # wording, which has a regression test and prevents auto-resume of
188
+ # a prior failed/blocked task on a clarifying question.
189
+ if goal_state.goal_type == "question":
190
+ block = (
191
+ "\n\nCurrent goal:\n"
192
+ f"- Answer this question directly: {user_text.strip()}\n"
193
+ "- This is a question/diagnostic turn, not permission to resume prior coding work.\n"
194
+ "- Do not continue, rebuild, edit, launch, or verify a previous task unless the user explicitly asks you to do that.\n"
195
+ "- If local evidence is needed, inspect only the smallest relevant files/logs, then answer.\n"
196
+ )
197
+ if task_state is not None:
198
+ status = getattr(task_state, "status", "") or ""
199
+ slug = getattr(task_state, "task_slug", "") or ""
200
+ if status or slug:
201
+ block += f"- Previous task context: {slug or 'task'} status={status or 'unknown'}.\n"
202
+ return block
203
+ return ""
204
+
205
+
206
+ def build_dynamic_skills_block(
207
+ app: Any,
208
+ user_text: str,
209
+ ) -> tuple[str, list[str], list[str], int, list[dict[str, str]]]:
210
+ try:
211
+ from ..skills import (
212
+ dynamic_skill_block,
213
+ dynamic_skill_candidates,
214
+ load_registry,
215
+ select_dynamic_skills,
216
+ )
217
+
218
+ registry = load_registry(app.repo_root)
219
+ catalog = registry.listing()
220
+ catalog_block = (
221
+ "\n\n## Available Skills\n"
222
+ "Load a matching recipe with the skill tool; full bodies are deferred.\n"
223
+ + catalog
224
+ if catalog else ""
225
+ )
226
+ recent_tools = list(getattr(app, "_recent_tool_names", []) or [])
227
+ last_failed_tool = str(getattr(app, "_last_failed_tool_name", "") or "")
228
+ candidates = dynamic_skill_candidates(
229
+ user_text,
230
+ recent_tools=recent_tools,
231
+ last_failed_tool=last_failed_tool,
232
+ )
233
+ selected = select_dynamic_skills(
234
+ user_text,
235
+ registry,
236
+ recent_tools=recent_tools,
237
+ last_failed_tool=last_failed_tool,
238
+ )
239
+ # Gate on selected count rather than letting dynamic_skill_block
240
+ # return its own placeholder/header for an empty list. With no
241
+ # selected skills (the steady-state today: events show
242
+ # selected_count=0 every turn), the prompt template's
243
+ # {skills_block} slot becomes an empty string — saves a few
244
+ # hundred bytes of header/scaffolding per round and, more
245
+ # importantly, keeps the prompt prefix byte-identical to other
246
+ # turns so llama.cpp's prefix-cache can hit.
247
+ if not selected:
248
+ return (
249
+ catalog_block,
250
+ [],
251
+ [],
252
+ 0,
253
+ candidates,
254
+ )
255
+ block = catalog_block + dynamic_skill_block(selected)
256
+ return (
257
+ block,
258
+ [skill.name for skill in selected],
259
+ [skill.origin for skill in selected],
260
+ len(block),
261
+ candidates,
262
+ )
263
+ except Exception:
264
+ return "", [], [], 0, []