omega-code 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. omega/__init__.py +0 -0
  2. omega/__main__.py +589 -0
  3. omega/artifacts.py +151 -0
  4. omega/checkpoint.py +246 -0
  5. omega/compact.py +106 -0
  6. omega/config.py +285 -0
  7. omega/eval/__init__.py +3 -0
  8. omega/eval/cli.py +127 -0
  9. omega/eval/examples/plan-version-flag.yaml +11 -0
  10. omega/eval/examples/relative-age-negative-delta.yaml +14 -0
  11. omega/eval/examples/version-flag.yaml +10 -0
  12. omega/eval/manifest.py +129 -0
  13. omega/eval/prices.py +29 -0
  14. omega/eval/report.py +135 -0
  15. omega/eval/runner.py +199 -0
  16. omega/eval/tasks.py +97 -0
  17. omega/events.py +145 -0
  18. omega/export.py +80 -0
  19. omega/gitlog.py +229 -0
  20. omega/hooks.py +63 -0
  21. omega/instructions.py +103 -0
  22. omega/integrations.py +284 -0
  23. omega/keys.py +173 -0
  24. omega/llm.py +442 -0
  25. omega/loop.py +510 -0
  26. omega/mcp.py +490 -0
  27. omega/memory/__init__.py +5 -0
  28. omega/memory/consolidate.py +103 -0
  29. omega/memory/curate.py +69 -0
  30. omega/memory/store.py +321 -0
  31. omega/memory/tools.py +175 -0
  32. omega/migrate.py +40 -0
  33. omega/onboarding.py +242 -0
  34. omega/permissions.py +137 -0
  35. omega/secrets.py +173 -0
  36. omega/server/__init__.py +7 -0
  37. omega/server/__main__.py +18 -0
  38. omega/server/app.py +71 -0
  39. omega/server/auth.py +73 -0
  40. omega/server/manager.py +287 -0
  41. omega/server/models.py +123 -0
  42. omega/server/tasks_api.py +311 -0
  43. omega/server/terminals.py +245 -0
  44. omega/server/worker.py +186 -0
  45. omega/session.py +209 -0
  46. omega/setup.html +281 -0
  47. omega/setup_server.py +452 -0
  48. omega/skills.py +158 -0
  49. omega/subagent.py +98 -0
  50. omega/tasks.py +195 -0
  51. omega/tools.py +590 -0
  52. omega/trace.py +156 -0
  53. omega/trajectory.py +146 -0
  54. omega/ui/__init__.py +0 -0
  55. omega/ui/composer.py +140 -0
  56. omega/ui/format.py +708 -0
  57. omega/ui/plain.py +141 -0
  58. omega/ui/tui/__init__.py +9 -0
  59. omega/ui/tui/app.py +958 -0
  60. omega/ui/tui/history.py +50 -0
  61. omega/ui/tui/modals.py +292 -0
  62. omega/ui/tui/onboarding.py +367 -0
  63. omega/ui/tui/prefs.py +25 -0
  64. omega/ui/tui/sidebar.py +510 -0
  65. omega/ui/tui/status.py +115 -0
  66. omega/ui/tui/theme.py +91 -0
  67. omega/ui/tui/transcript.py +783 -0
  68. omega/verify.py +133 -0
  69. omega_code-0.4.0.dist-info/METADATA +479 -0
  70. omega_code-0.4.0.dist-info/RECORD +73 -0
  71. omega_code-0.4.0.dist-info/WHEEL +4 -0
  72. omega_code-0.4.0.dist-info/entry_points.txt +2 -0
  73. omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
