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/eval/report.py ADDED
@@ -0,0 +1,135 @@
1
+ import json
2
+ import time
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .manifest import ContextManifest
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class RunResult:
12
+ task: str
13
+ model: str
14
+ repeat: int
15
+ passed: bool
16
+ turns: int
17
+ tool_calls: dict[str, int]
18
+ tokens_in: int
19
+ tokens_out: int
20
+ cost_usd: float | None
21
+ wall_time_s: float
22
+ cache_hit_ratio: float | None
23
+ error: str | None
24
+ check_output: str
25
+ manifest: ContextManifest | None = None
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ return {
29
+ "task": self.task, "model": self.model, "repeat": self.repeat,
30
+ "passed": self.passed, "turns": self.turns, "tool_calls": self.tool_calls,
31
+ "tokens_in": self.tokens_in, "tokens_out": self.tokens_out,
32
+ "cost_usd": self.cost_usd, "wall_time_s": round(self.wall_time_s, 3),
33
+ "cache_hit_ratio": self.cache_hit_ratio, "error": self.error,
34
+ "check_output": self.check_output[:2000],
35
+ "manifest": self.manifest.to_dict() if self.manifest else None,
36
+ }
37
+
38
+
39
+ def _result_from_dict(d: dict[str, Any]) -> RunResult:
40
+ return RunResult(
41
+ task=d["task"], model=d["model"], repeat=d["repeat"], passed=d["passed"],
42
+ turns=d["turns"], tool_calls=dict(d["tool_calls"]), tokens_in=d["tokens_in"],
43
+ tokens_out=d["tokens_out"], cost_usd=d["cost_usd"], wall_time_s=d["wall_time_s"],
44
+ cache_hit_ratio=d["cache_hit_ratio"], error=d["error"], check_output=d["check_output"],
45
+ # `compare` only needs the summary row -- reloading a full ContextManifest
46
+ # from JSON isn't worth the code, so the manifest stays report.json-only.
47
+ manifest=None,
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Report:
53
+ created: float
54
+ results: tuple[RunResult, ...] = field(default_factory=tuple)
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ return {"created": self.created, "results": [r.to_dict() for r in self.results]}
58
+
59
+ def summary_by_model(self) -> dict[str, dict[str, Any]]:
60
+ by_model: dict[str, list[RunResult]] = {}
61
+ for r in self.results:
62
+ by_model.setdefault(r.model, []).append(r)
63
+ out: dict[str, dict[str, Any]] = {}
64
+ for model, rows in by_model.items():
65
+ costs = [r.cost_usd for r in rows if r.cost_usd is not None]
66
+ out[model] = {
67
+ "runs": len(rows),
68
+ "pass_rate": sum(r.passed for r in rows) / len(rows),
69
+ "mean_cost_usd": (sum(costs) / len(costs)) if costs else None,
70
+ "mean_time_s": sum(r.wall_time_s for r in rows) / len(rows),
71
+ }
72
+ return out
73
+
74
+
75
+ def render_table(report: Report) -> str:
76
+ if not report.results:
77
+ return "(no runs)"
78
+ header = (f"{'TASK':<30}{'MODEL':<10}{'RESULT':>7}{'TURNS':>7}{'TOOLS':>7}"
79
+ f"{'TOK IN':>9}{'TOK OUT':>9}{'$':>9}{'TIME':>8}")
80
+ lines = [header]
81
+ for r in report.results:
82
+ tool_total = sum(r.tool_calls.values())
83
+ cost = f"{r.cost_usd:.4f}" if r.cost_usd is not None else "-"
84
+ lines.append(f"{r.task[:29]:<30}{r.model:<10}{'PASS' if r.passed else 'FAIL':>7}"
85
+ f"{r.turns:>7}{tool_total:>7}{r.tokens_in:>9}{r.tokens_out:>9}"
86
+ f"{cost:>9}{r.wall_time_s:>7.1f}s")
87
+ lines.append("")
88
+ for model, s in sorted(report.summary_by_model().items()):
89
+ cost = f"${s['mean_cost_usd']:.4f}" if s["mean_cost_usd"] is not None else "$-"
90
+ lines.append(f"{model}: {s['pass_rate'] * 100:.0f}% pass · {cost} mean · "
91
+ f"{s['mean_time_s']:.1f}s mean · {s['runs']} run(s)")
92
+ return "\n".join(lines)
93
+
94
+
95
+ def compare(a: Report, b: Report) -> str:
96
+ def key(r: RunResult) -> tuple[str, str]:
97
+ return (r.task, r.model)
98
+
99
+ a_by = {key(r): r for r in a.results}
100
+ b_by = {key(r): r for r in b.results}
101
+ lines = [f"{'TASK':<30}{'MODEL':<10}{'PASS':>8}{'$ delta':>12}{'TIME delta':>14}"]
102
+ for k in sorted(set(a_by) | set(b_by)):
103
+ task, model = k
104
+ ra, rb = a_by.get(k), b_by.get(k)
105
+ if ra is None:
106
+ pass_change = "new(fail)" if not (rb and rb.passed) else "new(pass)"
107
+ elif rb is None:
108
+ pass_change = "gone"
109
+ elif ra.passed == rb.passed:
110
+ pass_change = "pass" if rb.passed else "fail"
111
+ else:
112
+ pass_change = "fixed" if rb.passed else "REGRESSED"
113
+ cost_delta = ("-" if not (ra and rb and ra.cost_usd is not None and rb.cost_usd is not None)
114
+ else f"{rb.cost_usd - ra.cost_usd:+.4f}")
115
+ time_delta = "-" if not (ra and rb) else f"{rb.wall_time_s - ra.wall_time_s:+.1f}s"
116
+ lines.append(f"{task[:29]:<30}{model:<10}{pass_change:>8}{cost_delta:>12}{time_delta:>14}")
117
+ return "\n".join(lines)
118
+
119
+
120
+ def write_report(report: Report, run_dir: Path) -> Path:
121
+ run_dir.mkdir(parents=True, exist_ok=True)
122
+ path = run_dir / "report.json"
123
+ tmp = path.with_suffix(".tmp")
124
+ tmp.write_text(json.dumps(report.to_dict(), indent=1))
125
+ tmp.replace(path)
126
+ return path
127
+
128
+
129
+ def load_report(path: Path) -> Report:
130
+ raw = json.loads(path.read_text())
131
+ return Report(created=raw["created"], results=tuple(_result_from_dict(r) for r in raw["results"]))
132
+
133
+
134
+ def new_run_dir(runs_root: Path) -> Path:
135
+ return runs_root / time.strftime("%Y%m%d-%H%M%S")
omega/eval/runner.py ADDED
@@ -0,0 +1,199 @@
1
+ import asyncio
2
+ import os
3
+ import secrets
4
+ import shutil
5
+ import tempfile
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .. import events, loop, tools
11
+ from ..config import Config, Role
12
+ from ..session import Message
13
+ from .manifest import build_manifest
14
+ from .prices import estimate_cost
15
+ from .report import RunResult
16
+ from .tasks import Task
17
+
18
+ _CHECK_TIMEOUT_S = 300
19
+
20
+ # `tools._bash` resolves its working directory via `os.getcwd()`, which is
21
+ # process-wide -- two runs executing tools concurrently in different
22
+ # worktrees would race on which directory a bash call actually lands in.
23
+ # `--jobs` still bounds how many runs are in flight (workspace prep/cleanup
24
+ # and the model's own network waits overlap fine); this lock only serializes
25
+ # the tool-executing portion of each run, which is the part that touches cwd.
26
+ _TOOL_LOCK = asyncio.Lock()
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Workspace:
31
+ path: Path
32
+ is_git_worktree: bool
33
+ source_repo: Path
34
+
35
+
36
+ def resolve_models(cfg: Config, models_arg: str | None) -> list[tuple[str, Role]]:
37
+ """`--models a,b,c` resolves each alias against the catalog; with no
38
+ `--models`, eval runs against whatever `main` currently points at."""
39
+ if not models_arg:
40
+ role = cfg.role("main")
41
+ return [(role.alias or "main", role)]
42
+ out: list[tuple[str, Role]] = []
43
+ for raw in models_arg.split(","):
44
+ alias = cfg.resolve_alias(raw.strip())
45
+ out.append((alias, cfg.model(alias)))
46
+ return out
47
+
48
+
49
+ async def _is_git_repo(repo: Path) -> bool:
50
+ proc = await asyncio.create_subprocess_exec(
51
+ "git", "-C", str(repo), "rev-parse", "--is-inside-work-tree",
52
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
53
+ await proc.communicate()
54
+ return proc.returncode == 0
55
+
56
+
57
+ async def prepare_workspace(task: Task, work_root: Path) -> Workspace:
58
+ repo = Path(task.repo).expanduser().resolve()
59
+ work_root.mkdir(parents=True, exist_ok=True)
60
+ dest = work_root / f"{task.name.replace(' ', '_')}-{secrets.token_hex(3)}"
61
+ is_git = await _is_git_repo(repo)
62
+ if is_git:
63
+ proc = await asyncio.create_subprocess_exec(
64
+ "git", "-C", str(repo), "worktree", "add", "--detach", str(dest), "HEAD",
65
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
66
+ _out, err = await proc.communicate()
67
+ if proc.returncode != 0:
68
+ raise RuntimeError(f"git worktree add failed: {err.decode(errors='replace')[:500]}")
69
+ else:
70
+ shutil.copytree(repo, dest)
71
+
72
+ if task.setup:
73
+ proc = await asyncio.create_subprocess_shell(
74
+ task.setup, cwd=str(dest),
75
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
76
+ _out, err = await proc.communicate()
77
+ if proc.returncode != 0:
78
+ raise RuntimeError(f"setup command failed ({proc.returncode}): "
79
+ f"{err.decode(errors='replace')[:2000]}")
80
+ return Workspace(path=dest, is_git_worktree=is_git, source_repo=repo)
81
+
82
+
83
+ async def cleanup_workspace(ws: Workspace) -> None:
84
+ if ws.is_git_worktree:
85
+ proc = await asyncio.create_subprocess_exec(
86
+ "git", "-C", str(ws.source_repo), "worktree", "remove", "--force", str(ws.path),
87
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
88
+ await proc.communicate()
89
+ else:
90
+ shutil.rmtree(ws.path, ignore_errors=True)
91
+
92
+
93
+ async def _run_check(check: str, cwd: Path) -> tuple[bool, str]:
94
+ proc = await asyncio.create_subprocess_shell(
95
+ check, cwd=str(cwd),
96
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
97
+ try:
98
+ out, _ = await asyncio.wait_for(proc.communicate(), timeout=_CHECK_TIMEOUT_S)
99
+ except TimeoutError:
100
+ proc.kill()
101
+ await proc.communicate()
102
+ return False, f"check timed out after {_CHECK_TIMEOUT_S}s"
103
+ return proc.returncode == 0, out.decode(errors="replace")[-4000:]
104
+
105
+
106
+ async def run_one(cfg: Config, task: Task, label: str, role: Role, ws: Workspace,
107
+ repeat: int = 0) -> RunResult:
108
+ """Runs one task against one model in an already-prepared workspace.
109
+ Consumes `loop.run_agent` exactly as the CLI does -- `--yolo` semantics
110
+ (no CONFIRM/ASK_USER), events collected via `emit` instead of rendered."""
111
+ system, tool_names = loop.MODES[task.mode]
112
+ system = f"{system}\n{loop.UNTRUSTED_NOTE}"
113
+ schemas = tools.schemas(tool_names)
114
+ history: list[Message] = [{"role": "user", "content": task.prompt}]
115
+ initial_history = list(history)
116
+ collected: list[events.Event] = []
117
+ error: str | None = None
118
+ result_text = ""
119
+
120
+ started = time.monotonic()
121
+ prev_cwd = os.getcwd()
122
+ # tools.SESSION_ID/CONFIRM/ASK_USER/TAINTED are process globals `tools.run`
123
+ # reads directly -- this run isn't the only thing that may touch them
124
+ # (a caller embedding the eval harness in a live session, or -- in
125
+ # tests -- an unrelated fixture), so snapshot and restore them rather
126
+ # than assuming None/False is always the right thing to leave behind.
127
+ prev_session_id, prev_confirm, prev_ask_user, prev_tainted = (
128
+ tools.SESSION_ID, tools.CONFIRM, tools.ASK_USER, tools.TAINTED)
129
+ async with _TOOL_LOCK:
130
+ tools.SESSION_ID = f"eval-{task.name}-{label}-{repeat}-{secrets.token_hex(3)}"
131
+ tools.CONFIRM = None
132
+ tools.ASK_USER = None
133
+ tools.set_tainted(False)
134
+ os.chdir(ws.path)
135
+ try:
136
+ result_text = await asyncio.wait_for(
137
+ loop.run_agent(cfg, "main" if task.mode == "build" else "plan",
138
+ system, history, tool_names, emit=collected.append, role=role),
139
+ timeout=task.timeout_s)
140
+ except TimeoutError:
141
+ error = f"timed out after {task.timeout_s}s"
142
+ except Exception as e:
143
+ error = f"{type(e).__name__}: {e}"
144
+ finally:
145
+ os.chdir(prev_cwd)
146
+ tools.SESSION_ID, tools.CONFIRM, tools.ASK_USER = (
147
+ prev_session_id, prev_confirm, prev_ask_user)
148
+ tools.set_tainted(prev_tainted)
149
+ wall_time_s = time.monotonic() - started
150
+
151
+ passed, check_output = False, ""
152
+ if error is None:
153
+ (ws.path / "TRANSCRIPT.md").write_text(result_text)
154
+ passed, check_output = await _run_check(task.check, ws.path)
155
+ else:
156
+ check_output = error
157
+
158
+ turns = sum(1 for ev in collected if isinstance(ev, events.Phase) and ev.state == "waiting")
159
+ tool_calls: dict[str, int] = {}
160
+ for ev in collected:
161
+ if isinstance(ev, events.ToolStart):
162
+ tool_calls[ev.name] = tool_calls.get(ev.name, 0) + 1
163
+ usage_events = [ev for ev in collected if isinstance(ev, events.Usage)]
164
+ tokens_in = sum(ev.prompt_tokens for ev in usage_events)
165
+ tokens_out = sum(ev.completion_tokens for ev in usage_events)
166
+ cache_total = sum(getattr(ev, "cached_tokens", None) or getattr(ev, "cache_read", None) or 0
167
+ for ev in usage_events)
168
+ cache_hit_ratio = (cache_total / tokens_in) if tokens_in else None
169
+ manifest = build_manifest(collected, system, schemas, initial_history, history)
170
+
171
+ return RunResult(task=task.name, model=label, repeat=repeat, passed=passed, turns=turns,
172
+ tool_calls=tool_calls, tokens_in=tokens_in, tokens_out=tokens_out,
173
+ cost_usd=estimate_cost(label, tokens_in, tokens_out),
174
+ wall_time_s=wall_time_s, cache_hit_ratio=cache_hit_ratio,
175
+ error=error, check_output=check_output, manifest=manifest)
176
+
177
+
178
+ async def run_suite(cfg: Config, tasks: list[Task], roles: list[tuple[str, Role]],
179
+ repeat: int = 1, jobs: int = 1,
180
+ work_root: Path | None = None) -> list[RunResult]:
181
+ owns_root = work_root is None
182
+ work_root = work_root or Path(tempfile.mkdtemp(prefix="omega-eval-"))
183
+ sem = asyncio.Semaphore(max(1, jobs))
184
+
185
+ async def _one(task: Task, label: str, role: Role, i: int) -> RunResult:
186
+ async with sem:
187
+ ws = await prepare_workspace(task, work_root)
188
+ try:
189
+ return await run_one(cfg, task, label, role, ws, repeat=i)
190
+ finally:
191
+ await cleanup_workspace(ws)
192
+
193
+ coros = [_one(task, label, role, i)
194
+ for task in tasks for label, role in roles for i in range(repeat)]
195
+ try:
196
+ return list(await asyncio.gather(*coros))
197
+ finally:
198
+ if owns_root:
199
+ shutil.rmtree(work_root, ignore_errors=True)
omega/eval/tasks.py ADDED
@@ -0,0 +1,97 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+ from typing import Any, Literal
4
+
5
+ import yaml
6
+
7
+ Mode = Literal["build", "plan"]
8
+
9
+ _REQUIRED = ("name", "prompt", "check")
10
+ _MODES = ("build", "plan")
11
+
12
+ EXAMPLES_DIR = Path(__file__).parent / "examples"
13
+
14
+
15
+ class TaskError(ValueError):
16
+ """A task file is missing a required field, has an invalid value, or the
17
+ given path/directory doesn't resolve to any task -- distinct from
18
+ ValueError so callers can catch just this without swallowing unrelated
19
+ bugs in yaml.safe_load or dataclass construction."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Task:
24
+ name: str
25
+ prompt: str
26
+ check: str
27
+ repo: str = "."
28
+ setup: str | None = None
29
+ timeout_s: int = 600
30
+ mode: Mode = "build"
31
+ tags: tuple[str, ...] = field(default_factory=tuple)
32
+ source: str = ""
33
+
34
+
35
+ def parse_task(raw: dict[str, Any], source: str = "") -> Task:
36
+ if not isinstance(raw, dict):
37
+ raise TaskError(f"{source}: task must be a YAML mapping, got {type(raw).__name__}")
38
+ missing = [f for f in _REQUIRED if not raw.get(f)]
39
+ if missing:
40
+ raise TaskError(f"{source}: missing required field(s): {', '.join(missing)}")
41
+
42
+ mode = raw.get("mode", "build")
43
+ if mode not in _MODES:
44
+ raise TaskError(f"{source}: mode must be one of {_MODES}, got {mode!r}")
45
+
46
+ timeout_s = raw.get("timeout_s", 600)
47
+ if not isinstance(timeout_s, int) or isinstance(timeout_s, bool) or timeout_s <= 0:
48
+ raise TaskError(f"{source}: timeout_s must be a positive integer, got {timeout_s!r}")
49
+
50
+ tags = raw.get("tags") or []
51
+ if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags):
52
+ raise TaskError(f"{source}: tags must be a list of strings")
53
+
54
+ setup = raw.get("setup")
55
+ return Task(
56
+ name=str(raw["name"]), prompt=str(raw["prompt"]), check=str(raw["check"]),
57
+ repo=str(raw.get("repo", ".")), setup=(str(setup) if setup else None),
58
+ timeout_s=timeout_s, mode=mode, tags=tuple(tags), source=source,
59
+ )
60
+
61
+
62
+ def load_task_file(path: Path) -> Task:
63
+ try:
64
+ raw = yaml.safe_load(path.read_text())
65
+ except yaml.YAMLError as e:
66
+ raise TaskError(f"{path}: invalid YAML: {e}") from e
67
+ return parse_task(raw or {}, source=str(path))
68
+
69
+
70
+ def discover_task_files(path: str | None) -> list[Path]:
71
+ """`path=None` is the project default `.omega/evals/`; a directory globs
72
+ its *.yaml/*.yml; a file is used as-is."""
73
+ if path is None:
74
+ root = Path.cwd() / ".omega" / "evals"
75
+ if not root.exists():
76
+ return []
77
+ return sorted(root.glob("*.yaml")) + sorted(root.glob("*.yml"))
78
+ p = Path(path).expanduser()
79
+ if p.is_dir():
80
+ return sorted(p.glob("*.yaml")) + sorted(p.glob("*.yml"))
81
+ if p.is_file():
82
+ return [p]
83
+ raise TaskError(f"no such task file or directory: {p}")
84
+
85
+
86
+ def load_tasks(path: str | None = None) -> list[Task]:
87
+ return [load_task_file(f) for f in discover_task_files(path)]
88
+
89
+
90
+ def init_examples(dest: Path) -> list[Path]:
91
+ dest.mkdir(parents=True, exist_ok=True)
92
+ written = []
93
+ for src in sorted(EXAMPLES_DIR.glob("*.yaml")):
94
+ target = dest / src.name
95
+ target.write_text(src.read_text())
96
+ written.append(target)
97
+ return written
omega/events.py ADDED
@@ -0,0 +1,145 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal, TypedDict
3
+
4
+
5
+ class Option(TypedDict, total=False):
6
+ """An `ask_user` choice, as passed to `tools.ASK_USER` and rendered by both UIs."""
7
+ label: str
8
+ description: str
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TextDelta:
13
+ text: str
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ToolStart:
18
+ call_id: str
19
+ name: str
20
+ args_preview: str
21
+ subagent_id: str | None = None
22
+ tier: str | None = None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ToolEnd:
27
+ call_id: str
28
+ name: str
29
+ result_preview: str
30
+ duration_s: float
31
+ offloaded: bool
32
+ artifact_id: str | None = None
33
+ result_chars: int = 0
34
+ outcome: str = ""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Compacted:
39
+ note: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class MemoryWrite:
44
+ node_id: str
45
+ type: str
46
+ title: str
47
+ scope: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class MemoryConsolidated:
52
+ summary: str
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SubagentSpawned:
57
+ subagent_id: str
58
+ tier: str
59
+ task_preview: str
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class SubagentDone:
64
+ subagent_id: str
65
+ summary_preview: str
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Error:
70
+ message: str
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Done:
75
+ text: str
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Usage:
80
+ prompt_tokens: int
81
+ completion_tokens: int
82
+ used: int
83
+ limit: int
84
+ cache_read: int = 0
85
+ cache_write: int = 0
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Fallback:
90
+ from_model: str
91
+ to_model: str
92
+ reason: str
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class ModelUsed:
97
+ alias: str | None
98
+ model: str
99
+ provider: str
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Phase:
104
+ state: Literal["waiting", "thinking", "streaming", "tools", "idle"]
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class Checkpoint:
109
+ """A working-tree snapshot taken before a BUILD-mode turn -- see checkpoint.py."""
110
+ turn: int
111
+ id: str
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class Verified:
116
+ """One end-of-turn verification pass (possibly a retry) -- see verify.py."""
117
+ results_summary: str
118
+ ok: bool
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class JobStarted:
123
+ """A `bash(..., background=True)` job began running -- see tools.py."""
124
+ id: str
125
+ command: str
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class JobFinished:
130
+ id: str
131
+ exit_code: int
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class RetryBlocked:
136
+ """The harness refused to re-execute a tool call whose exact
137
+ (name, args) already failed repeatedly with the same error -- see
138
+ loop.py's per-turn repeat-fail guard."""
139
+ name: str
140
+ attempts: int
141
+
142
+
143
+ Event = (TextDelta | ToolStart | ToolEnd | Compacted | MemoryWrite |
144
+ MemoryConsolidated | SubagentSpawned | SubagentDone | Error | Done | Usage | ModelUsed | Phase |
145
+ Fallback | Checkpoint | Verified | JobStarted | JobFinished | RetryBlocked)
omega/export.py ADDED
@@ -0,0 +1,80 @@
1
+ """Session transcript -> Markdown, for `/export`. A pure function of
2
+ `history` (the same message list `session.Session` carries): user prompts as
3
+ `## › …`, assistant text verbatim, tool calls as a compact fenced list with
4
+ outcomes, and an offloaded result referenced by its artifact id rather than
5
+ re-embedded in full."""
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from . import session
14
+ from .session import Message
15
+ from .ui import format
16
+
17
+ _ARTIFACT_RE = re.compile(r"saved as artifact ([0-9a-f]+)")
18
+ _OUTCOME_CHARS = 120
19
+
20
+
21
+ def _tool_call_args(call: dict[str, Any]) -> dict[str, Any]:
22
+ fn = call.get("function") or {}
23
+ try:
24
+ parsed = json.loads(fn.get("arguments") or "{}")
25
+ except json.JSONDecodeError:
26
+ return {}
27
+ return parsed if isinstance(parsed, dict) else {}
28
+
29
+
30
+ def _outcome(result: str) -> str:
31
+ m = _ARTIFACT_RE.search(result)
32
+ if m:
33
+ return f"[artifact {m.group(1)}]"
34
+ flat = " ".join(result.split())
35
+ return flat if len(flat) <= _OUTCOME_CHARS else flat[:_OUTCOME_CHARS - 1] + "…"
36
+
37
+
38
+ def _tool_calls_block(tool_calls: list[dict[str, Any]], results: dict[str, str]) -> str:
39
+ lines = []
40
+ for call in tool_calls:
41
+ name = str((call.get("function") or {}).get("name", ""))
42
+ args = _tool_call_args(call)
43
+ result = results.get(str(call.get("id", "")), "")
44
+ lines.append(f"{format.describe_call(name, args)} → {_outcome(result)}")
45
+ return "```\n" + "\n".join(lines) + "\n```"
46
+
47
+
48
+ def to_markdown(history: list[Message], session_id: str = "") -> str:
49
+ results: dict[str, str] = {
50
+ str(m.get("tool_call_id", "")): str(m.get("content", ""))
51
+ for m in history if m.get("role") == "tool"
52
+ }
53
+
54
+ parts: list[str] = [f"# omega session {session_id}"] if session_id else []
55
+ for msg in history:
56
+ role = msg.get("role")
57
+ if role == "user":
58
+ content = str(msg.get("content", ""))
59
+ if content.startswith(session.RESUME_PREFIX):
60
+ continue
61
+ parts.append(f"## › {content}")
62
+ elif role == "assistant":
63
+ assistant_text = msg.get("content")
64
+ if assistant_text:
65
+ parts.append(str(assistant_text))
66
+ tool_calls = msg.get("tool_calls")
67
+ if tool_calls:
68
+ parts.append(_tool_calls_block(tool_calls, results))
69
+ return "\n\n".join(parts) + "\n"
70
+
71
+
72
+ def default_path(session_id: str) -> Path:
73
+ return session.DIR / session_id / "transcript.md"
74
+
75
+
76
+ def write(history: list[Message], session_id: str, path: str | None = None) -> Path:
77
+ out = Path(path).expanduser() if path else default_path(session_id)
78
+ out.parent.mkdir(parents=True, exist_ok=True)
79
+ out.write_text(to_markdown(history, session_id))
80
+ return out