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,1152 @@
1
+ """Context-management pipeline — redaction, compaction, aging.
2
+
3
+ Extracted from agent/__init__.py during T0.1-c. No behaviour change —
4
+ every function here is verbatim from the pre-split module. The loop
5
+ imports them from `.context` instead of having them in the same file.
6
+
7
+ Why these nine functions together:
8
+ They all shape the `messages` list that goes to the model. Some
9
+ truncate tool results inline (`_truncate_result`). Some age older
10
+ tool-call payloads so the context window doesn't explode
11
+ (`_redact_old_write_args`, `_redact_duplicate_reads`,
12
+ `_compact_old_tool_results`). Some bound / estimate the total
13
+ (`_msg_bytes`, `_estimate_tokens`). The top-level `_prepare_model_messages`
14
+ composes the aging pipeline in order. `_compact_messages` is the
15
+ LLM-summary fallback when the deterministic pipeline still leaves
16
+ the context too big. `_summarize_args` formats tool-call args for
17
+ both compaction-summary text and live tool logging.
18
+
19
+ Nothing here is loop logic or model-specific. Pure functions over the
20
+ message-list shape we pass to the runtime.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from typing import TYPE_CHECKING
26
+
27
+ from .constants import (
28
+ COMPACT_KEEP_RECENT_TOOL_RESULTS,
29
+ COMPACT_MIN_CONTENT_CHARS,
30
+ REDACT_KEEP_RECENT_WRITES,
31
+ REDACT_MIN_CONTENT_CHARS,
32
+ READ_UNCHANGED_STUB_PREFIX,
33
+ RESULT_LIMITS,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ from ..output import OutputManager
38
+
39
+
40
+ # Every helper here is intentionally private (underscore-prefixed).
41
+ # The aging pipeline internals may be reshaped freely as long as
42
+ # `_prepare_model_messages` and `_compact_messages` keep the
43
+ # behaviour the loop expects. `__all__` is empty deliberately —
44
+ # callers import by name rather than through a public surface.
45
+ __all__: list[str] = []
46
+
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 chmod_quiet, global_state_dir, write_private
60
+ spill_dir = global_state_dir() / "tool_output"
61
+ existed = spill_dir.is_dir()
62
+ spill_dir.mkdir(parents=True, exist_ok=True)
63
+ if not existed:
64
+ chmod_quiet(spill_dir, 0o700)
65
+ # Reap spills older than 7 days so this never grows unbounded.
66
+ try:
67
+ cutoff = _time.time() - 7 * 86400
68
+ for old in spill_dir.glob("*.txt"):
69
+ if old.stat().st_mtime < cutoff:
70
+ old.unlink(missing_ok=True)
71
+ except Exception:
72
+ pass
73
+ digest = hashlib.sha1(result.encode("utf-8", "replace")).hexdigest()[:12]
74
+ path = spill_dir / f"{tool_name}_{digest}.txt"
75
+ if not path.exists():
76
+ # Spilled tool output can be file contents, command output or
77
+ # anything else the agent read — owner-read only.
78
+ write_private(path, result)
79
+ return str(path)
80
+ except Exception:
81
+ return None
82
+
83
+
84
+ # How much of the context window (in chars) a single tool result may occupy,
85
+ # per tool. The static RESULT_LIMITS values act as the FLOOR, so small machines
86
+ # (64K window ≈ 224K chars → these fractions fall below the floor) are
87
+ # byte-identical to before, while a big machine (256K ≈ 900K chars) lets the
88
+ # model keep far more of the output it has room for instead of a fixed 20–50K
89
+ # slice. grep matches are dense/repetitive so get the smallest share; read_file
90
+ # is the primary code-ingestion path so gets the most.
91
+ _RESULT_LIMIT_WINDOW_FRACTION: dict[str, float] = {
92
+ "grep": 0.03,
93
+ "bash": 0.05,
94
+ "read_file": 0.08,
95
+ "default": 0.08,
96
+ }
97
+
98
+
99
+ def _dynamic_result_limit(tool_name: str, ctx_tokens: int) -> int:
100
+ """Per-tool truncation budget (chars), scaled to the real context window.
101
+
102
+ Returns the static RESULT_LIMITS value as a floor; on a large window the
103
+ limit grows to a fraction of the window so the model isn't starved of output
104
+ it has room for. web_search and any tool without a fraction stay at the
105
+ fixed floor (web results don't benefit from window-scaling).
106
+ """
107
+ floor = RESULT_LIMITS.get(tool_name, RESULT_LIMITS["default"])
108
+ frac = _RESULT_LIMIT_WINDOW_FRACTION.get(tool_name)
109
+ if not ctx_tokens or frac is None:
110
+ return floor
111
+ # CHARS_PER_TOKEN=4 is the internal estimate; use 3.5 to match loop.py's
112
+ # tokens→chars conversion and stay conservative.
113
+ return max(floor, int(ctx_tokens * 3.5 * frac))
114
+
115
+
116
+ def _truncate_result(result: str, tool_name: str, ctx_tokens: int = 0) -> str:
117
+ """Truncate a tool result to its per-tool size limit with a
118
+ strategy tuned to what each tool's output actually looks like.
119
+
120
+ `ctx_tokens` (the model's real context window) scales the limit up on big
121
+ machines; 0 falls back to the static floor (small machines unchanged).
122
+
123
+ Strategies:
124
+ • `read_file` — keep HEAD only. File content is ordered; the
125
+ model can paginate with `offset=` to see more. Middle-dropping
126
+ gives the model the illusion of complete context when it only
127
+ saw half. Replace dropped tail with an explicit offset hint.
128
+ • `grep` — keep HEAD only. Individual matches are small; losing
129
+ trailing matches to a count is fine (the hint tells the
130
+ model to narrow the pattern if it needs more).
131
+ • `bash` — prioritise the TAIL (most recent output, which
132
+ typically contains the error / result) plus a small HEAD
133
+ (which often contains the command echo + initial status).
134
+ • default — original middle-drop, a reasonable fallback for
135
+ tools whose output structure we haven't characterised.
136
+
137
+ Returns the result unchanged when it's within the limit.
138
+ """
139
+ limit = _dynamic_result_limit(tool_name, ctx_tokens)
140
+ if len(result) <= limit:
141
+ return result
142
+
143
+ dropped = len(result) - limit
144
+ # Spill the FULL output to disk and point the model at it, so it fetches
145
+ # the exact part it needs instead of re-running the command to "see the
146
+ # rest". Appended to every strategy's hint below.
147
+ _spill = _spill_tool_output(result, tool_name)
148
+ spill_hint = (
149
+ f" Full output saved to {_spill} — read_file (with offset) or grep it "
150
+ f"to see the rest; do NOT re-run the command."
151
+ if _spill else ""
152
+ )
153
+
154
+ if tool_name == "read_file":
155
+ # Keep the HEAD; if we can find line numbers in the content
156
+ # (read_file numbers lines "N\t..."), compute the offset the
157
+ # model should call with next.
158
+ head = result[:limit]
159
+ # Estimate next-offset by counting newlines in head + 1.
160
+ # Line-numbered output makes this tighter than any heuristic.
161
+ last_numbered = None
162
+ for line in head.rsplit("\n", 200)[-200:]:
163
+ # Lines look like "1234\tsome content". Pull leading int.
164
+ if "\t" in line and line.split("\t", 1)[0].isdigit():
165
+ last_numbered = int(line.split("\t", 1)[0])
166
+ hint = (
167
+ f"\n\n[truncated — {dropped} more chars not shown. "
168
+ 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}]"
169
+ )
170
+ return head + hint
171
+
172
+ if tool_name == "grep":
173
+ # Keep HEAD only. Grep output is one-match-per-line; losing
174
+ # trailing matches to a counter is OK — if the model needed
175
+ # more hits, it should narrow the pattern.
176
+ head = result[:limit]
177
+ # Count how many lines we've dropped so the model knows
178
+ # whether to broaden or narrow.
179
+ dropped_lines = result[limit:].count("\n") + 1
180
+ hint = (
181
+ f"\n\n[truncated — {dropped_lines} more match-lines not shown. "
182
+ f"Narrow with a more specific pattern or `include=*.py` to see them.{spill_hint}]"
183
+ )
184
+ return head + hint
185
+
186
+ if tool_name == "bash":
187
+ # Bash output is the noisiest path in long app tasks. Prefer a
188
+ # structured summary over raw head/tail when the output looks
189
+ # like a directory tree or a huge install/build log; otherwise
190
+ # keep the most recent tail with a short head for context.
191
+ lines = result.splitlines()
192
+ non_empty = [ln for ln in lines if ln.strip()]
193
+ if len(lines) >= 40:
194
+ first_sig = "\n".join(non_empty[:8])
195
+ last_sig = "\n".join(non_empty[-12:]) if len(non_empty) > 12 else ""
196
+ marker = (
197
+ f"\n\n[... {dropped} chars of bash output compressed.{spill_hint} ...]\n\n"
198
+ )
199
+ if any(token in result for token in ("backend", "frontend", "src/", "node_modules", "package.json", "pyproject.toml")):
200
+ tree_hint = (
201
+ f"[bash output summarized: {len(lines)} lines, "
202
+ f"{len(non_empty)} non-empty lines]\n"
203
+ )
204
+ return tree_hint + first_sig + marker + last_sig
205
+ if any(token in result.lower() for token in ("vite", "webpack", "npm run", "uvicorn", "fastapi", "build", "install")):
206
+ log_hint = (
207
+ f"[bash output summarized: {len(lines)} lines, build/install log]\n"
208
+ )
209
+ return log_hint + first_sig + marker + last_sig
210
+ # Prioritise TAIL (recent output = errors, final result)
211
+ # with a small HEAD for command echo / initial status.
212
+ head_size = min(limit // 4, 1000)
213
+ tail_size = limit - head_size - 64 # 64 chars for the marker line
214
+ head = result[:head_size]
215
+ tail = result[-tail_size:]
216
+ marker = f"\n\n[... {dropped} chars of middle output dropped.{spill_hint} ...]\n\n"
217
+ return head + marker + tail
218
+
219
+ # Default — middle-drop.
220
+ half = limit // 2
221
+ return (
222
+ result[:half]
223
+ + f"\n\n[...{dropped} chars truncated.{spill_hint} ...]\n\n"
224
+ + result[-half:]
225
+ )
226
+
227
+
228
+ # COMPACT_KEEP_RECENT_TOOL_RESULTS / COMPACT_MIN_CONTENT_CHARS moved
229
+ # to constants.py during the T0.1 split; re-exported at the top of
230
+ # this module for back-compat.
231
+
232
+ # Only these tools' RESULTS are safe to age. They are REPLAYABLE
233
+ # OBSERVATIONS: their output is a snapshot of external state (the filesystem,
234
+ # a search, a shell command) that the model can regenerate on demand by
235
+ # re-running the exact same call. Aging them is lossless-in-effect — the model
236
+ # can always get them back. This mirrors claude-code's `COMPACTABLE_TOOLS` set
237
+ # (microCompact.ts) and opencode's prune (which protects non-replayable tool
238
+ # output). Deliberately EXCLUDED: write_file / edit_file / multi_edit /
239
+ # append_file. Their result is the DIFF of what the model just wrote — the only
240
+ # record in history of the change it made. Aging it away is what made the model
241
+ # "forget" what it wrote after a build failure and rewrite whole files from
242
+ # scratch (churn). Anything we can't attribute to a replayable tool is also left
243
+ # intact (conservative default).
244
+ _REPLAYABLE_TOOLS: frozenset[str] = frozenset({
245
+ "read_file", "grep", "glob", "list_files", "bash",
246
+ "web_search", "web_fetch",
247
+ })
248
+
249
+
250
+ def _tool_names_by_call_id(messages: list[dict]) -> dict[str, str]:
251
+ """Map tool_call_id -> tool name by scanning assistant tool_calls, so a
252
+ tool-role result (which only carries `tool_call_id`) can be attributed to
253
+ the tool that produced it."""
254
+ names: dict[str, str] = {}
255
+ for m in messages:
256
+ if m.get("role") != "assistant":
257
+ continue
258
+ for tc in m.get("tool_calls") or []:
259
+ tc_id = tc.get("id")
260
+ name = ((tc.get("function") or {}).get("name") or "").strip()
261
+ if tc_id and name:
262
+ names[tc_id] = name
263
+ return names
264
+
265
+
266
+ def _result_tool_name(m: dict, id_to_name: dict[str, str]) -> str:
267
+ """Best-effort tool name for a tool-role result: explicit `name` field if
268
+ present (some providers set it), else via the tool_call_id map."""
269
+ explicit = (m.get("name") or "").strip()
270
+ if explicit:
271
+ return explicit
272
+ return id_to_name.get(m.get("tool_call_id"), "")
273
+
274
+
275
+ def _compact_old_tool_results(
276
+ messages: list[dict], keep_recent: int = COMPACT_KEEP_RECENT_TOOL_RESULTS
277
+ ) -> list[dict]:
278
+ """Return a copy of `messages` with older REPLAYABLE-OBSERVATION tool
279
+ results summarized.
280
+
281
+ Gated on `Feature.TOOL_RESULT_AGING` — when disabled the caller gets
282
+ the input list back unchanged, which is what eval uses to A/B
283
+ "how much does aging actually save per turn?"
284
+
285
+ We keep the last `keep_recent` tool results verbatim (the model usually
286
+ only needs the recent ones to decide the next step) and replace earlier
287
+ ones with a compact "[summarized ...]" placeholder — but ONLY for tools in
288
+ `_REPLAYABLE_TOOLS`. write_file/edit_file/multi_edit results (the diffs of
289
+ what the model wrote) are NEVER aged, so the model doesn't lose sight of
290
+ its own changes. `keep_recent` scales with the context window (more on a big
291
+ machine — see `_prepare_model_messages`), floored at 1 so aging never clears
292
+ the entire working set. User/assistant/system messages pass through
293
+ unchanged, and `tool_call_id` is preserved so the chat protocol still
294
+ reconciles ids correctly.
295
+ """
296
+ from ..features import Feature, is_enabled
297
+ if not is_enabled(Feature.TOOL_RESULT_AGING):
298
+ return messages
299
+ # Floor at 1 so we never age away the entire working set of tool output.
300
+ keep_recent = max(1, keep_recent)
301
+ id_to_name = _tool_names_by_call_id(messages)
302
+ # Protect the LATEST read_file result for the few HOTTEST (most-recently-read)
303
+ # files from aging. read_file is "replayable", so aging its result out is
304
+ # cheap in theory — but in practice the model REPLAYS it (re-reads), and real
305
+ # traces showed ~68% of all reads were re-reads of a handful of actively-used
306
+ # files (App.tsx read 40× after being written). Keeping each hot file's
307
+ # current content in context (like write/edit diffs, which are never aged)
308
+ # makes the prompt's "don't re-read, it's already above" actually true.
309
+ # Bounded to _PROTECT_HOT_READS distinct paths so prefill/TTFT stays capped.
310
+ _PROTECT_HOT_READS = 5
311
+ _read_paths: dict[str, str] = {}
312
+ for _m in messages:
313
+ if _m.get("role") != "assistant":
314
+ continue
315
+ for _tc in _m.get("tool_calls") or []:
316
+ if ((_tc.get("function") or {}).get("name") or "").strip() != "read_file":
317
+ continue
318
+ _tcid = _tc.get("id")
319
+ if not _tcid:
320
+ continue
321
+ try:
322
+ _raw = (_tc.get("function") or {}).get("arguments") or "{}"
323
+ _a = _raw if isinstance(_raw, dict) else json.loads(_raw)
324
+ except Exception:
325
+ continue
326
+ _p = _a.get("path")
327
+ if isinstance(_p, str) and _p:
328
+ _read_paths[_tcid] = _p
329
+ _path_latest_idx: dict[str, int] = {}
330
+ for i, _m in enumerate(messages):
331
+ if _m.get("role") == "tool":
332
+ _p = _read_paths.get(_m.get("tool_call_id"))
333
+ if _p:
334
+ _path_latest_idx[_p] = i # highest index = most recent read
335
+ protected_read_idxs = {
336
+ idx for _p, idx in
337
+ sorted(_path_latest_idx.items(), key=lambda kv: -kv[1])[:_PROTECT_HOT_READS]
338
+ }
339
+ # Only REPLAYABLE-observation results are age-eligible; keep the last
340
+ # `keep_recent` of THOSE verbatim (counting non-replayable results toward
341
+ # the recency window would let a burst of writes push a still-needed read
342
+ # out of the kept set).
343
+ ageable_idxs = [
344
+ i for i, m in enumerate(messages)
345
+ if m.get("role") == "tool"
346
+ and _result_tool_name(m, id_to_name) in _REPLAYABLE_TOOLS
347
+ and i not in protected_read_idxs
348
+ ]
349
+ if len(ageable_idxs) <= keep_recent:
350
+ return messages
351
+
352
+ cutoff_idx = ageable_idxs[-keep_recent]
353
+ out: list[dict] = []
354
+ for i, m in enumerate(messages):
355
+ if i >= cutoff_idx or m.get("role") != "tool":
356
+ out.append(m)
357
+ continue
358
+ if i in protected_read_idxs:
359
+ out.append(m) # hot file's current content — keep so it isn't re-read
360
+ continue
361
+ if _result_tool_name(m, id_to_name) not in _REPLAYABLE_TOOLS:
362
+ out.append(m) # non-replayable (write/edit diff, etc.) — preserve
363
+ continue
364
+ content = m.get("content") or ""
365
+ if not isinstance(content, str) or len(content) < COMPACT_MIN_CONTENT_CHARS:
366
+ out.append(m)
367
+ continue
368
+ summary = _semantic_tool_summary(content)
369
+ out.append({**m, "content": summary})
370
+ return out
371
+
372
+
373
+ def _semantic_tool_summary(content: str) -> str:
374
+ """Compact old tool output by preserving operational facts first."""
375
+ lines = content.splitlines()
376
+ first_line = next((ln.strip()[:120] for ln in lines if ln.strip()), "")
377
+ facts = ""
378
+ if "[tool facts:" in content:
379
+ facts = content[content.find("[tool facts:"):].splitlines()[0][:300]
380
+ is_error = (
381
+ content.startswith("Error:")
382
+ or content.startswith("REJECTED:")
383
+ or content.startswith("[exit code ")
384
+ or "old_string not found" in content
385
+ or "Traceback " in content
386
+ )
387
+ if is_error:
388
+ # Self-conditioning (arXiv:2509.09677): echoing an OLD error's text
389
+ # pushes the model to repeat the mistake. Recent errors stay intact
390
+ # (kept above by keep_recent) so it can fix the immediate problem;
391
+ # older ones drop to a neutral note (the ledger still records the fact).
392
+ return "[an earlier tool call errored and was handled — details dropped]"
393
+ if facts:
394
+ return (
395
+ f"[older successful tool result summarized: {len(content)} chars, "
396
+ f"{len(lines)} lines]\n{facts}"
397
+ )
398
+ if any(token in content for token in ("http://localhost:", "127.0.0.1:", "PID:", "URL:")):
399
+ important = [
400
+ ln.strip()
401
+ for ln in lines
402
+ if any(token in ln for token in ("http://localhost:", "127.0.0.1:", "PID:", "URL:", "Log:"))
403
+ ][:8]
404
+ return (
405
+ f"[older runtime result summarized: {len(content)} chars, "
406
+ f"{len(lines)} lines]\n" + "\n".join(important)
407
+ )
408
+ return (
409
+ f"[older tool result summarized: {first_line} … "
410
+ f"({len(content)} chars, {len(lines)} lines)]"
411
+ )
412
+
413
+
414
+ # REDACT_KEEP_RECENT_WRITES / REDACT_MIN_CONTENT_CHARS moved to
415
+ # constants.py during the T0.1 split; re-exported at the top of
416
+ # this module for back-compat.
417
+
418
+ def _redact_old_write_args(messages: list[dict]) -> list[dict]:
419
+ """Pass-through: the model's OWN write/edit bodies are NEVER stripped.
420
+
421
+ History note (this used to strip them, and that was a bug)
422
+ ----------------------------------------------------------
423
+ This pass previously replaced older write_file `content` (and edit_file /
424
+ multi_edit anchors) in the assistant tool_calls with a "[REDACTED — file is
425
+ on disk, call read_file to reload]" stub, keeping only the last
426
+ `REDACT_KEEP_RECENT_WRITES` verbatim. The intent was to cut context bloat.
427
+
428
+ The real cost (observed in logs): after a build failure the model could no
429
+ longer SEE what it had written a few rounds earlier — the body was a stub —
430
+ so instead of a targeted fix it rewrote the whole file from scratch, over
431
+ and over (churn). What the model wrote is exactly what it needs to reason
432
+ about its own change; that record must stay in history.
433
+
434
+ The reference agents agree: claude-code's microCompact clears old tool
435
+ RESULTS but never the write BODIES in assistant messages; codex/opencode
436
+ likewise summarize whole turns wholesale only when over budget rather than
437
+ surgically deleting the code the model wrote. So this function no longer
438
+ strips anything. Context is bounded instead by:
439
+ * `_compact_old_tool_results` — ages only REPLAYABLE observation results
440
+ (read/grep/glob/list_files/bash), never write/edit diffs;
441
+ * `_redact_duplicate_reads` — collapses re-reads of the same path;
442
+ * `_microcompact_for_prompt_budget` / `_compact_messages` — summarize old
443
+ turns wholesale when the prompt genuinely approaches the window budget.
444
+
445
+ Kept as a named no-op (rather than deleted) so callers, telemetry, and the
446
+ A/B feature flag keep working. Still gated on `Feature.WRITE_ARG_REDACTION`.
447
+ """
448
+ from ..features import Feature, is_enabled
449
+ if not is_enabled(Feature.WRITE_ARG_REDACTION):
450
+ return messages
451
+ # Never strip write/edit bodies — see docstring. Older-write redaction is
452
+ # intentionally disabled; the empty target set makes the rest of this
453
+ # function a structural no-op while preserving its shape for back-compat.
454
+ REDACT_TOOLS: set[str] = set()
455
+
456
+ # Collect (outer_idx, tool_call_idx, tool_name) for every targeted call.
457
+ targets: list[tuple[int, int, str]] = []
458
+ for i, m in enumerate(messages):
459
+ if m.get("role") != "assistant":
460
+ continue
461
+ for j, tc in enumerate(m.get("tool_calls") or []):
462
+ name = ((tc.get("function") or {}).get("name") or "").strip()
463
+ if name in REDACT_TOOLS:
464
+ targets.append((i, j, name))
465
+
466
+ if len(targets) <= REDACT_KEEP_RECENT_WRITES:
467
+ return messages
468
+
469
+ # Everything except the last N is a redaction candidate.
470
+ to_redact = set(targets[:-REDACT_KEEP_RECENT_WRITES])
471
+ if not to_redact:
472
+ return messages
473
+
474
+ out: list[dict] = []
475
+ for i, m in enumerate(messages):
476
+ tcs = m.get("tool_calls") if m.get("role") == "assistant" else None
477
+ if not tcs:
478
+ out.append(m)
479
+ continue
480
+ new_tcs = []
481
+ touched = False
482
+ for j, tc in enumerate(tcs):
483
+ name = ((tc.get("function") or {}).get("name") or "").strip()
484
+ if (i, j, name) not in to_redact:
485
+ new_tcs.append(tc)
486
+ continue
487
+ # Redact: parse args, strip the bulky field, re-serialise.
488
+ try:
489
+ raw_args = (tc.get("function") or {}).get("arguments") or "{}"
490
+ args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args)
491
+ except Exception:
492
+ new_tcs.append(tc)
493
+ continue
494
+ path = args.get("path") or "?"
495
+ size_hint = ""
496
+ if name in {"write_file", "append_file"}:
497
+ content = args.get("content") or ""
498
+ if isinstance(content, str) and len(content) >= REDACT_MIN_CONTENT_CHARS:
499
+ # DROP the `content` key entirely instead of replacing
500
+ # it with a stub string. Earlier we put a "[REDACTED
501
+ # from history — …]" sentinel into `args["content"]`
502
+ # to inform the model the file existed but the bytes
503
+ # were elided. IQ2_M Qwen, on the next round, would
504
+ # SEE that string in its own past tool_call and copy
505
+ # it verbatim as the `content` arg of a NEW write_file
506
+ # call — overwriting the real file on disk with the
507
+ # stub itself. Dropping the
508
+ # key means there's nothing to copy. The synthetic
509
+ # `_redaction_note` sibling tells the model what
510
+ # happened without exposing a copyable `content`.
511
+ size_hint = f" ({len(content)} chars, {content.count(chr(10)) + 1} lines)"
512
+ args.pop("content", None)
513
+ args["_redaction_note"] = (
514
+ f"content elided from history{size_hint}. "
515
+ f"file is on disk at {path}. "
516
+ f"call read_file path={path!r} to reload."
517
+ )
518
+ touched = True
519
+ elif name == "edit_file":
520
+ old_s = args.get("old_string") or ""
521
+ new_s = args.get("new_string") or ""
522
+ if isinstance(new_s, str) and len(new_s) + len(old_s) >= REDACT_MIN_CONTENT_CHARS:
523
+ size_hint = f" ({len(new_s)} chars new, {len(old_s)} chars old)"
524
+ args["old_string"] = f"[REDACTED{size_hint}]"
525
+ args["new_string"] = (
526
+ f"[REDACTED — edit applied to {path}. "
527
+ f"Call read_file with path={path!r} to see current content.]"
528
+ )
529
+ touched = True
530
+ elif name == "multi_edit":
531
+ edits = args.get("edits") or []
532
+ if isinstance(edits, list) and edits:
533
+ total = sum(
534
+ len((e or {}).get("new_string") or "")
535
+ + len((e or {}).get("old_string") or "")
536
+ for e in edits
537
+ )
538
+ if total >= REDACT_MIN_CONTENT_CHARS:
539
+ args["edits"] = [
540
+ {"old_string": f"[REDACTED edit {k+1}/{len(edits)}]",
541
+ "new_string": f"[REDACTED — {len(edits)} edits applied to {path}. "
542
+ f"Call read_file with path={path!r} to see current content.]"}
543
+ for k in range(len(edits))
544
+ ]
545
+ touched = True
546
+ if touched:
547
+ # Rebuild the tool_call with the redacted arguments.
548
+ new_fn = {**(tc.get("function") or {}), "arguments": json.dumps(args)}
549
+ new_tcs.append({**tc, "function": new_fn})
550
+ else:
551
+ new_tcs.append(tc)
552
+ out.append({**m, "tool_calls": new_tcs})
553
+ return out
554
+
555
+
556
+ # READ_UNCHANGED_STUB_PREFIX moved to constants.py during the T0.1
557
+ # split; re-exported at the top of this module for back-compat.
558
+
559
+ def _redact_duplicate_reads(messages: list[dict]) -> list[dict]:
560
+ """Return a copy of `messages` with older duplicate read_file results stubbed.
561
+
562
+ Walks the message list once. For each read_file tool_use -> tool_result
563
+ pair, tracks which paths have been read. The LAST read for each path
564
+ keeps its full result; earlier ones get replaced with
565
+ `READ_UNCHANGED_STUB_PREFIX`. The tool_call itself (in the assistant
566
+ message) is untouched — only the result content shrinks.
567
+
568
+ This runs alongside `_redact_old_write_args` and
569
+ `_compact_old_tool_results`. The three are complementary:
570
+ • write-arg redaction shrinks assistant → tool_calls payloads
571
+ • duplicate-read stubbing shrinks tool role results for read_file
572
+ • tool-result aging shrinks tool role results for everything else
573
+
574
+ Gated on `Feature.DUPLICATE_READ_STUB`. Disabled returns the input
575
+ unchanged.
576
+ """
577
+ from ..features import Feature, is_enabled
578
+ if not is_enabled(Feature.DUPLICATE_READ_STUB):
579
+ return messages
580
+ # First pass: build map from tool_call_id → (path, outer_idx) for
581
+ # every read_file call. We need the id to match it against the
582
+ # corresponding tool-role result message later.
583
+ read_calls: dict[str, str] = {} # id → path
584
+ for m in messages:
585
+ if m.get("role") != "assistant":
586
+ continue
587
+ for tc in m.get("tool_calls") or []:
588
+ name = ((tc.get("function") or {}).get("name") or "").strip()
589
+ if name != "read_file":
590
+ continue
591
+ tc_id = tc.get("id")
592
+ if not tc_id:
593
+ continue
594
+ try:
595
+ _raw = (tc.get("function") or {}).get("arguments") or "{}"
596
+ args = _raw if isinstance(_raw, dict) else json.loads(_raw)
597
+ except Exception:
598
+ continue
599
+ path = args.get("path")
600
+ if isinstance(path, str) and path:
601
+ read_calls[tc_id] = path
602
+
603
+ if not read_calls:
604
+ return messages
605
+
606
+ # Second pass: find the tool-role result index for each read_file call
607
+ # and group by path. Keep the highest-index (most recent) per path;
608
+ # stub everything else for the same path.
609
+ path_to_result_idxs: dict[str, list[int]] = {}
610
+ for i, m in enumerate(messages):
611
+ if m.get("role") != "tool":
612
+ continue
613
+ tc_id = m.get("tool_call_id")
614
+ if tc_id in read_calls:
615
+ path_to_result_idxs.setdefault(read_calls[tc_id], []).append(i)
616
+
617
+ # Any path with 2+ reads is a redaction candidate.
618
+ to_stub: set[int] = set()
619
+ for path, idxs in path_to_result_idxs.items():
620
+ if len(idxs) >= 2:
621
+ # Keep the last; stub all earlier ones.
622
+ to_stub.update(idxs[:-1])
623
+
624
+ if not to_stub:
625
+ return messages
626
+
627
+ out: list[dict] = []
628
+ for i, m in enumerate(messages):
629
+ if i not in to_stub:
630
+ out.append(m)
631
+ continue
632
+ content = m.get("content") or ""
633
+ if not isinstance(content, str) or len(content) < REDACT_MIN_CONTENT_CHARS:
634
+ out.append(m)
635
+ continue
636
+ out.append({**m, "content": READ_UNCHANGED_STUB_PREFIX})
637
+ return out
638
+
639
+ def _msg_bytes(messages: list[dict]) -> int:
640
+ """Approximate serialized size of `messages` for telemetry.
641
+
642
+ Uses `json.dumps` so we count tool_call argument payloads and
643
+ tool_result content the same way the API client will. Cheap enough
644
+ to call once per round; not exact-token-count, but proportional
645
+ to wire size which is what we actually care about.
646
+ """
647
+ try:
648
+ return len(json.dumps(messages))
649
+ except Exception:
650
+ return 0
651
+
652
+ def _window_aware_compaction(ctx_window_chars: int | None) -> tuple[int, int]:
653
+ """(history_budget_bytes, keep_recent_tool_results) scaled to the context
654
+ window. Capacity and replay latency are separate budgets: large-RAM Macs
655
+ can retain richer recent evidence, but hybrid/recurrent local models may
656
+ still re-process the entire prompt each round. Durable ledgers preserve
657
+ completed work while this hot transcript remains bounded.
658
+
659
+ `ctx_window_chars` ≈ num_ctx tokens × ~3.5 chars/token. None/unknown →
660
+ the legacy fixed behaviour (back-compat, safe on tiny windows).
661
+ """
662
+ if not ctx_window_chars or ctx_window_chars <= 0:
663
+ return 36_000, COMPACT_KEEP_RECENT_TOOL_RESULTS
664
+ # A large KV window is capacity, not a latency target. Live 128 GB / Qwen
665
+ # traces showed TTFT rising from ~2 s to 44 s as one build replayed ~28K
666
+ # prompt tokens. Cap the hot transcript while the durable progress ledger
667
+ # and filesystem-state block preserve what was completed.
668
+ budget = max(36_000, min(48_000, int(ctx_window_chars * 0.20)))
669
+ keep = max(COMPACT_KEEP_RECENT_TOOL_RESULTS, min(8, ctx_window_chars // 40_000))
670
+ return budget, keep
671
+
672
+
673
+ def latency_budgeted_hot_replay(
674
+ ctx_window_chars: int | None,
675
+ observed_ttft_ms: int | None,
676
+ *,
677
+ target_ttft_ms: int = 8_000,
678
+ ) -> tuple[int, int]:
679
+ """Adapt hot replay to measured local prefill latency, not KV capacity."""
680
+ budget, keep = _window_aware_compaction(ctx_window_chars)
681
+ if not observed_ttft_ms or observed_ttft_ms <= target_ttft_ms:
682
+ return budget, keep
683
+ ratio = max(0.35, target_ttft_ms / observed_ttft_ms)
684
+ return max(18_000, int(budget * ratio)), max(2, int(keep * ratio))
685
+
686
+
687
+ def _prepare_model_messages(
688
+ messages: list[dict], ctx_window_chars: int | None = None,
689
+ observed_ttft_ms: int | None = None,
690
+ ) -> list[dict]:
691
+ """One-stop context-shrink pass before sending to the model.
692
+
693
+ `ctx_window_chars` (the model's context window in chars, RAM-derived) makes
694
+ compaction window-aware: big machines keep far more history, small ones
695
+ stay aggressive. None preserves the legacy fixed budget.
696
+
697
+ Composes the three reduction passes in a fixed order:
698
+ 1. `_redact_old_write_args` — strip bulky content from old
699
+ write_file / edit_file / multi_edit tool_call arguments.
700
+ 2. `_redact_duplicate_reads` — collapse older read_file results
701
+ for the same path to a stub pointing at the newer read.
702
+ 3. `_compact_old_tool_results` — summarize old tool-role results
703
+ for every other tool (bash, grep, etc.).
704
+ All three return copies, so the original `messages` list is
705
+ untouched. Order matters: (1) and (2) each shrink different parts
706
+ of the history, and (3) catches whatever's left from (1) and (2)
707
+ plus unrelated tools.
708
+
709
+ Telemetry: when any pass shrinks the message list, we append a
710
+ `redaction` line to ~/.localcode/lifecycle.log with per-pass byte
711
+ deltas. Lets us answer "how much is each layer actually saving in
712
+ practice?" without instrumenting the agent loop. Silent on no-op
713
+ so the log doesn't fill up with empty events.
714
+ """
715
+ budget_bytes, keep_recent = latency_budgeted_hot_replay(ctx_window_chars, observed_ttft_ms)
716
+ before = _msg_bytes(messages)
717
+ after_writes = _redact_old_write_args(messages)
718
+ bytes_writes = before - _msg_bytes(after_writes)
719
+ # RAM-tier the duplicate-read stubbing: on a big window (≥128K chars ≈
720
+ # 36K tokens, i.e. 48 GB+) there's room to KEEP prior reads so the model
721
+ # retains what it saw (less "re-read because it scrolled away"). Only stub
722
+ # duplicate reads on smaller windows where the space is actually needed.
723
+ # The progress ledger covers the small-window case where reads must be
724
+ # dropped. Codex similarly re-hydrates rather than aggressively stubbing.
725
+ if ctx_window_chars and ctx_window_chars >= 128_000:
726
+ after_reads = after_writes
727
+ else:
728
+ after_reads = _redact_duplicate_reads(after_writes)
729
+ bytes_reads = _msg_bytes(after_writes) - _msg_bytes(after_reads)
730
+ after_tools = _compact_old_tool_results(after_reads, keep_recent=keep_recent)
731
+ after_budget = _microcompact_for_prompt_budget(after_tools, target_bytes=budget_bytes)
732
+ bytes_tools = _msg_bytes(after_reads) - _msg_bytes(after_tools)
733
+ bytes_budget = _msg_bytes(after_tools) - _msg_bytes(after_budget)
734
+ total_saved = bytes_writes + bytes_reads + bytes_tools + bytes_budget
735
+ if total_saved > 0:
736
+ try:
737
+ from ..server_manager import _lifecycle_log
738
+ _lifecycle_log(
739
+ "redaction",
740
+ msgs=len(messages),
741
+ bytes_before=before,
742
+ bytes_after=_msg_bytes(after_budget),
743
+ saved_writes=bytes_writes,
744
+ saved_reads=bytes_reads,
745
+ saved_tools=bytes_tools,
746
+ saved_budget=bytes_budget,
747
+ saved_total=total_saved,
748
+ pct_saved=int(total_saved * 100 / max(1, before)),
749
+ )
750
+ except Exception:
751
+ pass
752
+ return after_budget
753
+
754
+
755
+ def _microcompact_for_prompt_budget(messages: list[dict], *, target_bytes: int = 36_000) -> list[dict]:
756
+ """Deterministically summarize older protocol-safe history.
757
+
758
+ The per-tool aging passes shrink payloads, but long turns can still
759
+ accumulate dozens of small assistant/tool pairs plus recent write args.
760
+ Keep a compact task ledger and recent messages instead of every
761
+ intermediate tool transcript. This pass keeps the system message and a
762
+ protocol-safe recent suffix, replacing the older middle with a factual
763
+ summary of user requests, files touched, commands, and errors. It
764
+ never mutates the source session history.
765
+ """
766
+ if _msg_bytes(messages) <= target_bytes or len(messages) <= 14:
767
+ return messages
768
+
769
+ system_count = 1 if messages and messages[0].get("role") == "system" else 0
770
+ # NEVER compact away the task statement. The first real user message (not
771
+ # a synthetic "SYSTEM:" nudge) anchors the entire turn: summarizing it into
772
+ # a one-line ledger made the model literally ask "what is the actual task?"
773
+ # mid-build and re-derive the goal from directory names (observed live on a
774
+ # long agentic build). Keep it VERBATIM at the head, always — the reference
775
+ # harnesses' compaction likewise carries the original request forward.
776
+ head_count = system_count
777
+ for i in range(system_count, len(messages)):
778
+ m = messages[i]
779
+ if m.get("role") == "user" and isinstance(m.get("content"), str) \
780
+ and not m["content"].startswith("SYSTEM:"):
781
+ head_count = i + 1
782
+ break
783
+ best = messages
784
+ # Tighten the protocol-safe suffix until the serialized prompt fits. A
785
+ # fixed 12-message tail can itself contain several complete source files.
786
+ for tail_size in (12, 10, 8, 6, 4):
787
+ boundary = max(head_count, len(messages) - tail_size)
788
+ while boundary > head_count:
789
+ if messages[boundary].get("role") == "tool":
790
+ boundary -= 1
791
+ continue
792
+ prev = messages[boundary - 1]
793
+ if prev.get("role") == "assistant" and prev.get("tool_calls"):
794
+ boundary -= 1
795
+ continue
796
+ break
797
+ if boundary <= head_count:
798
+ continue
799
+ old = messages[head_count:boundary]
800
+ recent = messages[boundary:]
801
+ summary = _compact_history_summary(old)
802
+ candidate = [
803
+ *messages[:head_count],
804
+ {"role": "user", "content": summary},
805
+ {"role": "assistant", "content": "Continuing with the summarized prior work."},
806
+ *recent,
807
+ ]
808
+ if _msg_bytes(candidate) < _msg_bytes(best):
809
+ best = candidate
810
+ if _msg_bytes(candidate) <= target_bytes:
811
+ return candidate
812
+ return best
813
+
814
+
815
+ def _compact_history_summary(messages: list[dict]) -> str:
816
+ files: list[str] = []
817
+ commands: list[str] = []
818
+ errors: list[str] = []
819
+ users: list[str] = []
820
+ actions: list[str] = []
821
+
822
+ def _add_unique(items: list[str], value: str, limit: int) -> None:
823
+ value = value.strip()
824
+ if value and value not in items:
825
+ items.append(value[:180])
826
+ del items[:-limit]
827
+
828
+ for m in messages:
829
+ role = m.get("role", "")
830
+ content = str(m.get("content") or "")
831
+ if role == "user" and content:
832
+ _add_unique(users, content.replace("\n", " "), 4)
833
+ if role == "tool" and content:
834
+ if (
835
+ content.startswith("Error:")
836
+ or content.startswith("REJECTED:")
837
+ or content.startswith("[exit code ")
838
+ or "Traceback " in content
839
+ or "old_string not found" in content
840
+ ):
841
+ _add_unique(errors, _semantic_tool_summary(content).replace("\n", " "), 6)
842
+ if role != "assistant":
843
+ continue
844
+ for tc in m.get("tool_calls") or []:
845
+ fn = tc.get("function") or {}
846
+ name = str(fn.get("name") or "").strip()
847
+ try:
848
+ _raw = fn.get("arguments") or "{}"
849
+ args = _raw if isinstance(_raw, dict) else json.loads(_raw)
850
+ except Exception:
851
+ args = {}
852
+ path = args.get("path") or args.get("file_path")
853
+ if isinstance(path, str) and path:
854
+ _add_unique(files, path, 12)
855
+ if name == "bash":
856
+ cmd = str(args.get("command") or "")
857
+ _add_unique(commands, cmd.replace("\n", " "), 8)
858
+ if name:
859
+ actions.append(f"{name}({_summarize_args(args)})")
860
+ del actions[:-10]
861
+
862
+ lines = ["Earlier context summarized to control prompt size:"]
863
+ if users:
864
+ lines.append("Recent user intent: " + " | ".join(users[-3:]))
865
+ if files:
866
+ lines.append("Files touched/read: " + ", ".join(files[-10:]))
867
+ if commands:
868
+ lines.append("Commands run: " + "; ".join(commands[-5:]))
869
+ if errors:
870
+ lines.append("Errors/fixes to preserve: " + " | ".join(errors[-4:]))
871
+ if actions:
872
+ lines.append("Recent prior tool actions: " + "; ".join(actions[-8:]))
873
+ lines.append("If exact file content is needed, call read_file on the relevant path.")
874
+ return "\n".join(lines)
875
+
876
+ def build_progress_ledger(
877
+ changed_files: list[str],
878
+ bash_history: list[tuple[str, str]],
879
+ files_read: list[str],
880
+ budget_chars: int,
881
+ ) -> str:
882
+ """A compact, always-current 'what I've already done this task' ledger.
883
+
884
+ Ported from Codex's design (handoff summary + tool-state awareness + an
885
+ explicit 'build on this, don't duplicate work' instruction). Built from the
886
+ loop's DURABLE state (changed_files / bash_history / files_read) — NOT the
887
+ message history — so it survives compaction: on a small-RAM machine the raw
888
+ tool results get compacted to placeholders, but this ledger persists, which
889
+ is exactly what stops the model re-reading files and "starting from scratch."
890
+
891
+ `budget_chars` is window-scaled (see model_config.progress_ledger_budget_chars):
892
+ compact on a 16 GB Mac, richer on a 128 GB Mac. Returns "" when nothing has
893
+ happened yet (keeps the prompt prefix stable on the first round).
894
+ """
895
+ if not (changed_files or bash_history or files_read):
896
+ return ""
897
+
898
+ def _uniq(seq: list[str]) -> list[str]:
899
+ return list(dict.fromkeys(s for s in seq if s))
900
+
901
+ lines = [
902
+ "## A log of YOUR OWN tool calls so far this turn — NOT the user's work "
903
+ "and NOT pre-existing files on disk (leftover files you did not create are "
904
+ "not your progress or your task). Don't re-read/re-run anything below "
905
+ "unless you changed it; keep making NEW progress toward the user's goal.",
906
+ ]
907
+ # Files created/edited is the most important line (it's "where I am" in a
908
+ # multi-file build) — list it FIRST and show ALL of them, not just the last
909
+ # 12. Capping at 12 dropped the earliest files once a build exceeded 12,
910
+ # so the model re-checked what existed and felt like it restarted. Paths are
911
+ # short; the whole list is cheap and is the anchor that prevents forgetting.
912
+ cf = _uniq(changed_files)
913
+ # The created/edited list is PROTECTED — never truncated by the budget.
914
+ # It's the anchor that stops the model forgetting what it built; losing any
915
+ # of it is what caused the "restarting" feeling. Everything ELSE (files
916
+ # read, commands) shares the remaining budget and can be trimmed.
917
+ protected = []
918
+ if cf:
919
+ protected.append(f"Files YOU already created/edited this task ({len(cf)}) — "
920
+ "do NOT recreate or re-check these, build on them: " + ", ".join(cf))
921
+ optional = []
922
+ rd = _uniq(files_read)
923
+ if rd:
924
+ optional.append("Files read: " + ", ".join(rd[-12:]))
925
+ if bash_history:
926
+ cmds: list[str] = []
927
+ for cmd, res in bash_history[-8:]:
928
+ r = str(res)
929
+ bad = (
930
+ r.startswith("[exit code ") or r.startswith("Error:")
931
+ or r.startswith("REJECTED:") or "Traceback " in r
932
+ )
933
+ cmds.append(f"{'x' if bad else 'ok'} {str(cmd).strip().splitlines()[0][:70]}"
934
+ if str(cmd).strip() else "")
935
+ cmds = [c for c in cmds if c]
936
+ if cmds:
937
+ optional.append("Commands run: " + " | ".join(cmds))
938
+
939
+ head = "\n".join(lines + protected)
940
+ if not optional:
941
+ return head
942
+ extra = "\n".join(optional)
943
+ if budget_chars and len(head) + 1 + len(extra) > budget_chars:
944
+ room = max(0, budget_chars - len(head) - 3)
945
+ extra = extra[:room].rstrip() + " …" if room > 0 else ""
946
+ return head + ("\n" + extra if extra else "")
947
+
948
+
949
+ _PROJECT_MARKERS = ("package.json", "pyproject.toml", ".git", "go.mod", "Cargo.toml", "tsconfig.json")
950
+
951
+
952
+ def _project_root_for(common: str, dirs: list[str]) -> str | None:
953
+ """The project directory to scan: the nearest marker at-or-below `common`,
954
+ else the deepest single changed-file dir. Bounds the walk to one project."""
955
+ import os
956
+ # Walk DOWN from each changed dir toward `common` looking for a marker,
957
+ # picking the shallowest marker that is still within `common`.
958
+ candidates = []
959
+ for d in dirs:
960
+ cur = d
961
+ while cur and cur.startswith(common):
962
+ if any(os.path.exists(os.path.join(cur, m)) for m in _PROJECT_MARKERS):
963
+ candidates.append(cur)
964
+ parent = os.path.dirname(cur)
965
+ if parent == cur:
966
+ break
967
+ cur = parent
968
+ if candidates:
969
+ return min(candidates, key=len) # shallowest marker dir
970
+ # No marker: only trust it if all changed files share ONE dir (a real
971
+ # single-project build); otherwise decline (common ancestor may be broad).
972
+ return dirs[0] if len(set(dirs)) == 1 else None
973
+
974
+
975
+ def build_filesystem_state(changed_files: list[str], max_files: int = 80) -> str:
976
+ """Ground-truth 'what's actually on disk' for the project being built.
977
+
978
+ The #1 fix for the model re-checking which files exist and re-creating
979
+ them (pi/codex/opencode all converge on this): don't rely on the model's
980
+ recollection — reconcile against the FILESYSTEM in code, each round. We
981
+ walk the tree rooted at the project the model is building (the common
982
+ parent of the files it has changed) and list the real source files. Because
983
+ the truth comes from the OS, it survives compaction/restart and the model
984
+ can't hallucinate it away. Returns "" when nothing has been built yet.
985
+ """
986
+ import os
987
+ roots = [os.path.dirname(f) for f in changed_files if f]
988
+ if not roots:
989
+ return ""
990
+ try:
991
+ common = os.path.commonpath([os.path.abspath(os.path.dirname(f))
992
+ for f in changed_files if f])
993
+ except Exception:
994
+ common = os.path.abspath(roots[0])
995
+ # SCOPE GUARD: only ever walk a real PROJECT dir, never a broad ancestor
996
+ # like ~ or a top-level workspace. Anchor to the nearest project marker
997
+ # (package.json / pyproject.toml / .git / go.mod / Cargo.toml) at-or-below
998
+ # the common path; if none, use the deepest single changed-file dir. And
999
+ # NEVER walk the home dir or anything shallower — bail out entirely.
1000
+ root = _project_root_for(common, [os.path.abspath(os.path.dirname(f))
1001
+ for f in changed_files if f])
1002
+ home = os.path.expanduser("~")
1003
+ if not root or not os.path.isdir(root):
1004
+ return ""
1005
+ if os.path.abspath(root) in (home, os.path.dirname(home), os.path.sep):
1006
+ return ""
1007
+ if len(os.path.abspath(root).rstrip("/").split("/")) < 4: # too shallow → unsafe to walk
1008
+ return ""
1009
+ _SKIP = {".git", "node_modules", "dist", "build", ".venv", "__pycache__", ".next"}
1010
+ _SRC = (".ts", ".tsx", ".js", ".jsx", ".py", ".css", ".scss", ".html", ".json",
1011
+ ".md", ".go", ".rs", ".java", ".rb", ".php", ".vue", ".svelte", ".toml", ".yaml", ".yml")
1012
+ found: list[str] = []
1013
+ for base, dirs, files in os.walk(root):
1014
+ dirs[:] = [d for d in dirs if d not in _SKIP]
1015
+ for fn in files:
1016
+ if fn.endswith(_SRC):
1017
+ rel = os.path.relpath(os.path.join(base, fn), root)
1018
+ found.append(rel)
1019
+ if len(found) >= max_files:
1020
+ break
1021
+ if len(found) >= max_files:
1022
+ break
1023
+ if not found:
1024
+ return ""
1025
+ found.sort()
1026
+ return (
1027
+ f"## Files that ALREADY EXIST on disk in this project ({os.path.basename(root)}/) "
1028
+ f"— ground truth, {len(found)} file(s). Do NOT list the directory to check; "
1029
+ "do NOT recreate these. Edit an existing file or create a NEW one the task needs:\n"
1030
+ + ", ".join(found)
1031
+ )
1032
+
1033
+
1034
+ def _estimate_tokens(messages: list[dict]) -> int:
1035
+ """Rough token estimate: chars / 4."""
1036
+ total = 0
1037
+ for m in messages:
1038
+ total += len(str(m.get("content", "")))
1039
+ for tc in m.get("tool_calls", []):
1040
+ total += len(str(tc.get("function", {}).get("arguments", "")))
1041
+ return total // 4
1042
+
1043
+ def _compact_messages(messages: list[dict], out: "OutputManager") -> list[dict]:
1044
+ """Summarize old messages, keep recent context.
1045
+
1046
+ The split must never orphan a `tool` message (server returns 400 if a tool
1047
+ response has no preceding assistant/tool_calls in the message list). Walk
1048
+ the boundary back until both halves are self-contained.
1049
+ """
1050
+ if len(messages) <= 12:
1051
+ return messages
1052
+
1053
+ system = [m for m in messages[:2] if m.get("role") == "system"]
1054
+ sys_len = len(system)
1055
+
1056
+ boundary = max(sys_len, len(messages) - 8)
1057
+ # Walk back until: messages[boundary] is not "tool" AND
1058
+ # messages[boundary-1] is not an assistant with tool_calls.
1059
+ while boundary > sys_len:
1060
+ if messages[boundary].get("role") == "tool":
1061
+ boundary -= 1
1062
+ continue
1063
+ prev = messages[boundary - 1]
1064
+ if prev.get("role") == "assistant" and prev.get("tool_calls"):
1065
+ boundary -= 1
1066
+ continue
1067
+ break
1068
+
1069
+ if boundary <= sys_len:
1070
+ # Whole tail is one tool-using sequence — nothing safe to compact
1071
+ return messages
1072
+
1073
+ recent = messages[boundary:]
1074
+ old = messages[sys_len:boundary]
1075
+ if not old:
1076
+ return messages
1077
+
1078
+ # Build an anchored "Work State" summary from old messages. This is the
1079
+ # structure opencode + codex converge on (Objective / Completed / Blocked /
1080
+ # Next Move) — a state machine a small local model can follow, not a flat
1081
+ # "Called X, Called Y" log. Built DETERMINISTICALLY (no LLM call — matches
1082
+ # codex's no-inference compaction, which matters on slow 16GB hardware):
1083
+ # completed = files written + commands that succeeded; blocked = commands
1084
+ # that failed / errors; objective = the first user request.
1085
+ objective = ""
1086
+ files_modified: list[str] = []
1087
+ ok_cmds: list[str] = []
1088
+ failed: list[str] = []
1089
+
1090
+ for m in old:
1091
+ role = m.get("role", "")
1092
+ content = str(m.get("content", ""))
1093
+ if role == "user" and not objective and not content.startswith(("Previous", "SYSTEM:", "##")):
1094
+ objective = content[:200]
1095
+ if role == "assistant" and m.get("tool_calls"):
1096
+ for tc in m["tool_calls"]:
1097
+ fn = tc.get("function", {})
1098
+ name = fn.get("name", "")
1099
+ try:
1100
+ _raw = fn.get("arguments", "{}")
1101
+ args = _raw if isinstance(_raw, dict) else json.loads(_raw)
1102
+ except (json.JSONDecodeError, TypeError, ValueError):
1103
+ args = {}
1104
+ if name in ("write_file", "append_file", "edit_file", "multi_edit"):
1105
+ p = args.get("path") or args.get("file_path")
1106
+ if p and p not in files_modified:
1107
+ files_modified.append(p)
1108
+ elif name == "bash":
1109
+ ok_cmds.append(str(args.get("command", ""))[:70])
1110
+ elif role == "tool":
1111
+ c = content.strip()
1112
+ if c.startswith(("Error", "[exit code", "REJECTED")) or "Traceback " in c:
1113
+ failed.append(c.splitlines()[0][:100] if c else "")
1114
+
1115
+ completed = []
1116
+ if files_modified:
1117
+ completed.append("Files created/edited: " + ", ".join(files_modified))
1118
+ if ok_cmds:
1119
+ completed.append("Commands run: " + " | ".join(ok_cmds[-5:]))
1120
+ blocked = [f for f in failed if f][-3:]
1121
+
1122
+ lines = ["## Work state so far (older turns compacted — build on this, don't redo it):"]
1123
+ if objective:
1124
+ lines.append(f"Objective: {objective}")
1125
+ lines.append("Completed:")
1126
+ lines.extend(f" - {c}" for c in completed) if completed else lines.append(" - (none yet)")
1127
+ if blocked:
1128
+ lines.append("Blocked / errors seen:")
1129
+ lines.extend(f" - {b}" for b in blocked)
1130
+ lines.append("Next move: continue the objective from the current file state; "
1131
+ "do not re-read files or re-run commands listed above.")
1132
+
1133
+ out.print_info("Context compacted — older turns summarized to a work-state note.")
1134
+
1135
+ return [
1136
+ *system,
1137
+ {"role": "user", "content": "\n".join(lines)},
1138
+ {"role": "assistant", "content": "Understood — continuing from the current state."},
1139
+ *recent,
1140
+ ]
1141
+
1142
+ def _summarize_args(args: dict) -> str:
1143
+ """Short summary of tool args for display."""
1144
+ if "path" in args:
1145
+ return args["path"]
1146
+ if "pattern" in args:
1147
+ return args["pattern"]
1148
+ if "command" in args:
1149
+ return args["command"][:60]
1150
+ if "query" in args:
1151
+ return args["query"][:40]
1152
+ return str(args)[:60]