omega/artifacts.py ADDED
@@ -0,0 +1,151 @@
1
+ import json
2
+ import secrets
3
+ import time
4
+ from collections import OrderedDict
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .tools import truncate
9
+
10
+ DIR = Path.home() / ".omega" / "sessions"
11
+
12
+ Meta = dict[str, Any]
13
+
14
+ # Threshold above which a tool result is offloaded to an artifact instead of
15
+ # returned inline.
16
+ OFFLOAD_THRESHOLD = 4000
17
+
18
+ # A single stored artifact is capped here even though the tool result that
19
+ # produced it may be larger -- a clipped result on disk still beats no
20
+ # artifact at all, and beats forcing the whole thing through the preview.
21
+ RESULT_MAX_CHARS = 100_000
22
+
23
+ # Default page size for fetch_result -- comfortably under tools.MAX_INLINE_CHARS
24
+ # so a default-sized page never itself needs the inline re-entry cap.
25
+ PAGE_CHARS = 18_000
26
+
27
+ # Per-process, per-session content cache checked before disk. Bounded by
28
+ # entry count rather than bytes: RESULT_MAX_CHARS already caps a single
29
+ # entry, so MAX_CACHE_ENTRIES * RESULT_MAX_CHARS is a safe worst case.
30
+ MAX_CACHE_ENTRIES = 50
31
+ _CACHE: OrderedDict[str, str] = OrderedDict()
32
+
33
+
34
+ def _cache_key(session_id: str, artifact_id: str) -> str:
35
+ return f"{session_id}/{artifact_id}"
36
+
37
+
38
+ def _cache_put(session_id: str, artifact_id: str, content: str) -> None:
39
+ key = _cache_key(session_id, artifact_id)
40
+ _CACHE[key] = content
41
+ _CACHE.move_to_end(key)
42
+ while len(_CACHE) > MAX_CACHE_ENTRIES:
43
+ _CACHE.popitem(last=False)
44
+
45
+
46
+ def _cache_get(session_id: str, artifact_id: str) -> str | None:
47
+ key = _cache_key(session_id, artifact_id)
48
+ if key not in _CACHE:
49
+ return None
50
+ _CACHE.move_to_end(key)
51
+ return _CACHE[key]
52
+
53
+
54
+ def _session_dir(session_id: str) -> Path:
55
+ return DIR / session_id / "artifacts"
56
+
57
+
58
+ def _paths(session_id: str, artifact_id: str) -> tuple[Path, Path]:
59
+ d = _session_dir(session_id)
60
+ return d / f"{artifact_id}.txt", d / f"{artifact_id}.meta.json"
61
+
62
+
63
+ def _write_meta(meta_path: Path, meta: Meta) -> None:
64
+ tmp = meta_path.with_suffix(".tmp")
65
+ tmp.write_text(json.dumps(meta, indent=1))
66
+ tmp.replace(meta_path)
67
+
68
+
69
+ def save(session_id: str, content: str, title: str | None = None, kind: str = "offload") -> str:
70
+ d = _session_dir(session_id)
71
+ d.mkdir(parents=True, exist_ok=True)
72
+ artifact_id = secrets.token_hex(4)
73
+ text_path, meta_path = _paths(session_id, artifact_id)
74
+ stored = content if len(content) <= RESULT_MAX_CHARS else truncate(content, RESULT_MAX_CHARS)
75
+ text_path.write_text(stored)
76
+ now = time.time()
77
+ _write_meta(meta_path, {
78
+ "title": title or "",
79
+ "kind": kind,
80
+ "created": now,
81
+ "updated": now,
82
+ "char_count": len(stored),
83
+ })
84
+ _cache_put(session_id, artifact_id, stored)
85
+ return artifact_id
86
+
87
+
88
+ def fetch(session_id: str, artifact_id: str, offset: int = 0, limit: int = PAGE_CHARS) -> str:
89
+ """Returns `content[offset:offset+limit]` plus a trailer line: either
90
+ `[end]` when the page reaches the artifact's end, or
91
+ `[chars <start>-<end> of <total>; next_offset=<n>]` so the model knows how
92
+ to page for more without re-fetching from the start."""
93
+ content = _cache_get(session_id, artifact_id)
94
+ if content is None:
95
+ text_path, _meta_path = _paths(session_id, artifact_id)
96
+ if not text_path.exists():
97
+ return f"error: no artifact {artifact_id!r} in session {session_id!r}"
98
+ content = text_path.read_text()
99
+ _cache_put(session_id, artifact_id, content)
100
+
101
+ total = len(content)
102
+ end = min(offset + limit, total)
103
+ chunk = content[offset:end]
104
+ trailer = "[end]" if end >= total else f"[chars {offset}-{end} of {total}; next_offset={end}]"
105
+ return f"{chunk}\n{trailer}"
106
+
107
+
108
+ def update(session_id: str, artifact_id: str, content: str) -> str:
109
+ text_path, meta_path = _paths(session_id, artifact_id)
110
+ if not meta_path.exists():
111
+ return f"error: no artifact {artifact_id!r} in session {session_id!r}"
112
+ stored = content if len(content) <= RESULT_MAX_CHARS else truncate(content, RESULT_MAX_CHARS)
113
+ text_path.write_text(stored)
114
+ meta = json.loads(meta_path.read_text())
115
+ meta["char_count"] = len(stored)
116
+ meta["updated"] = time.time()
117
+ _write_meta(meta_path, meta)
118
+ _cache_put(session_id, artifact_id, stored)
119
+ return f"updated artifact {artifact_id}"
120
+
121
+
122
+ def list_artifacts(session_id: str) -> list[Meta]:
123
+ d = _session_dir(session_id)
124
+ if not d.exists():
125
+ return []
126
+ out: list[Meta] = []
127
+ for meta_path in sorted(d.glob("*.meta.json")):
128
+ artifact_id = meta_path.name.removesuffix(".meta.json")
129
+ try:
130
+ meta = json.loads(meta_path.read_text())
131
+ except Exception:
132
+ continue
133
+ out.append({
134
+ "id": artifact_id,
135
+ "title": meta.get("title", ""),
136
+ "kind": meta.get("kind", "offload"),
137
+ "size": meta.get("char_count", 0),
138
+ "created": meta.get("created", 0),
139
+ })
140
+ return out
141
+
142
+
143
+ def offload_if_large(text: str, session_id: str, threshold: int = OFFLOAD_THRESHOLD,
144
+ preview_limit: int = 1200) -> str:
145
+ if len(text) <= threshold:
146
+ return text
147
+ artifact_id = save(session_id, text, kind="offload")
148
+ clipped_note = f" (clipped to {RESULT_MAX_CHARS} chars on save)" if len(text) > RESULT_MAX_CHARS else ""
149
+ return (f"{truncate(text, preview_limit)}\n"
150
+ f"[full output: {len(text)} chars{clipped_note}, saved as artifact {artifact_id} "
151
+ f"— fetch_result({artifact_id}) to read more]")
omega/checkpoint.py ADDED
@@ -0,0 +1,246 @@
1
+ """Working-tree checkpoints for BUILD-mode turns.
2
+
3
+ Each checkpoint snapshots the working tree into a git tree object via a
4
+ throwaway index (GIT_INDEX_FILE pointed at a temp file) so the user's real
5
+ index/staging area and HEAD are never touched. `.omega/` is excluded from
6
+ every snapshot so undo/diff never reads or writes omega's own state.
7
+ """
8
+ import json
9
+ import os
10
+ import secrets
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+ import time
15
+ from dataclasses import asdict, dataclass
16
+ from pathlib import Path
17
+
18
+ DIR = Path.home() / ".omega" / "sessions"
19
+
20
+ NOT_A_REPO = "not a git repository"
21
+
22
+ # Pathspec passed to every `git add -A` snapshot: everything except omega's
23
+ # own project-local state directory.
24
+ _PATHSPEC = ("--", ".", ":!.omega")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Checkpoint:
29
+ id: str
30
+ turn: int
31
+ created: float
32
+ tree_sha: str
33
+
34
+
35
+ def _checkpoints_path(session_id: str) -> Path:
36
+ return DIR / session_id / "checkpoints.json"
37
+
38
+
39
+ def _load(session_id: str) -> list[Checkpoint]:
40
+ path = _checkpoints_path(session_id)
41
+ if not path.exists():
42
+ return []
43
+ try:
44
+ raw = json.loads(path.read_text())
45
+ except json.JSONDecodeError:
46
+ return []
47
+ return [Checkpoint(**c) for c in raw]
48
+
49
+
50
+ def _save(session_id: str, checkpoints: list[Checkpoint]) -> None:
51
+ path = _checkpoints_path(session_id)
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ tmp = path.with_suffix(".tmp")
54
+ tmp.write_text(json.dumps([asdict(c) for c in checkpoints], indent=1))
55
+ tmp.replace(path)
56
+
57
+
58
+ def _find(checkpoints: list[Checkpoint], since_turn: int | None) -> Checkpoint | None:
59
+ if since_turn is None:
60
+ return checkpoints[0] if checkpoints else None
61
+ for cp in checkpoints:
62
+ if cp.turn == since_turn:
63
+ return cp
64
+ return None
65
+
66
+
67
+ def _repo_root(cwd: str) -> Path | None:
68
+ if shutil.which("git") is None:
69
+ return None
70
+ try:
71
+ result = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=cwd,
72
+ capture_output=True, text=True, timeout=10)
73
+ except (OSError, subprocess.SubprocessError):
74
+ return None
75
+ if result.returncode != 0 or not result.stdout.strip():
76
+ return None
77
+ return Path(result.stdout.strip())
78
+
79
+
80
+ def _temp_index() -> str:
81
+ """A path git can treat as a fresh index -- created then removed so
82
+ `git add`/`read-tree` populate it from nothing, not a stale prior run."""
83
+ fd, path = tempfile.mkstemp(prefix="omega-checkpoint-idx-")
84
+ os.close(fd)
85
+ os.remove(path)
86
+ return path
87
+
88
+
89
+ def _write_tree(repo_root: Path) -> str | None:
90
+ idx_path = _temp_index()
91
+ env = {**os.environ, "GIT_INDEX_FILE": idx_path}
92
+ try:
93
+ added = subprocess.run(["git", "add", "-A", *_PATHSPEC], cwd=repo_root, env=env,
94
+ capture_output=True, text=True, timeout=60)
95
+ if added.returncode != 0:
96
+ return None
97
+ written = subprocess.run(["git", "write-tree"], cwd=repo_root, env=env,
98
+ capture_output=True, text=True, timeout=30)
99
+ if written.returncode != 0:
100
+ return None
101
+ return written.stdout.strip()
102
+ except (OSError, subprocess.SubprocessError):
103
+ return None
104
+ finally:
105
+ Path(idx_path).unlink(missing_ok=True)
106
+
107
+
108
+ def create(session_id: str, turn: int, cwd: str | None = None) -> Checkpoint | None:
109
+ """Snapshots the working tree without touching the index or HEAD. Returns
110
+ None (never raises) when `cwd` isn't inside a git repo."""
111
+ root = _repo_root(cwd or os.getcwd())
112
+ if root is None:
113
+ return None
114
+ tree_sha = _write_tree(root)
115
+ if tree_sha is None:
116
+ return None
117
+ cp = Checkpoint(id=secrets.token_hex(4), turn=turn, created=time.time(), tree_sha=tree_sha)
118
+ checkpoints = _load(session_id)
119
+ checkpoints.append(cp)
120
+ _save(session_id, checkpoints)
121
+ return cp
122
+
123
+
124
+ def undo(session_id: str, steps: int = 1, cwd: str | None = None) -> str:
125
+ """Restores the working tree to the checkpoint `steps` turns back. Never
126
+ touches .git, untracked ignored files, or .omega/."""
127
+ root = _repo_root(cwd or os.getcwd())
128
+ if root is None:
129
+ return NOT_A_REPO
130
+ checkpoints = _load(session_id)
131
+ if steps < 1 or steps > len(checkpoints):
132
+ return f"no checkpoint {steps} turn(s) back (this session has {len(checkpoints)})"
133
+ target = checkpoints[-steps]
134
+
135
+ current_tree = _write_tree(root)
136
+ if current_tree is None:
137
+ return "error: could not snapshot the current working tree"
138
+
139
+ diffed = subprocess.run(
140
+ ["git", "diff-tree", "-r", "--no-renames", "--name-status", current_tree, target.tree_sha],
141
+ cwd=root, capture_output=True, text=True, timeout=30)
142
+ to_delete = [line[2:] for line in diffed.stdout.splitlines() if line.startswith("D\t")]
143
+ touched = len(diffed.stdout.splitlines())
144
+
145
+ idx_path = _temp_index()
146
+ env = {**os.environ, "GIT_INDEX_FILE": idx_path}
147
+ try:
148
+ read = subprocess.run(["git", "read-tree", target.tree_sha], cwd=root, env=env,
149
+ capture_output=True, text=True, timeout=30)
150
+ if read.returncode != 0:
151
+ return f"error: git read-tree failed: {read.stderr.strip()}"
152
+ checked_out = subprocess.run(["git", "checkout-index", "-a", "-f"], cwd=root, env=env,
153
+ capture_output=True, text=True, timeout=60)
154
+ if checked_out.returncode != 0:
155
+ return f"error: git checkout-index failed: {checked_out.stderr.strip()}"
156
+ except (OSError, subprocess.SubprocessError) as e:
157
+ return f"error: {type(e).__name__}: {e}"
158
+ finally:
159
+ Path(idx_path).unlink(missing_ok=True)
160
+
161
+ removed = 0
162
+ for rel in to_delete:
163
+ if rel.startswith(".omega/") or rel.startswith(".git/"):
164
+ continue
165
+ path = root / rel
166
+ try:
167
+ if path.exists() or path.is_symlink():
168
+ path.unlink()
169
+ removed += 1
170
+ except OSError:
171
+ continue
172
+ _prune_empty_parents(path.parent, root)
173
+
174
+ return (f"reverted {steps} turn(s) to the checkpoint from turn {target.turn} "
175
+ f"({touched} file(s) touched, {removed} removed)")
176
+
177
+
178
+ def _prune_empty_parents(start: Path, root: Path) -> None:
179
+ d = start
180
+ while d != root and root in d.parents and d.exists():
181
+ try:
182
+ next(d.iterdir())
183
+ return # not empty
184
+ except StopIteration:
185
+ pass
186
+ try:
187
+ d.rmdir()
188
+ except OSError:
189
+ return
190
+ d = d.parent
191
+
192
+
193
+ def diff(session_id: str, since_turn: int | None = None, cwd: str | None = None) -> str:
194
+ """Unified diff between the checkpoint before `since_turn` (default: the
195
+ session's first checkpoint) and the current working tree, untracked files
196
+ included."""
197
+ root = _repo_root(cwd or os.getcwd())
198
+ if root is None:
199
+ return NOT_A_REPO
200
+ checkpoints = _load(session_id)
201
+ target = _find(checkpoints, since_turn)
202
+ if target is None:
203
+ return "no checkpoint recorded yet this session" if since_turn is None \
204
+ else f"no checkpoint recorded for turn {since_turn}"
205
+
206
+ idx_path = _temp_index()
207
+ env = {**os.environ, "GIT_INDEX_FILE": idx_path}
208
+ try:
209
+ added = subprocess.run(["git", "add", "-A", *_PATHSPEC], cwd=root, env=env,
210
+ capture_output=True, text=True, timeout=60)
211
+ if added.returncode != 0:
212
+ return f"error: git add failed: {added.stderr.strip()}"
213
+ diffed = subprocess.run(["git", "diff", "--cached", target.tree_sha], cwd=root, env=env,
214
+ capture_output=True, text=True, timeout=60)
215
+ return diffed.stdout or "(no changes since that checkpoint)"
216
+ except (OSError, subprocess.SubprocessError) as e:
217
+ return f"error: {type(e).__name__}: {e}"
218
+ finally:
219
+ Path(idx_path).unlink(missing_ok=True)
220
+
221
+
222
+ def changed_files(session_id: str, since_turn: int | None = None, cwd: str | None = None) -> list[str]:
223
+ """Empty list for a non-git cwd or a session with no checkpoints -- unlike
224
+ undo()/diff(), the return type has no room for a sentinel message."""
225
+ root = _repo_root(cwd or os.getcwd())
226
+ if root is None:
227
+ return []
228
+ checkpoints = _load(session_id)
229
+ target = _find(checkpoints, since_turn)
230
+ if target is None:
231
+ return []
232
+
233
+ idx_path = _temp_index()
234
+ env = {**os.environ, "GIT_INDEX_FILE": idx_path}
235
+ try:
236
+ added = subprocess.run(["git", "add", "-A", *_PATHSPEC], cwd=root, env=env,
237
+ capture_output=True, text=True, timeout=60)
238
+ if added.returncode != 0:
239
+ return []
240
+ listed = subprocess.run(["git", "diff", "--cached", "--name-only", target.tree_sha],
241
+ cwd=root, env=env, capture_output=True, text=True, timeout=60)
242
+ return [line for line in listed.stdout.splitlines() if line]
243
+ except (OSError, subprocess.SubprocessError):
244
+ return []
245
+ finally:
246
+ Path(idx_path).unlink(missing_ok=True)
omega/compact.py ADDED
@@ -0,0 +1,106 @@
1
+ import json
2
+ from typing import cast
3
+
4
+ from . import llm, trajectory
5
+ from .config import Config
6
+ from .llm import Turn
7
+ from .session import Message
8
+
9
+ FRACTION = 0.75
10
+ KEEP_LAST = 6
11
+
12
+ # Cap on what the summariser LLM ever sees: the transcript already truncates
13
+ # each message to 1500 chars, but a long-running turn can still pile up far
14
+ # more messages than that keeps sane for one summarisation call.
15
+ _COMPACT_SUMMARY_INPUT_CHARS = 40_000
16
+
17
+ # Per-line cap for the deterministic ledger appended alongside the LLM
18
+ # summary -- generous compared to the live trajectory block's 64/120 char
19
+ # fields, since this is the durable record of a range that is about to be
20
+ # dropped from history for good.
21
+ _COMPACT_ENTRY_CHARS = 1000
22
+
23
+ SYSTEM = """Summarise this conversation excerpt for an agent that will keep working.
24
+
25
+ Preserve: what was asked, what was done, file paths touched, decisions made,
26
+ errors hit and how they were resolved, and anything still outstanding.
27
+ Drop: raw file contents, long command output, and restated code.
28
+
29
+ Write dense prose. No preamble."""
30
+
31
+
32
+ def estimate_tokens(messages: list[Message]) -> int:
33
+ return sum(len(json.dumps(m)) for m in messages) // 4
34
+
35
+
36
+ def safe_split(history: list[Message], keep_last: int) -> int:
37
+ """Return an index to cut at that never separates an assistant message
38
+ carrying tool_calls from the tool results that answer it.
39
+
40
+ Only the position immediately before a user message is safe, because tool
41
+ results always follow their assistant message inside a turn.
42
+ """
43
+ if len(history) <= keep_last:
44
+ return 0
45
+ for i in range(max(1, len(history) - keep_last), 0, -1):
46
+ m = history[i]
47
+ # Safe boundaries: a user message, or an assistant message with no
48
+ # tool_calls (its group is complete). Cutting anywhere else orphans a
49
+ # tool_call_id. Assistant boundaries matter because an agentic turn has
50
+ # exactly ONE user message -- at index 0, which can never be a cut.
51
+ if m.get("role") == "user":
52
+ return i
53
+ if m.get("role") == "assistant" and not m.get("tool_calls"):
54
+ return i
55
+ return 0
56
+
57
+
58
+ async def maybe_compact(cfg: Config, history: list[Message], used: int, limit: int,
59
+ fraction: float = FRACTION, keep_last: int = KEEP_LAST) -> str | None:
60
+ """Shrink history in place. Returns a note if it compacted, else None."""
61
+ if limit <= 0 or used < limit * fraction:
62
+ return None
63
+
64
+ cut = safe_split(history, keep_last)
65
+ if cut == 0:
66
+ return None
67
+
68
+ older, recent = history[:cut], history[cut:]
69
+ transcript = "\n\n".join(
70
+ f"[{m.get('role')}] {str(m.get('content'))[:1500]}" for m in older
71
+ if m.get("content"))[:_COMPACT_SUMMARY_INPUT_CHARS]
72
+
73
+ role = cfg.role("compact") if "compact" in cfg.roles else cfg.role("main")
74
+ summary = ""
75
+ async for kind, payload in llm.stream(
76
+ role, [{"role": "system", "content": SYSTEM},
77
+ {"role": "user", "content": transcript}]):
78
+ if kind == "done":
79
+ summary = cast(Turn, payload).text
80
+
81
+ if not summary.strip():
82
+ return None
83
+
84
+ # Anthropic's "preserved thinking" check binds a Claude Fable 5.1 thinking
85
+ # block's signature to the exact conversation prefix that produced it;
86
+ # summarising `older` into one message is itself a history edit, which
87
+ # invalidates every thinking block still carried by `recent`. Dropping the
88
+ # blocks (unbilled, and explicitly sanctioned as the no-beta recovery path)
89
+ # is simpler and safer than keeping them alive across a rewritten prefix --
90
+ # the model just answers the next turn without that carried-over reasoning.
91
+ for m in recent:
92
+ m.pop("thinking", None)
93
+
94
+ ledger_lines = trajectory.compaction_lines(older, _COMPACT_ENTRY_CHARS)
95
+ ledger_body = "\n".join(ledger_lines) if ledger_lines else "(no tool calls in this range)"
96
+
97
+ # The ledger is its own message, never folded into the summary message
98
+ # above: mutating an already-emitted message's content would invalidate
99
+ # the Anthropic prompt cache for everything after it, while appending a
100
+ # brand-new message only ever extends the cached prefix.
101
+ history[:] = [
102
+ {"role": "user", "content": f"[Summary of earlier conversation]\n{summary}"},
103
+ {"role": "user", "content": f"[Action ledger for dropped range]\n{ledger_body}"},
104
+ *recent,
105
+ ]
106
+ return f"compacted {len(older)} messages → summary"