PaperFarm 0.2.0b1__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.
- paperfarm/__init__.py +0 -0
- paperfarm/agent.py +255 -0
- paperfarm/cli.py +215 -0
- paperfarm/parallel.py +458 -0
- paperfarm/skill_runner.py +278 -0
- paperfarm/skills/critic.md +206 -0
- paperfarm/skills/experiment.md +126 -0
- paperfarm/skills/manager.md +239 -0
- paperfarm/skills/protocol.yaml +17 -0
- paperfarm/skills/scout.md +101 -0
- paperfarm/skills/scripts/record.py +81 -0
- paperfarm/skills/scripts/rollback.sh +6 -0
- paperfarm/state.py +393 -0
- paperfarm/tui/__init__.py +0 -0
- paperfarm/tui/app.py +166 -0
- paperfarm/tui/styles.css +51 -0
- paperfarm/tui/widgets.py +188 -0
- paperfarm-0.2.0b1.dist-info/METADATA +385 -0
- paperfarm-0.2.0b1.dist-info/RECORD +22 -0
- paperfarm-0.2.0b1.dist-info/WHEEL +4 -0
- paperfarm-0.2.0b1.dist-info/entry_points.txt +2 -0
- paperfarm-0.2.0b1.dist-info/licenses/LICENSE +21 -0
paperfarm/__init__.py
ADDED
|
File without changes
|
paperfarm/agent.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Agent adapter pattern for launching AI agent CLI subprocesses.
|
|
2
|
+
|
|
3
|
+
Provides a thin wrapper around agent CLIs (Claude Code, Codex, Aider, Gemini)
|
|
4
|
+
with a common interface for running them against a research workspace.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
agent_adapter = create_agent("claude-code")
|
|
9
|
+
agent = Agent(agent_adapter)
|
|
10
|
+
rc = agent.run(workdir, program_content="...", program_file="program.md")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import abc
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import subprocess
|
|
20
|
+
import threading
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Base adapter
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentAdapter(abc.ABC):
|
|
30
|
+
"""Abstract base for agent CLI adapters."""
|
|
31
|
+
|
|
32
|
+
name: str = ""
|
|
33
|
+
command: str = ""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
|
36
|
+
self._config: dict[str, Any] = config or {}
|
|
37
|
+
self._proc: subprocess.Popen[str] | None = None
|
|
38
|
+
self._lock = threading.Lock()
|
|
39
|
+
|
|
40
|
+
def check_installed(self) -> bool:
|
|
41
|
+
"""Return True if the agent CLI binary is on PATH."""
|
|
42
|
+
return shutil.which(self.command) is not None
|
|
43
|
+
|
|
44
|
+
@abc.abstractmethod
|
|
45
|
+
def run(
|
|
46
|
+
self,
|
|
47
|
+
workdir: Path,
|
|
48
|
+
*,
|
|
49
|
+
on_output: Callable[[str], None] | None = None,
|
|
50
|
+
program_file: str = "program.md",
|
|
51
|
+
env: dict[str, str] | None = None,
|
|
52
|
+
) -> int:
|
|
53
|
+
"""Run the agent in *workdir*. Returns exit code."""
|
|
54
|
+
|
|
55
|
+
def terminate(self) -> None:
|
|
56
|
+
"""Send SIGTERM to the running agent subprocess (if any)."""
|
|
57
|
+
with self._lock:
|
|
58
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
59
|
+
self._proc.send_signal(signal.SIGTERM)
|
|
60
|
+
|
|
61
|
+
# -- common helper -------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _run_process(
|
|
64
|
+
self,
|
|
65
|
+
cmd: list[str],
|
|
66
|
+
workdir: Path,
|
|
67
|
+
*,
|
|
68
|
+
on_output: Callable[[str], None] | None = None,
|
|
69
|
+
stdin_text: str | None = None,
|
|
70
|
+
env: dict[str, str] | None = None,
|
|
71
|
+
) -> int:
|
|
72
|
+
"""Spawn *cmd* in *workdir*, stream stdout/stderr, return exit code."""
|
|
73
|
+
merged_env = {**os.environ, **(env or {})}
|
|
74
|
+
# Remove nesting-detection vars so agent CLIs don't refuse to start
|
|
75
|
+
for _var in ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"):
|
|
76
|
+
merged_env.pop(_var, None)
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._proc = subprocess.Popen(
|
|
79
|
+
cmd,
|
|
80
|
+
cwd=str(workdir),
|
|
81
|
+
stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
|
|
82
|
+
stdout=subprocess.PIPE,
|
|
83
|
+
stderr=subprocess.STDOUT,
|
|
84
|
+
text=True,
|
|
85
|
+
env=merged_env,
|
|
86
|
+
)
|
|
87
|
+
proc = self._proc
|
|
88
|
+
assert proc is not None # for type-checker
|
|
89
|
+
|
|
90
|
+
# Feed stdin if needed, then close.
|
|
91
|
+
if stdin_text is not None:
|
|
92
|
+
assert proc.stdin is not None
|
|
93
|
+
proc.stdin.write(stdin_text)
|
|
94
|
+
proc.stdin.close()
|
|
95
|
+
|
|
96
|
+
# Stream combined output line-by-line.
|
|
97
|
+
assert proc.stdout is not None
|
|
98
|
+
for line in proc.stdout:
|
|
99
|
+
if on_output is not None:
|
|
100
|
+
on_output(line)
|
|
101
|
+
|
|
102
|
+
proc.wait()
|
|
103
|
+
with self._lock:
|
|
104
|
+
self._proc = None
|
|
105
|
+
return proc.returncode
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Concrete adapters
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ClaudeCodeAdapter(AgentAdapter):
|
|
114
|
+
"""Adapter for the ``claude`` CLI (Claude Code)."""
|
|
115
|
+
|
|
116
|
+
name = "claude-code"
|
|
117
|
+
command = "claude"
|
|
118
|
+
|
|
119
|
+
def run(
|
|
120
|
+
self,
|
|
121
|
+
workdir: Path,
|
|
122
|
+
*,
|
|
123
|
+
on_output: Callable[[str], None] | None = None,
|
|
124
|
+
program_file: str = "program.md",
|
|
125
|
+
env: dict[str, str] | None = None,
|
|
126
|
+
) -> int:
|
|
127
|
+
prompt = (workdir / ".research" / program_file).read_text(encoding="utf-8")
|
|
128
|
+
cmd = [self.command, "-p", prompt, "--verbose"]
|
|
129
|
+
return self._run_process(cmd, workdir, on_output=on_output, env=env)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class CodexAdapter(AgentAdapter):
|
|
133
|
+
"""Adapter for the ``codex`` CLI (OpenAI Codex)."""
|
|
134
|
+
|
|
135
|
+
name = "codex"
|
|
136
|
+
command = "codex"
|
|
137
|
+
|
|
138
|
+
def run(
|
|
139
|
+
self,
|
|
140
|
+
workdir: Path,
|
|
141
|
+
*,
|
|
142
|
+
on_output: Callable[[str], None] | None = None,
|
|
143
|
+
program_file: str = "program.md",
|
|
144
|
+
env: dict[str, str] | None = None,
|
|
145
|
+
) -> int:
|
|
146
|
+
prompt = (workdir / ".research" / program_file).read_text(encoding="utf-8")
|
|
147
|
+
cmd = [
|
|
148
|
+
self.command, "exec",
|
|
149
|
+
"-s", self._config.get("sandbox", "workspace-write"),
|
|
150
|
+
"--full-auto",
|
|
151
|
+
prompt,
|
|
152
|
+
]
|
|
153
|
+
return self._run_process(cmd, workdir, on_output=on_output, env=env)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AiderAdapter(AgentAdapter):
|
|
157
|
+
"""Adapter for the ``aider`` CLI."""
|
|
158
|
+
|
|
159
|
+
name = "aider"
|
|
160
|
+
command = "aider"
|
|
161
|
+
|
|
162
|
+
def run(
|
|
163
|
+
self,
|
|
164
|
+
workdir: Path,
|
|
165
|
+
*,
|
|
166
|
+
on_output: Callable[[str], None] | None = None,
|
|
167
|
+
program_file: str = "program.md",
|
|
168
|
+
env: dict[str, str] | None = None,
|
|
169
|
+
) -> int:
|
|
170
|
+
msg_file = workdir / ".research" / program_file
|
|
171
|
+
cmd = [self.command, "--yes-always", "--no-git", "--message-file", str(msg_file)]
|
|
172
|
+
return self._run_process(cmd, workdir, on_output=on_output, env=env)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class GeminiAdapter(AgentAdapter):
|
|
176
|
+
"""Adapter for the ``gemini`` CLI."""
|
|
177
|
+
|
|
178
|
+
name = "gemini"
|
|
179
|
+
command = "gemini"
|
|
180
|
+
|
|
181
|
+
def run(
|
|
182
|
+
self,
|
|
183
|
+
workdir: Path,
|
|
184
|
+
*,
|
|
185
|
+
on_output: Callable[[str], None] | None = None,
|
|
186
|
+
program_file: str = "program.md",
|
|
187
|
+
env: dict[str, str] | None = None,
|
|
188
|
+
) -> int:
|
|
189
|
+
prompt = (workdir / ".research" / program_file).read_text(encoding="utf-8")
|
|
190
|
+
cmd = [self.command, "-p", prompt]
|
|
191
|
+
return self._run_process(cmd, workdir, on_output=on_output, env=env)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Factory
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
_ADAPTERS: dict[str, type[AgentAdapter]] = {
|
|
199
|
+
"claude-code": ClaudeCodeAdapter,
|
|
200
|
+
"codex": CodexAdapter,
|
|
201
|
+
"aider": AiderAdapter,
|
|
202
|
+
"gemini": GeminiAdapter,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def create_agent(name: str, config: dict[str, Any] | None = None) -> AgentAdapter:
|
|
207
|
+
"""Instantiate an agent adapter by *name*.
|
|
208
|
+
|
|
209
|
+
Raises ``ValueError`` if *name* is not in the adapter registry.
|
|
210
|
+
"""
|
|
211
|
+
cls = _ADAPTERS.get(name)
|
|
212
|
+
if cls is None:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"Unknown agent {name!r}. Available: {sorted(_ADAPTERS)}"
|
|
215
|
+
)
|
|
216
|
+
return cls(config)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# High-level Agent wrapper
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Agent:
|
|
225
|
+
"""High-level wrapper that pairs an adapter with program-file management.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
adapter:
|
|
230
|
+
A concrete ``AgentAdapter`` instance.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(self, adapter: AgentAdapter) -> None:
|
|
234
|
+
self.adapter = adapter
|
|
235
|
+
|
|
236
|
+
def run(
|
|
237
|
+
self,
|
|
238
|
+
workdir: Path,
|
|
239
|
+
*,
|
|
240
|
+
program_content: str = "",
|
|
241
|
+
program_file: str = "program.md",
|
|
242
|
+
env: dict[str, str] | None = None,
|
|
243
|
+
on_output: Callable[[str], None] | None = None,
|
|
244
|
+
) -> int:
|
|
245
|
+
"""Write *program_content* to ``.research/<program_file>`` then run the agent."""
|
|
246
|
+
dest = workdir / ".research" / program_file
|
|
247
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
dest.write_text(program_content, encoding="utf-8")
|
|
249
|
+
return self.adapter.run(
|
|
250
|
+
workdir, on_output=on_output, program_file=program_file, env=env,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def terminate(self) -> None:
|
|
254
|
+
"""Terminate the running agent subprocess."""
|
|
255
|
+
self.adapter.terminate()
|
paperfarm/cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""CLI entry-points for PaperFarm.
|
|
2
|
+
|
|
3
|
+
Provides three commands via :pypi:`typer`:
|
|
4
|
+
|
|
5
|
+
* ``run`` — launch a research session (serial, parallel, or TUI)
|
|
6
|
+
* ``status`` — display a snapshot of the current session state
|
|
7
|
+
* ``results`` — show the experiment results ledger
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="paperfarm",
|
|
21
|
+
help="Sow ideas, run experiments, harvest better code.",
|
|
22
|
+
)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Helpers
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _auto_tag() -> str:
|
|
32
|
+
"""Generate a session tag like ``r-20260318-153042``."""
|
|
33
|
+
return "r-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_research_dir(repo: Path) -> Path:
|
|
37
|
+
"""Return the ``.research`` directory inside *repo*."""
|
|
38
|
+
return repo / ".research"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _deploy_scripts(research_dir: Path) -> None:
|
|
42
|
+
"""Copy bundled helper scripts into .research/scripts/."""
|
|
43
|
+
scripts_src = Path(__file__).parent / "skills" / "scripts"
|
|
44
|
+
scripts_dst = research_dir / "scripts"
|
|
45
|
+
scripts_dst.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
for name in ("record.py", "rollback.sh"):
|
|
47
|
+
src = scripts_src / name
|
|
48
|
+
dst = scripts_dst / name
|
|
49
|
+
if src.exists():
|
|
50
|
+
dst.write_bytes(src.read_bytes())
|
|
51
|
+
if name.endswith(".sh"):
|
|
52
|
+
dst.chmod(0o755)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# run
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def run(
|
|
62
|
+
repo: Path = typer.Argument(..., help="Path to target repo"),
|
|
63
|
+
goal: str = typer.Option("", help="Research goal"),
|
|
64
|
+
tag: str = typer.Option("", help="Session tag"),
|
|
65
|
+
workers: int = typer.Option(0, help="Max parallel workers (0=serial)"),
|
|
66
|
+
headless: bool = typer.Option(False, help="Run without TUI"),
|
|
67
|
+
agent_name: str = typer.Option("claude-code", help="Agent to use"),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Launch or resume a research session."""
|
|
70
|
+
# Validate repo
|
|
71
|
+
if not repo.is_dir():
|
|
72
|
+
console.print(f"[red]Error:[/red] repo path does not exist: {repo}")
|
|
73
|
+
raise typer.Exit(code=1)
|
|
74
|
+
|
|
75
|
+
# Create .research dir and deploy helper scripts
|
|
76
|
+
research_dir = _resolve_research_dir(repo)
|
|
77
|
+
research_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
_deploy_scripts(research_dir)
|
|
79
|
+
|
|
80
|
+
# Auto-generate tag if not provided
|
|
81
|
+
if not tag:
|
|
82
|
+
tag = _auto_tag()
|
|
83
|
+
|
|
84
|
+
# Lazy imports to keep module-load fast
|
|
85
|
+
from .agent import Agent, create_agent # noqa: E402
|
|
86
|
+
from .skill_runner import SkillRunner # noqa: E402
|
|
87
|
+
from .state import ResearchState # noqa: E402
|
|
88
|
+
|
|
89
|
+
state = ResearchState(research_dir)
|
|
90
|
+
adapter = create_agent(agent_name)
|
|
91
|
+
agent = Agent(adapter)
|
|
92
|
+
|
|
93
|
+
if headless:
|
|
94
|
+
# -- headless mode (serial or parallel) --
|
|
95
|
+
if workers > 0:
|
|
96
|
+
from .parallel import WorkerPool # noqa: E402
|
|
97
|
+
|
|
98
|
+
# Load experiment skill content for parallel workers
|
|
99
|
+
runner = SkillRunner(
|
|
100
|
+
repo, state, agent, goal=goal, tag=tag,
|
|
101
|
+
on_output=lambda line: console.print(line, end=""),
|
|
102
|
+
)
|
|
103
|
+
# First run bootstrap
|
|
104
|
+
rc = runner.run_bootstrap()
|
|
105
|
+
if rc != 0:
|
|
106
|
+
console.print(f"[red]Bootstrap failed (rc={rc})[/red]")
|
|
107
|
+
raise typer.Exit(code=rc)
|
|
108
|
+
|
|
109
|
+
# Then run parallel pool
|
|
110
|
+
pool = WorkerPool(
|
|
111
|
+
repo_path=repo,
|
|
112
|
+
state=state,
|
|
113
|
+
agent_factory=lambda: Agent(create_agent(agent_name)),
|
|
114
|
+
skill_content="",
|
|
115
|
+
max_workers=workers,
|
|
116
|
+
on_output=lambda line: console.print(line, end=""),
|
|
117
|
+
)
|
|
118
|
+
pool.run()
|
|
119
|
+
pool.wait()
|
|
120
|
+
else:
|
|
121
|
+
# Serial mode
|
|
122
|
+
runner = SkillRunner(
|
|
123
|
+
repo, state, agent, goal=goal, tag=tag,
|
|
124
|
+
on_output=lambda line: console.print(line, end=""),
|
|
125
|
+
)
|
|
126
|
+
rc = runner.run_serial()
|
|
127
|
+
if rc != 0:
|
|
128
|
+
console.print(f"[red]Session ended with rc={rc}[/red]")
|
|
129
|
+
raise typer.Exit(code=rc)
|
|
130
|
+
else:
|
|
131
|
+
# -- TUI mode --
|
|
132
|
+
from .tui.app import ResearchApp # noqa: E402
|
|
133
|
+
|
|
134
|
+
runner = SkillRunner(
|
|
135
|
+
repo, state, agent, goal=goal, tag=tag,
|
|
136
|
+
)
|
|
137
|
+
tui_app = ResearchApp(
|
|
138
|
+
repo_path=str(repo),
|
|
139
|
+
state=state,
|
|
140
|
+
runner=runner.run_serial,
|
|
141
|
+
)
|
|
142
|
+
tui_app.run()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# status
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command()
|
|
151
|
+
def status(
|
|
152
|
+
repo: Path = typer.Argument(..., help="Path to target repo"),
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Display a snapshot of the current research session state."""
|
|
155
|
+
research_dir = _resolve_research_dir(repo)
|
|
156
|
+
if not research_dir.is_dir():
|
|
157
|
+
console.print(f"[red]No .research directory found at {repo}[/red]")
|
|
158
|
+
raise typer.Exit(code=1)
|
|
159
|
+
|
|
160
|
+
from .state import ResearchState # noqa: E402
|
|
161
|
+
|
|
162
|
+
state = ResearchState(research_dir)
|
|
163
|
+
summary = state.summary()
|
|
164
|
+
|
|
165
|
+
table = Table(title="Research Status")
|
|
166
|
+
table.add_column("Field", style="cyan")
|
|
167
|
+
table.add_column("Value", style="green")
|
|
168
|
+
|
|
169
|
+
table.add_row("Phase", str(summary.get("phase", "idle")))
|
|
170
|
+
table.add_row("Round", str(summary.get("round", 0)))
|
|
171
|
+
table.add_row("Hypotheses", str(summary.get("hypotheses", 0)))
|
|
172
|
+
table.add_row("Experiments (total)", str(summary.get("experiments_total", 0)))
|
|
173
|
+
table.add_row("Experiments (done)", str(summary.get("experiments_done", 0)))
|
|
174
|
+
table.add_row("Experiments (running)", str(summary.get("experiments_running", 0)))
|
|
175
|
+
table.add_row("Results", str(summary.get("results_count", 0)))
|
|
176
|
+
table.add_row("Best value", str(summary.get("best_value", "—")))
|
|
177
|
+
table.add_row("Paused", str(summary.get("paused", False)))
|
|
178
|
+
|
|
179
|
+
console.print(table)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# results
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def results(
|
|
189
|
+
repo: Path = typer.Argument(..., help="Path to target repo"),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Display experiment results from the results ledger."""
|
|
192
|
+
research_dir = _resolve_research_dir(repo)
|
|
193
|
+
if not research_dir.is_dir():
|
|
194
|
+
console.print(f"[red]No .research directory found at {repo}[/red]")
|
|
195
|
+
raise typer.Exit(code=1)
|
|
196
|
+
|
|
197
|
+
from .state import ResearchState # noqa: E402
|
|
198
|
+
|
|
199
|
+
state = ResearchState(research_dir)
|
|
200
|
+
rows = state.load_results()
|
|
201
|
+
|
|
202
|
+
if not rows:
|
|
203
|
+
console.print("[dim]No results recorded yet.[/dim]")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
table = Table(title="Experiment Results")
|
|
207
|
+
# Use columns from the first row (or default fields)
|
|
208
|
+
columns = list(rows[0].keys()) if rows else []
|
|
209
|
+
for col in columns:
|
|
210
|
+
table.add_column(col, style="cyan" if col == "frontier_id" else "")
|
|
211
|
+
|
|
212
|
+
for row in rows:
|
|
213
|
+
table.add_row(*(row.get(c, "") for c in columns))
|
|
214
|
+
|
|
215
|
+
console.print(table)
|