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/__init__.py +6 -0
- infini/__main__.py +4 -0
- infini/adapters.py +68 -0
- infini/cli.py +287 -0
- infini/diff.py +167 -0
- infini/engine.py +191 -0
- infini/inspect.py +102 -0
- infini/mock.py +108 -0
- infini/parse.py +207 -0
- infini/replay.py +137 -0
- infini/schema.json +181 -0
- infini/trace.py +178 -0
- infini/ui.py +70 -0
- infini_cli-0.1.0.dist-info/METADATA +159 -0
- infini_cli-0.1.0.dist-info/RECORD +18 -0
- infini_cli-0.1.0.dist-info/WHEEL +5 -0
- infini_cli-0.1.0.dist-info/entry_points.txt +2 -0
- infini_cli-0.1.0.dist-info/top_level.txt +1 -0
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]")
|