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 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)