infini-cli 0.1.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.
infini/replay.py ADDED
@@ -0,0 +1,137 @@
1
+ """Replay engine — `infini replay`.
2
+
3
+ Replays a run from a specific step. In V1 (mock mode), this re-executes
4
+ from the named step using the same mock engine. The original trace is
5
+ preserved; the replay produces a new trace with `replay_of` pointing
6
+ back to the original.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from rich.console import Console
14
+
15
+ from .parse import parse_file
16
+ from .trace import load_trace, save_trace, new_trace, add_step, add_verification, finalize_trace
17
+ from .mock import mock_execute, mock_verify
18
+
19
+ console = Console()
20
+
21
+
22
+ def replay(
23
+ run_dir: str | Path,
24
+ from_step: str | None = None,
25
+ mutations: dict | None = None,
26
+ output_dir: str | Path | None = None,
27
+ freeze_model_calls: bool = False,
28
+ ) -> dict:
29
+ """Replay a run from a specific step.
30
+
31
+ Args:
32
+ run_dir: Path to the original run directory (containing run.json).
33
+ from_step: Step ID to replay from. If None, replays the whole run.
34
+ mutations: Optional dict of input mutations.
35
+ output_dir: Where to save the replay trace. Defaults to runs/replay-<timestamp>/.
36
+ freeze_model_calls: If True, reuse the original model responses (bit-exact).
37
+
38
+ Returns:
39
+ The replay trace as a dict.
40
+ """
41
+ run_dir = Path(run_dir)
42
+ trace_path = run_dir / "run.json" if (run_dir / "run.json").exists() else run_dir
43
+ original = load_trace(trace_path)
44
+
45
+ if from_step is None:
46
+ from_step = original["steps"][0]["id"] if original.get("steps") else "s1"
47
+
48
+ console.print(f"[bold]▶ replay[/bold] {original.get('loopfile', '?')} from step [cyan]{from_step}[/cyan]")
49
+
50
+ if freeze_model_calls:
51
+ console.print("[dim] --freeze-model-calls: using cached model responses[/dim]")
52
+
53
+ if mutations:
54
+ console.print("[dim] input mutations:[/dim]")
55
+ for k, v in mutations.items():
56
+ console.print(f"[dim] {k}: {v}[/dim]")
57
+
58
+ # In V1 mock mode: re-execute from the named step
59
+ # A real implementation would restore state and resume; the mock just re-runs.
60
+ replay_steps = []
61
+ found_step = False
62
+ for s in original.get("steps", []):
63
+ if s["id"] == from_step:
64
+ found_step = True
65
+ if found_step:
66
+ replay_steps.append(s)
67
+
68
+ if not found_step:
69
+ console.print(f"[red]Step {from_step} not found in trace[/red]")
70
+ return {}
71
+
72
+ # Create replay trace
73
+ replay_trace = new_trace(
74
+ original.get("loopfile", "replay"),
75
+ json.dumps(original, default=str),
76
+ engine_type="infini-reference-replay",
77
+ )
78
+ replay_trace.replay_of = str(trace_path)
79
+ replay_trace.replay_from_step = from_step
80
+
81
+ console.print(f"[dim] re-executing {len(replay_steps)} step(s)[/dim]")
82
+ for s in replay_steps:
83
+ # In freeze mode, copy the original step's result
84
+ if freeze_model_calls:
85
+ from .trace import StepTrace
86
+ replay_trace.steps.append(StepTrace(
87
+ id=s["id"], name=s["name"], status=s.get("status", "ok"),
88
+ started_at=s.get("started_at", ""), ended_at=s.get("ended_at", ""),
89
+ cost=s.get("cost", {}),
90
+ artifacts=s.get("artifacts", []),
91
+ agent=s.get("agent", "builder"),
92
+ action=s.get("action", ""),
93
+ retry_attempt=s.get("retry_attempt"),
94
+ ))
95
+ replay_trace.budget["spent_dollars"] += s.get("cost", {}).get("dollars", 0)
96
+ replay_trace.budget["spent_minutes"] += s.get("cost", {}).get("minutes", 0)
97
+ else:
98
+ # Re-execute (mock)
99
+ result = mock_execute(
100
+ step_id=s["id"], step_name=s["name"], action=s.get("action", ""),
101
+ agent_role="builder", model_tier="sonnet",
102
+ produces=s.get("artifacts", []),
103
+ loopfile_name=original.get("loopfile", "replay"),
104
+ iteration=original.get("iterations", 1),
105
+ )
106
+ add_step(
107
+ replay_trace, s["id"], s["name"],
108
+ agent=s.get("agent", "builder"), action=s.get("action", ""),
109
+ artifacts=result.artifacts,
110
+ cost_dollars=result.cost_dollars, cost_minutes=result.cost_minutes,
111
+ tokens_in=result.tokens_in, tokens_out=result.tokens_out,
112
+ )
113
+
114
+ status_icon = "✓" if s.get("status") == "ok" else "⚠"
115
+ console.print(f" {status_icon} {s['id']} {s['name']}")
116
+
117
+ # Copy verifications
118
+ for v in original.get("verifications", []):
119
+ from .trace import CheckResult
120
+ replay_trace.verifications.append(CheckResult(
121
+ check=v.get("check", ""), status=v.get("status", "pass"),
122
+ confidence=v.get("confidence"), detail=v.get("detail"),
123
+ ))
124
+
125
+ finalize_trace(replay_trace, original.get("outcome", "verified"))
126
+ replay_trace.iterations = 1
127
+
128
+ # Save
129
+ if output_dir is None:
130
+ output_dir = f"runs/replay-{from_step}"
131
+ out_path = save_trace(replay_trace, Path(output_dir) / "run.json")
132
+ console.print(f"\n[green]✓ replay complete[/green]")
133
+ console.print(f"[dim] original: {trace_path}[/dim]")
134
+ console.print(f"[dim] replay: {out_path}[/dim]")
135
+ console.print(f"[dim] diff: infini diff {trace_path} {out_path}[/dim]")
136
+
137
+ return replay_trace
infini/schema.json ADDED
@@ -0,0 +1,181 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://infini.dev/spec/loopfile-v1.schema.json",
4
+ "title": "Loopfile v1.0",
5
+ "description": "JSON Schema for Loopfile v1.0. Used by `infini validate`. INFINI runs Loopfiles.",
6
+ "type": "object",
7
+ "required": [
8
+ "LOOPFILE",
9
+ "name",
10
+ "version",
11
+ "OBJECTIVE",
12
+ "AGENTS",
13
+ "STEPS",
14
+ "VERIFY",
15
+ "BUDGET",
16
+ "STOP_WHEN"
17
+ ],
18
+ "additionalProperties": false,
19
+ "properties": {
20
+ "LOOPFILE": {
21
+ "type": "string",
22
+ "const": "1.0",
23
+ "description": "Spec version. Must be exactly '1.0' for v1.0 Loopfiles."
24
+ },
25
+ "name": {
26
+ "type": "string",
27
+ "pattern": "^[a-z0-9][a-z0-9-]{0,62}$",
28
+ "description": "Slug identifier. Lowercase, digits, hyphens. Unique within a registry namespace."
29
+ },
30
+ "version": {
31
+ "type": "string",
32
+ "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$",
33
+ "description": "Semver version of this Loopfile, independent of spec version."
34
+ },
35
+ "description": {
36
+ "type": "string",
37
+ "maxLength": 280
38
+ },
39
+ "OBJECTIVE": {
40
+ "type": "string",
41
+ "minLength": 1,
42
+ "maxLength": 1000,
43
+ "description": "Single sentence the loop is trying to satisfy."
44
+ },
45
+ "AGENTS": {
46
+ "type": "array",
47
+ "minItems": 1,
48
+ "items": { "$ref": "#/$defs/agent" }
49
+ },
50
+ "STEPS": {
51
+ "type": "array",
52
+ "minItems": 1,
53
+ "items": { "$ref": "#/$defs/step" }
54
+ },
55
+ "VERIFY": { "$ref": "#/$defs/verify" },
56
+ "BUDGET": { "$ref": "#/$defs/budget" },
57
+ "STOP_WHEN": {
58
+ "type": "array",
59
+ "minItems": 1,
60
+ "items": { "type": "string", "minLength": 1 }
61
+ },
62
+ "LESSONS": { "$ref": "#/$defs/lessons" },
63
+ "STATE": { "$ref": "#/$defs/state" },
64
+ "ENGINE": { "$ref": "#/$defs/engine" }
65
+ },
66
+
67
+ "$defs": {
68
+ "agent": {
69
+ "type": "object",
70
+ "required": ["name", "role", "model_tier"],
71
+ "additionalProperties": false,
72
+ "properties": {
73
+ "name": { "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" },
74
+ "role": {
75
+ "type": "string",
76
+ "enum": ["builder", "verifier", "critic", "researcher", "planner"]
77
+ },
78
+ "model_tier": {
79
+ "type": "string",
80
+ "description": "Engine-resolved model tier. Common values: haiku, sonnet, opus, gpt-4o, but engines may define their own."
81
+ },
82
+ "tools": {
83
+ "type": "array",
84
+ "items": { "type": "string" }
85
+ }
86
+ }
87
+ },
88
+
89
+ "step": {
90
+ "type": "object",
91
+ "required": ["id", "name", "action", "uses"],
92
+ "additionalProperties": false,
93
+ "properties": {
94
+ "id": { "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" },
95
+ "name": { "type": "string" },
96
+ "action": { "type": "string" },
97
+ "uses": { "type": "string" },
98
+ "produces": { "type": "array", "items": { "type": "string" } },
99
+ "depends_on": { "type": "array", "items": { "type": "string" } },
100
+ "retry": { "$ref": "#/$defs/retry" }
101
+ }
102
+ },
103
+
104
+ "retry": {
105
+ "type": "object",
106
+ "required": ["max", "backoff"],
107
+ "additionalProperties": false,
108
+ "properties": {
109
+ "max": { "type": "integer", "minimum": 0, "maximum": 10 },
110
+ "backoff": { "type": "string", "enum": ["constant", "linear", "exponential"] }
111
+ }
112
+ },
113
+
114
+ "verify": {
115
+ "type": "object",
116
+ "required": ["syntactic", "semantic", "confidence_threshold"],
117
+ "additionalProperties": false,
118
+ "properties": {
119
+ "syntactic": {
120
+ "type": "array",
121
+ "items": { "type": "string" },
122
+ "description": "Deterministic checks (exit code, schema, linter)."
123
+ },
124
+ "semantic": {
125
+ "type": "array",
126
+ "items": { "type": "string" },
127
+ "description": "Model-judged checks producing a 0-100 confidence."
128
+ },
129
+ "confidence_threshold": {
130
+ "type": "integer",
131
+ "minimum": 0,
132
+ "maximum": 100
133
+ }
134
+ }
135
+ },
136
+
137
+ "budget": {
138
+ "type": "object",
139
+ "required": ["dollars", "minutes"],
140
+ "additionalProperties": false,
141
+ "properties": {
142
+ "dollars": { "type": "number", "exclusiveMinimum": 0 },
143
+ "minutes": { "type": "number", "exclusiveMinimum": 0 },
144
+ "tokens": { "type": "integer", "minimum": 1 }
145
+ }
146
+ },
147
+
148
+ "lessons": {
149
+ "type": "object",
150
+ "required": ["path", "append"],
151
+ "additionalProperties": false,
152
+ "properties": {
153
+ "path": { "type": "string" },
154
+ "append": { "type": "boolean" }
155
+ }
156
+ },
157
+
158
+ "state": {
159
+ "type": "object",
160
+ "required": ["path", "resume"],
161
+ "additionalProperties": false,
162
+ "properties": {
163
+ "path": { "type": "string" },
164
+ "resume": { "type": "boolean" }
165
+ }
166
+ },
167
+
168
+ "engine": {
169
+ "type": "object",
170
+ "required": ["type", "adapter"],
171
+ "additionalProperties": true,
172
+ "properties": {
173
+ "type": { "type": "string" },
174
+ "adapter": { "type": "string" },
175
+ "governance": { "type": "object" },
176
+ "tools": { "type": "array", "items": { "type": "string" } },
177
+ "delegates": { "type": "object" }
178
+ }
179
+ }
180
+ }
181
+ }
infini/trace.py ADDED
@@ -0,0 +1,178 @@
1
+ """Trace emission and loading.
2
+
3
+ A trace is the `run.json` file every execution produces. It's the
4
+ portable record of what happened — the Observatory reads it, `infini
5
+ replay` reads it, `infini diff` reads it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import time
12
+ from dataclasses import dataclass, field, asdict
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ def _now_iso() -> str:
19
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
20
+
21
+
22
+ def _sha256(data: bytes) -> str:
23
+ return "sha256:" + hashlib.sha256(data).hexdigest()
24
+
25
+
26
+ @dataclass
27
+ class StepTrace:
28
+ id: str
29
+ name: str
30
+ status: str # ok | failed | retried | skipped
31
+ started_at: str
32
+ ended_at: str
33
+ cost: dict # {dollars, minutes, tokens: {input, output, total}}
34
+ artifacts: list[str]
35
+ agent: str
36
+ action: str
37
+ retry_attempt: int | None = None
38
+ extensions: dict = field(default_factory=dict)
39
+
40
+
41
+ @dataclass
42
+ class CheckResult:
43
+ check: str
44
+ status: str # pass | fail
45
+ confidence: float | None = None
46
+ detail: str | None = None
47
+
48
+
49
+ @dataclass
50
+ class Trace:
51
+ loopfile: str
52
+ loopfile_hash: str
53
+ engine: dict
54
+ started_at: str
55
+ ended_at: str | None
56
+ iterations: int
57
+ steps: list[StepTrace]
58
+ verifications: list[CheckResult]
59
+ budget: dict
60
+ outcome: str # verified | unverified | budget_exceeded | escalated | error
61
+ lessons: list[str]
62
+ provenance: dict
63
+ extensions: dict = field(default_factory=dict)
64
+ replay_of: str | None = None
65
+ replay_from_step: str | None = None
66
+
67
+ def to_dict(self) -> dict:
68
+ return {
69
+ "loopfile": self.loopfile,
70
+ "loopfile_hash": self.loopfile_hash,
71
+ "engine": self.engine,
72
+ "started_at": self.started_at,
73
+ "ended_at": self.ended_at,
74
+ "iterations": self.iterations,
75
+ "steps": [asdict(s) for s in self.steps],
76
+ "verifications": [asdict(v) for v in self.verifications],
77
+ "budget": self.budget,
78
+ "outcome": self.outcome,
79
+ "lessons": self.lessons,
80
+ "provenance": self.provenance,
81
+ "extensions": self.extensions,
82
+ "replay_of": self.replay_of,
83
+ "replay_from_step": self.replay_from_step,
84
+ }
85
+
86
+ def to_json(self, indent: int = 2) -> str:
87
+ return json.dumps(self.to_dict(), indent=indent, default=str)
88
+
89
+
90
+ def new_trace(loopfile_name: str, loopfile_yaml: str, engine_type: str = "infini-reference") -> Trace:
91
+ """Create a fresh Trace at the start of a run."""
92
+ return Trace(
93
+ loopfile=loopfile_name,
94
+ loopfile_hash=_sha256(loopfile_yaml.encode()),
95
+ engine={"type": engine_type, "version": "1.0.0"},
96
+ started_at=_now_iso(),
97
+ ended_at=None,
98
+ iterations=0,
99
+ steps=[],
100
+ verifications=[],
101
+ budget={"spent_dollars": 0.0, "spent_minutes": 0.0},
102
+ outcome="running",
103
+ lessons=[],
104
+ provenance={
105
+ "engine_signature": "ed25519:placeholder",
106
+ "artifact_hashes": {},
107
+ },
108
+ )
109
+
110
+
111
+ def add_step(
112
+ trace: Trace,
113
+ step_id: str,
114
+ step_name: str,
115
+ agent: str,
116
+ action: str,
117
+ artifacts: list[str],
118
+ cost_dollars: float = 0.0,
119
+ cost_minutes: float = 0.0,
120
+ tokens_in: int = 0,
121
+ tokens_out: int = 0,
122
+ status: str = "ok",
123
+ retry_attempt: int | None = None,
124
+ ) -> None:
125
+ """Append a step to the trace."""
126
+ started = _now_iso()
127
+ time.sleep(0.001) # ensure ended > started
128
+ ended = _now_iso()
129
+ trace.steps.append(
130
+ StepTrace(
131
+ id=step_id, name=step_name, status=status,
132
+ started_at=started, ended_at=ended,
133
+ cost={
134
+ "dollars": round(cost_dollars, 4),
135
+ "minutes": round(cost_minutes, 2),
136
+ "tokens": {"input": tokens_in, "output": tokens_out, "total": tokens_in + tokens_out},
137
+ },
138
+ artifacts=artifacts, agent=agent, action=action,
139
+ retry_attempt=retry_attempt,
140
+ )
141
+ )
142
+ trace.budget["spent_dollars"] = round(trace.budget["spent_dollars"] + cost_dollars, 4)
143
+ trace.budget["spent_minutes"] = round(trace.budget["spent_minutes"] + cost_minutes, 2)
144
+
145
+
146
+ def finalize_trace(trace: Trace, outcome: str, lessons: list[str] | None = None) -> None:
147
+ """Mark the trace as complete."""
148
+ trace.ended_at = _now_iso()
149
+ trace.outcome = outcome
150
+ if lessons:
151
+ trace.lessons = lessons
152
+
153
+
154
+ def add_verification(trace: Trace, check: str, passed: bool, confidence: float | None = None, detail: str | None = None) -> None:
155
+ trace.verifications.append(
156
+ CheckResult(
157
+ check=check,
158
+ status="pass" if passed else "fail",
159
+ confidence=confidence,
160
+ detail=detail,
161
+ )
162
+ )
163
+
164
+
165
+ def save_trace(trace: Trace, path: str | Path) -> Path:
166
+ """Save a trace to a file (run.json or run.trace)."""
167
+ path = Path(path)
168
+ path.parent.mkdir(parents=True, exist_ok=True)
169
+ path.write_text(trace.to_json())
170
+ return path
171
+
172
+
173
+ def load_trace(path: str | Path) -> dict:
174
+ """Load a trace from a file. Returns the raw dict."""
175
+ path = Path(path)
176
+ if not path.exists():
177
+ raise FileNotFoundError(f"Trace not found: {path}")
178
+ return json.loads(path.read_text())
infini/ui.py ADDED
@@ -0,0 +1,70 @@
1
+ """Observatory UI launcher — `infini ui`.
2
+
3
+ Launches the local Observatory web app. In V1, this serves the static
4
+ HTML mockups from assets/ and lets you drop a trace file to visualize it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import webbrowser
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+
14
+ console = Console()
15
+
16
+ # Path to the Observatory UI (Next.js app in observatory-ui/)
17
+ _UI_DIR = Path(__file__).parent.parent.parent.parent / "observatory-ui"
18
+
19
+
20
+ def launch_ui(trace_path: str | None = None, port: int = 3000) -> None:
21
+ """Launch the Observatory UI.
22
+
23
+ In V1, if the Next.js app is built, serve it. Otherwise, print
24
+ instructions for running it manually.
25
+ """
26
+ console.print(f"[bold cyan]╔══════════════════════════════════════════════════════╗[/bold cyan]")
27
+ console.print(f"[bold cyan]║[/bold cyan] [bold]INFINI Loop Observatory[/bold] [bold cyan]║[/bold cyan]")
28
+ console.print(f"[bold cyan]╚══════════════════════════════════════════════════════╝[/bold cyan]")
29
+ console.print()
30
+
31
+ if _UI_DIR.exists() and (_UI_DIR / "package.json").exists():
32
+ # Next.js app exists — try to launch it
33
+ console.print(f"[green]▶ Observatory UI found at {_UI_DIR}[/green]")
34
+ console.print(f"[dim] Starting Next.js dev server on port {port}...[/dim]")
35
+ console.print()
36
+ console.print(f" [bold]Open:[/bold] [link=http://localhost:{port}]http://localhost:{port}[/link]")
37
+ if trace_path:
38
+ console.print(f" [bold]Trace:[/bold] {trace_path}")
39
+ console.print()
40
+ console.print(f"[dim] Press Ctrl+C to stop.[/dim]")
41
+
42
+ import subprocess
43
+ try:
44
+ env = {"PORT": str(port), "PATH": __import__("os").environ.get("PATH", "")}
45
+ import os
46
+ env = {**os.environ, "PORT": str(port)}
47
+ if trace_path:
48
+ env["INFINI_TRACE"] = str(Path(trace_path).resolve())
49
+ subprocess.run(
50
+ ["npm", "run", "dev", "--", "--port", str(port)],
51
+ cwd=str(_UI_DIR),
52
+ env=env,
53
+ )
54
+ except KeyboardInterrupt:
55
+ console.print("\n[yellow]Observatory stopped.[/yellow]")
56
+ except FileNotFoundError:
57
+ console.print("[yellow]npm not found. Install Node.js to run the Observatory UI.[/yellow]")
58
+ console.print(f"[dim] Or open the mockup directly: file://{_UI_DIR}/../assets/observatory.html[/dim]")
59
+ else:
60
+ # No Next.js app — serve the static mockup
61
+ console.print(f"[yellow]▶ Observatory UI not built. Showing static mockup.[/yellow]")
62
+ console.print()
63
+ mockup = Path(__file__).parent.parent.parent.parent / "assets" / "observatory.html"
64
+ if mockup.exists():
65
+ console.print(f" [bold]Open:[/bold] [link=file://{mockup}]file://{mockup}[/link]")
66
+ webbrowser.open(f"file://{mockup}")
67
+ else:
68
+ console.print(f" [red]Mockup not found at {mockup}[/red]")
69
+ console.print()
70
+ console.print(f"[dim] To build the full UI: cd observatory-ui && npm install && npm run dev[/dim]")