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.
- omega/__init__.py +0 -0
- omega/__main__.py +589 -0
- omega/artifacts.py +151 -0
- omega/checkpoint.py +246 -0
- omega/compact.py +106 -0
- omega/config.py +285 -0
- omega/eval/__init__.py +3 -0
- omega/eval/cli.py +127 -0
- omega/eval/examples/plan-version-flag.yaml +11 -0
- omega/eval/examples/relative-age-negative-delta.yaml +14 -0
- omega/eval/examples/version-flag.yaml +10 -0
- omega/eval/manifest.py +129 -0
- omega/eval/prices.py +29 -0
- omega/eval/report.py +135 -0
- omega/eval/runner.py +199 -0
- omega/eval/tasks.py +97 -0
- omega/events.py +145 -0
- omega/export.py +80 -0
- omega/gitlog.py +229 -0
- omega/hooks.py +63 -0
- omega/instructions.py +103 -0
- omega/integrations.py +284 -0
- omega/keys.py +173 -0
- omega/llm.py +442 -0
- omega/loop.py +510 -0
- omega/mcp.py +490 -0
- omega/memory/__init__.py +5 -0
- omega/memory/consolidate.py +103 -0
- omega/memory/curate.py +69 -0
- omega/memory/store.py +321 -0
- omega/memory/tools.py +175 -0
- omega/migrate.py +40 -0
- omega/onboarding.py +242 -0
- omega/permissions.py +137 -0
- omega/secrets.py +173 -0
- omega/server/__init__.py +7 -0
- omega/server/__main__.py +18 -0
- omega/server/app.py +71 -0
- omega/server/auth.py +73 -0
- omega/server/manager.py +287 -0
- omega/server/models.py +123 -0
- omega/server/tasks_api.py +311 -0
- omega/server/terminals.py +245 -0
- omega/server/worker.py +186 -0
- omega/session.py +209 -0
- omega/setup.html +281 -0
- omega/setup_server.py +452 -0
- omega/skills.py +158 -0
- omega/subagent.py +98 -0
- omega/tasks.py +195 -0
- omega/tools.py +590 -0
- omega/trace.py +156 -0
- omega/trajectory.py +146 -0
- omega/ui/__init__.py +0 -0
- omega/ui/composer.py +140 -0
- omega/ui/format.py +708 -0
- omega/ui/plain.py +141 -0
- omega/ui/tui/__init__.py +9 -0
- omega/ui/tui/app.py +958 -0
- omega/ui/tui/history.py +50 -0
- omega/ui/tui/modals.py +292 -0
- omega/ui/tui/onboarding.py +367 -0
- omega/ui/tui/prefs.py +25 -0
- omega/ui/tui/sidebar.py +510 -0
- omega/ui/tui/status.py +115 -0
- omega/ui/tui/theme.py +91 -0
- omega/ui/tui/transcript.py +783 -0
- omega/verify.py +133 -0
- omega_code-0.4.0.dist-info/METADATA +479 -0
- omega_code-0.4.0.dist-info/RECORD +73 -0
- omega_code-0.4.0.dist-info/WHEEL +4 -0
- omega_code-0.4.0.dist-info/entry_points.txt +2 -0
- 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"
|