diff --git a/docs/guide/installation.md b/docs/guide/installation.md index e35d555d..d0a493d5 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -27,9 +27,9 @@ checkout for those files. These docs track the latest `main`. The current PyPI release is `0.2.0`. The generic research `openai_compatible` backend, SkillOpt-Sleep handoff, Sleep support for non-Azure OpenAI-compatible endpoints, the Sleep - `--preferences` flag, and Cursor source/backend/plugin support landed after - that release and require a source install from `main` until the next - release. + `--preferences` flag, Cursor source/backend/plugin support, and Pi + source/backend support landed after that release and require a source + install from `main` until the next release. ### Source checkout @@ -63,6 +63,19 @@ Install extras for specific benchmarks or backends: Claude Code CLI separately. The SDK extra is only needed when selecting an SDK-backed Claude Code exec path. +=== "Pi coding-agent CLI (optional)" + + ```bash + npm install -g --ignore-scripts @earendil-works/pi-coding-agent + ``` + + Install and authenticate the [Pi coding-agent CLI](https://github.com/earendil-works/pi) + only when using SkillOpt-Sleep with `--backend pi`. Harvesting local Pi + transcripts with `--source pi` does not require the CLI or provider + authentication. By default, the source reads below + `~/.pi/agent/sessions`; `--pi-home` selects the parent directory that + contains `agent/sessions`. + === "Qwen (Local)" ```bash diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 98594e4a..ae234146 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -4,8 +4,9 @@ > include the generic research `openai_compatible` backend, Sleep handoff, > Sleep support for non-Azure OpenAI-compatible endpoints, the Sleep > `--preferences` flag, the research `cursor_exec` target harness, or Cursor -> source/backend/plugin support or VS Code Copilot transcript harvesting; use -> a source install from `main` for those features until the next release. +> source/backend/plugin support, Pi source/backend support, or VS Code Copilot +> transcript harvesting; use a source install from `main` for those features +> until the next release. ## Training @@ -128,12 +129,14 @@ Actions are `run`, `dry-run`, `status`, `adopt`, `harvest`, `schedule`, and |---|---| | `--project PATH` | Project used for transcript scope, targets, state, and staging (default: current directory) | | `--scope invoked\|all` | Harvest this project or all projects | -| `--source claude\|codex\|copilot\|cursor\|auto` | Transcript source; `auto` keeps Codex-then-Claude precedence and does not select Copilot or Cursor | -| `--backend mock\|claude\|codex\|copilot\|cursor\|handoff\|azure_openai` | Replay/optimizer backend | +| `--source claude\|codex\|copilot\|cursor\|pi\|auto` | Transcript source; `auto` keeps Codex-then-Claude precedence and does not select Copilot, Cursor, or Pi | +| `--backend mock\|claude\|codex\|copilot\|cursor\|pi\|handoff\|azure_openai` | Replay/optimizer backend | | `--model NAME` | Backend-specific model override | | `--cursor-home PATH` | Override `~/.cursor` for Cursor transcript harvesting | +| `--pi-home PATH` | Parent directory containing Pi's `agent/sessions` tree (default: `~/.pi`) | | `--vscode-workspace-storage PATH` | Override VS Code's `User/workspaceStorage` root for Copilot transcript harvesting | | `--cursor-path PATH` | Path to the installed Cursor Agent CLI | +| `--pi-path PATH` | Path to the installed Pi coding-agent CLI | | `--preferences TEXT` | House rules supplied to reflection | | `--lookback-hours N` | Initial transcript lookback; `0` scans all history | | `--max-sessions N` / `--max-tasks N` | Bound the harvested workload | @@ -143,6 +146,11 @@ Actions are `run`, `dry-run`, `status`, `adopt`, `harvest`, `schedule`, and | `--progress` / `--json` | Progress or machine-readable output | | `--auto-adopt` | Apply an accepted staged proposal automatically | +The `mock` and `handoff` backends make no network calls. A real backend sends +mining, replay, judging, and reflection prompts derived from harvested +transcripts and tasks to its selected provider. Review that provider's +data-retention and privacy policy before processing sensitive sessions. + ### VS Code GitHub Copilot Chat source `--source copilot` reads local VS Code GitHub Copilot Chat session logs from @@ -167,6 +175,44 @@ The managed `schedule` command does not persist `--source` or `"vscode_workspace_storage": "/absolute/path/to/workspaceStorage"` in `~/.skillopt-sleep/config.json`. +### Pi source and backend + +`--source pi` reads local session JSONL files below +`~/.pi/agent/sessions`; use `--pi-home PATH` to select the parent directory that +contains `agent/sessions`. This local source does not require the Pi CLI or +provider authentication. It retains user/assistant text, tool names, and lexical +feedback found in user text, while excluding thinking, tool arguments, tool +outputs, images, and unrelated metadata. The absolute project `cwd` from the +session header is retained for scope filtering and may appear in miner prompts +sent to a real backend and its provider. Known secret-shaped strings in retained +message text are redacted as defense in depth, not as a guarantee. Pi is an explicit source: `--source auto` +retains Codex-then-Claude precedence and does not select it. + +Transcript source and model backend are independent. `--backend pi` launches a +locally installed, authenticated Pi CLI and makes real provider calls for +mining, replay, judging, and reflection. Use `--pi-path PATH` to select its +executable and `--model NAME` to override its configured model: + +```bash +skillopt-sleep run --project "$(pwd)" \ + --source pi --backend pi --pi-path /absolute/path/to/pi \ + --model provider/model --max-sessions 5 --max-tasks 3 --progress +``` + +For these calls, SkillOpt disables Pi tools, skills, context files, extensions, +prompt templates, themes, and session writes. Pi authentication and model +configuration remain available. It also enables Pi's offline startup mode, so +configured npm/git packages are not installed or updated and model catalogs are +not refreshed; the selected model provider is still contacted for generation. +These controls should not be treated as permanent or complete isolation. The +provider selected in Pi receives the transcript-derived prompts. + +The managed `schedule` command preserves the backend but not `--source`, +`--pi-home`, `--pi-path`, or `--model`. Before scheduling Pi, put +`transcript_source`, `pi_home`, `pi_path`, and `model` in +`~/.skillopt-sleep/config.json`; use an absolute `pi_path` and verify +authentication for the scheduled account. + ### Cursor source and backend `--source cursor` reads local Cursor JSONL transcripts from diff --git a/docs/sleep/README.md b/docs/sleep/README.md index 8c4b0c80..f2cc4395 100644 --- a/docs/sleep/README.md +++ b/docs/sleep/README.md @@ -17,7 +17,7 @@ normal agent requests. One "night": ``` -harvest Claude Code / Codex / VS Code Copilot / Cursor transcripts → mine recurring tasks → replay via the configured backend (isolation varies by backend; mock/handoff make no network calls) +harvest Claude Code / Codex / VS Code Copilot / Cursor / Pi transcripts → mine recurring tasks → replay via the configured backend (isolation varies by backend; mock/handoff make no network calls) → consolidate (reflect → bounded edit → GATE on real held-out tasks) → stage proposal → (you) adopt ``` @@ -49,6 +49,22 @@ experience → long-term competence). > context, and account/model metadata. Known secret-shaped strings are > redacted, but this remains defense in depth rather than a guarantee. > +> The Pi source reads local sessions below `~/.pi/agent/sessions`, retaining +> user/assistant text, tool names, and lexical feedback found in user text. It +> excludes thinking, tool arguments, tool outputs, images, and unrelated +> metadata. The absolute project `cwd` from the session header is retained for +> scope filtering and may appear in miner prompts sent to a real backend and its +> provider. Known secret-shaped strings in retained message text are redacted +> only as defense in depth. +> The Pi backend uses the installed, authenticated Pi CLI to contact the user's +> selected model provider. Calls disable tools, skills, context files, extensions, prompt +> templates, themes, and session writes, while retaining Pi authentication and +> model configuration. Pi's offline startup mode also prevents configured +> npm/git package installation, package updates, and model-catalog refresh; it +> does not prevent the selected provider call. This is not a guarantee of +> permanent or complete isolation. Review the provider's retention and privacy +> policy before sending transcript-derived prompts from sensitive sessions. +> > By default, each stateful night also writes a local `evidence.jsonl` under > the project staging tree (beside the report when one is staged); dry-runs > write evidence under the configured Sleep state directory. The log contains @@ -73,9 +89,9 @@ skillopt-sleep schedule # install a nightly cron entry for this project > **Version note.** This page tracks `main`. PyPI 0.2.0 provides the base > commands above. Cursor source/backend/plugin support, VS Code Copilot -> transcript harvesting, Sleep handoff, non-Azure OpenAI-compatible endpoints, -> and `--preferences` landed later and require a source install from `main` -> until the next release. +> transcript harvesting, Pi source/backend support, Sleep handoff, non-Azure +> OpenAI-compatible endpoints, and `--preferences` landed later and require a +> source install from `main` until the next release. The per-agent integrations below still come from the repo; the CLI above is the standalone, pip-only way to run a cycle. Claude Code, Codex, Cursor, Copilot, and @@ -119,6 +135,41 @@ The managed scheduler does not preserve `--source` or `"vscode_workspace_storage": "/absolute/path/to/workspaceStorage"` in `~/.skillopt-sleep/config.json`. +### Pi + +Pi transcript harvesting and model execution are independent. Use `--source pi` +to read local session JSONL files below `~/.pi/agent/sessions`, or set +`--pi-home` to the parent directory that contains `agent/sessions` (the default +is `~/.pi`). The source alone does not require Pi CLI installation or provider +authentication. Pi is never selected implicitly: +`--source auto` retains Codex-then-Claude precedence. + +`--backend pi` uses a locally installed and authenticated Pi CLI for real +model-provider calls during mining, replay, judging, and reflection. Select its +executable with `--pi-path` and override its configured model with `--model`: + +```bash +skillopt-sleep run --project "$(pwd)" \ + --source pi --backend pi --pi-path /absolute/path/to/pi \ + --model provider/model --max-sessions 5 --max-tasks 3 --progress +``` + +These calls disable tools, skills, context files, extensions, prompt templates, +themes, and session writes, but still use the user's Pi authentication and model +configuration. They also enable Pi's offline startup mode to prevent configured +npm/git package installation, package updates, and model-catalog refresh; the +selected model provider is still contacted. Treat those controls as bounded +invocation setup, not permanent or complete isolation. A real Pi backend sends +transcript-derived prompts to the provider configured in Pi; inspect that +provider's retention and privacy policy first. The `mock` and `handoff` backends +make no network calls. + +The managed scheduler records the backend but does not preserve `--source`, +`--pi-home`, `--pi-path`, or `--model`. Before scheduling Pi, set +`transcript_source`, `pi_home`, `pi_path`, and `model` in +`~/.skillopt-sleep/config.json`. Use an absolute `pi_path` and verify the +scheduled account's Pi authentication. + ### Cursor Cursor transcript harvesting and model execution are independent. Use diff --git a/plugins/README.md b/plugins/README.md index 61d222c9..7391075d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -47,8 +47,9 @@ an importable `skillopt_sleep` module. Install with `uv tool install skillopt` o > **Version note.** This integration reference tracks `main`. PyPI 0.2.0 > supports the base Sleep CLI, while Cursor source/backend/plugin support, -> handoff, Sleep support for non-Azure OpenAI-compatible endpoints, and -> `--preferences` require a source checkout from `main` until the next release. +> Pi source/backend support, handoff, Sleep support for non-Azure +> OpenAI-compatible endpoints, and `--preferences` require a source checkout +> from `main` until the next release. ## One sleep cycle @@ -64,10 +65,10 @@ optimization. ## Data boundary -- Harvesting is local and read-only. The `mock` backend has no model-provider - data path and no API spend. -- A real backend sends truncated transcript excerpts and derived task content to - the provider selected for mining, replay, judging, and reflection. +- Harvesting is local and read-only. The `mock` and `handoff` backends make no + network calls; handoff writes prompts for separate, user-controlled completion. +- A real backend sends mining, replay, judging, and reflection prompts derived + from truncated transcript excerpts and tasks to the selected provider. - The Cursor source reads local user/assistant message text, explicit turn errors, and tool names from `~/.cursor/projects/*/agent-transcripts`; it does not retain tool arguments, tool outputs, or other record types. Known @@ -79,6 +80,19 @@ optimization. `tool_called` validation fail before Agent mode starts; use another backend for those tasks. Cursor and the model provider selected by Cursor can receive the resulting prompt content. +- The Pi backend sends prompts through the installed, authenticated Pi CLI to + the provider configured by the user. It disables tools, skills, context files, + extensions, prompt templates, themes, and session writes for these calls, but + retains the user's Pi authentication and model configuration. Pi's offline + startup mode prevents configured npm/git package installation, package + updates, and model-catalog refresh; it does not prevent the selected provider + call. These controls are not a guarantee of permanent or complete isolation. +- The Pi source retains user/assistant text, tool names, and lexical feedback + found in user text. It excludes thinking, tool arguments, tool outputs, images, + and unrelated metadata. The absolute project `cwd` from the session header is + retained for scope filtering and may appear in miner prompts sent to a real + backend and its provider. Known secret-shaped strings in retained message text + are redacted only as defense in depth. - Outbound prompts are not currently guaranteed to be free of secrets. Do not use a third-party provider on sensitive transcripts without reviewing the data source and the provider's retention policy. @@ -114,11 +128,13 @@ Common implemented flags include: | Flag | Default | Purpose | |---|---|---| -| `--backend mock\|claude\|codex\|cursor\|copilot\|handoff\|azure_openai` | `mock` | select who performs model calls | +| `--backend mock\|claude\|codex\|cursor\|copilot\|pi\|handoff\|azure_openai` | `mock` | select who performs model calls | | `--model NAME` | backend default | select a backend-specific model | -| `--source claude\|codex\|cursor\|auto` | `claude` | select the transcript source; `auto` retains Codex-then-Claude precedence and does not select Cursor | +| `--source claude\|codex\|copilot\|cursor\|pi\|auto` | `claude` | select the transcript source; `auto` retains Codex-then-Claude precedence and does not select Copilot, Cursor, or Pi | | `--cursor-home PATH` | `~/.cursor` | override the Cursor transcript home | | `--cursor-path PATH` | auto-detect `cursor-agent` | select the Cursor Agent CLI executable | +| `--pi-home PATH` | `~/.pi` | select the parent directory containing `agent/sessions` | +| `--pi-path PATH` | auto-detect `pi` | select the Pi coding-agent CLI executable | | `--project PATH` | current directory | select the project and invoked harvest scope | | `--scope invoked\|all` | `invoked` | limit transcript harvesting | | `--target-skill-path PATH` | managed skill | select a specific `SKILL.md` to stage/adopt | @@ -153,6 +169,45 @@ python -m skillopt_sleep run --backend codex --project "$(pwd)" \ Preferences guide reflection but remain subject to the validation gate. +### Pi source and backend + +Pi transcript harvesting is explicit: `--source pi` reads session JSONL files +below `~/.pi/agent/sessions`; use `--pi-home` to select the parent directory +that contains `agent/sessions`. This source does not require the Pi CLI or +provider authentication. It retains user/assistant text, tool names, and lexical +feedback found in user text, while excluding thinking, tool arguments, tool +outputs, images, and unrelated metadata. The absolute project `cwd` from the +session header is retained for scope filtering and may appear in miner prompts +sent to a real backend and its provider. Known secret-shaped strings in retained +message text are redacted as defense in depth, not as a guarantee. `--source auto` keeps Codex-then-Claude +precedence and does not select Pi. + +The source and backend are independent. `--backend pi` uses a locally installed, +authenticated Pi CLI to make real model-provider calls for mining, replay, +judging, and reflection. Select another executable with `--pi-path` and a model +with `--model`: + +```bash +python -m skillopt_sleep run --project "$(pwd)" \ + --source pi --backend pi --pi-path /absolute/path/to/pi \ + --model provider/model --max-sessions 5 --max-tasks 3 --progress +``` + +Pi calls disable tools, skills, context files, extensions, prompt templates, +themes, and session writes. They still use the user's Pi authentication and +model configuration. Pi's offline startup mode also prevents configured npm/git +package installation, package updates, and model-catalog refresh; it does not +prevent the selected provider call. This is bounded invocation setup rather +than permanent or complete isolation. Transcript-derived prompts reach the +provider configured in Pi; review that provider's data-retention and privacy +policy before using sensitive sessions. + +The managed scheduler stores the selected backend but does not persist +`--source`, `--pi-home`, `--pi-path`, or `--model`. Before scheduling Pi, set +`transcript_source`, `pi_home`, `pi_path`, and `model` in +`~/.skillopt-sleep/config.json`; prefer an absolute `pi_path` and verify that the +scheduled account is authenticated. + ### Cursor source and backend Cursor transcript harvesting is explicit: use `--source cursor` rather than @@ -221,7 +276,7 @@ the shipping CLI defaults. ## Safety summary - Session harvesting is read-only. -- `mock` replay makes no provider calls. +- `mock` and `handoff` make no network calls. - `run` stages proposals; `adopt` is the normal live-change boundary. - Adoption backs up existing target files. - `--max-sessions` and `--max-tasks` bound work, but the main CLI does not yet diff --git a/plugins/openclaw/run_sleep.py b/plugins/openclaw/run_sleep.py index 3ec50da2..9421df67 100755 --- a/plugins/openclaw/run_sleep.py +++ b/plugins/openclaw/run_sleep.py @@ -35,14 +35,27 @@ # Patch get_backend to know about our backend _orig_get_backend = _b.get_backend -def get_backend(name, model="", codex_path="", cursor_path="", project_dir=""): +def get_backend( + name, + *, + model="", + claude_path="claude", + codex_path="", + pi_path="", + cursor_path="", + azure_endpoint="", + project_dir="", +): if name == "openclaw-deepseek": return OpenClawDeepSeekBackend(model=model or "deepseek-v4-pro") return _orig_get_backend( name, model=model, + claude_path=claude_path, codex_path=codex_path, + pi_path=pi_path, cursor_path=cursor_path, + azure_endpoint=azure_endpoint, project_dir=project_dir, ) diff --git a/plugins/openclaw/skillopt_sleep_openclaw.py b/plugins/openclaw/skillopt_sleep_openclaw.py index 119030ad..2faceaf2 100644 --- a/plugins/openclaw/skillopt_sleep_openclaw.py +++ b/plugins/openclaw/skillopt_sleep_openclaw.py @@ -204,7 +204,12 @@ def reflect( rubric_text = "" if failures: - rubric_text = f"\n\n## REFERENCE ANSWERS\n{chr(10).join(f'Q: {t.intent[:120]}\\nA: {t.reference}' for t, _ in failures[:3] if t.reference)}" + reference_answers = "\n".join( + f"Q: {t.intent[:120]}\nA: {t.reference}" + for t, _ in failures[:3] + if t.reference + ) + rubric_text = f"\n\n## REFERENCE ANSWERS\n{reference_answers}" sys = ( "You are SkillOpt-Sleep's bounded-edit optimizer. Your job is to propose 1-4 MINIMAL text edits to a skill or memory document " diff --git a/skillopt_sleep/__main__.py b/skillopt_sleep/__main__.py index d6fac84f..c87cde0d 100644 --- a/skillopt_sleep/__main__.py +++ b/skillopt_sleep/__main__.py @@ -13,8 +13,8 @@ --max-tasks N cap mined tasks per run --target-skill-path PATH explicit live SKILL.md to stage/adopt --tasks-file PATH reviewed TaskRecord JSON file to replay instead of harvesting - --backend mock|claude|codex|copilot|cursor|handoff - --source claude|codex|copilot|cursor|auto + --backend mock|claude|codex|copilot|cursor|pi|handoff|azure_openai + --source claude|codex|copilot|cursor|pi|auto --vscode-workspace-storage PATH --model NAME --lookback-hours N @@ -72,15 +72,18 @@ def _add_common(p: argparse.ArgumentParser) -> None: p.add_argument("--project", default="") p.add_argument("--scope", default="", choices=["", "all", "invoked"]) p.add_argument("--backend", default="", - choices=["", "mock", "claude", "codex", "copilot", "cursor", "handoff", - "azure_openai"]) + choices=["", "mock", "claude", "codex", "copilot", "cursor", "pi", + "handoff", "azure_openai"]) p.add_argument("--model", default="") p.add_argument("--codex-path", default="", help="path to the real @openai/codex binary") p.add_argument("--cursor-path", default="", help="path to the Cursor Agent CLI") + p.add_argument("--pi-path", default="", help="path to the Pi coding-agent CLI") p.add_argument("--claude-home", default="", help="override ~/.claude (also isolates state)") p.add_argument("--codex-home", default="", help="override ~/.codex for archived session harvest") p.add_argument("--cursor-home", default="", help="override ~/.cursor for Cursor session harvest") - p.add_argument("--source", default="", choices=["", "claude", "codex", "copilot", "cursor", "auto"], + p.add_argument("--pi-home", default="", help="override ~/.pi for Pi session harvest") + p.add_argument("--source", default="", + choices=["", "claude", "codex", "copilot", "cursor", "pi", "auto"], help="session transcript source") p.add_argument("--vscode-workspace-storage", default="", help="override VS Code User/workspaceStorage root for copilot source") @@ -116,12 +119,16 @@ def _cfg_from_args(args, task_meta: Dict[str, Any] | None = None) -> Any: overrides["model"] = args.model if getattr(args, "codex_path", ""): overrides["codex_path"] = os.path.abspath(args.codex_path) + if getattr(args, "pi_path", ""): + overrides["pi_path"] = os.path.abspath(os.path.expanduser(args.pi_path)) if getattr(args, "cursor_path", ""): overrides["cursor_path"] = os.path.abspath(os.path.expanduser(args.cursor_path)) if getattr(args, "claude_home", ""): overrides["claude_home"] = os.path.abspath(args.claude_home) if getattr(args, "codex_home", ""): overrides["codex_home"] = os.path.abspath(args.codex_home) + if getattr(args, "pi_home", ""): + overrides["pi_home"] = os.path.abspath(os.path.expanduser(args.pi_home)) if getattr(args, "cursor_home", ""): overrides["cursor_home"] = os.path.abspath(os.path.expanduser(args.cursor_home)) if getattr(args, "source", ""): diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py index f2cc3c84..b522444f 100644 --- a/skillopt_sleep/backend.py +++ b/skillopt_sleep/backend.py @@ -553,6 +553,145 @@ def tokens_used(self) -> int: return self._tokens +# ── Pi CLI backend ──────────────────────────────────────────────── + + +class PiCliBackend(CliBackend): + """Drive an authenticated Pi coding-agent CLI in print mode. + + pi (the pi coding agent) speaks the open Agent Skills standard and supports + a `-p` / `--print` headless mode, so it slots in alongside the claude/codex + CLI backends. Using pi here means the replay model is whatever the user has + configured in pi (e.g. `zai/glm-5.2`), keeping source and backend on the same + agent — which is the design intent of `--source pi`. + """ + + name = "pi" + + def __init__(self, model: str = "", pi_path: str = "pi", timeout: int = 180) -> None: + super().__init__(model=model or os.environ.get("SKILLOPT_SLEEP_PI_MODEL", ""), + timeout=timeout) + # npm installs Pi through a ``.cmd`` shim on Windows. ``which`` honors + # PATHEXT there, while expanduser also makes an explicit ``~/bin/pi`` + # work consistently on every platform. + import shutil + + requested = os.path.expanduser(pi_path or "pi") + self.pi_path = shutil.which(requested) or requested + + @staticmethod + def _stream_text(value: object) -> str: + """Normalize subprocess output, including TimeoutExpired byte streams.""" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value or "") + + def _set_call_error(self, message: object) -> None: + import logging + + from skillopt_sleep.staging import redact_secrets + + text = self._stream_text(message) + self.last_call_error = str(redact_secrets(text)).strip()[:500] + logging.getLogger("skillopt_sleep").warning( + "Pi CLI call failed: %s", self.last_call_error + ) + + def _cached_call(self, key: str, prompt: str, *, max_tokens: int = 1024) -> str: + """Do not make a transient Pi failure sticky in the response cache.""" + if key in self._cache: + # A cached success must not expose an unrelated previous failure + # through diagnostics/evidence attached to this call. + self.last_call_error = "" + out = super()._cached_call(key, prompt, max_tokens=max_tokens) + if not out: + self._cache.pop(key, None) + return out + + def _call(self, prompt: str, *, max_tokens: int = 1024) -> str: + # Keep credentials/model settings available, but exclude ambient agent + # behavior from replay: disable tools and discoverable resources, force + # Pi's built-in system prompt, suppress APPEND_SYSTEM.md, and run from a + # clean cwd. (``--no-context-files`` alone only disables AGENTS.md and + # CLAUDE.md; it does not disable global SYSTEM.md/APPEND_SYSTEM.md.) + # --no-tools no tool use during replay + # --no-skills do not load the user's installed skills + # --no-context-files do not load AGENTS.md/CLAUDE.md + # --no-session ephemeral; do not write to session history + # --no-extensions skip extension discovery + import shutil + + self.last_call_error = "" + cmd = [self.pi_path, "-p", "--no-session"] + cmd += [ + "--no-tools", + "--no-skills", + "--no-context-files", + "--no-extensions", + "--no-prompt-templates", + "--no-themes", + # An explicit empty custom prompt falls back to Pi's built-in + # prompt while preventing discovery of ~/.pi/agent/SYSTEM.md. + "--system-prompt", + "", + # Suppress discovery of ~/.pi/agent/APPEND_SYSTEM.md. + "--append-system-prompt", + "", + ] + if self.model: + cmd += ["--model", self.model] + clean_cwd = tempfile.mkdtemp(prefix="skillopt_sleep_pi_") + env = os.environ.copy() + # A replay should contact the selected model provider, not Pi's update + # or install-telemetry endpoints during startup. + env["PI_OFFLINE"] = "1" + env["PI_SKIP_VERSION_CHECK"] = "1" + env["PI_TELEMETRY"] = "0" + try: + try: + proc = subprocess.run( + cmd, + input=prompt, + capture_output=True, + creationflags=_NO_WINDOW, + text=True, + encoding="utf-8", + errors="replace", + timeout=self.timeout, + cwd=clean_cwd, + env=env, + ) + except subprocess.TimeoutExpired as exc: + detail = "" + if exc.stderr: + detail = f": {self._stream_text(exc.stderr)}" + self._set_call_error(f"Pi CLI timed out after {self.timeout}s{detail}") + return "" + except Exception as exc: + self._set_call_error(f"Pi CLI failed to start: {exc}") + return "" + finally: + try: + shutil.rmtree(clean_cwd, ignore_errors=True) + except Exception: + pass + out = self._stream_text(proc.stdout).strip() + stderr = self._stream_text(proc.stderr).strip() + if proc.returncode != 0: + # Never let stdout from a failed child masquerade as a model + # answer; some CLIs print diagnostics to stdout. + detail = stderr or out or "no diagnostic output" + self._set_call_error(f"Pi CLI exited {proc.returncode}: {detail}") + return "" + if not out: + # A zero exit status is not a usable model response. Preserve any + # stderr diagnostic for operators, but never return it as an answer. + detail = f": {stderr}" if stderr else "" + self._set_call_error(f"Pi CLI returned an empty response{detail}") + return "" + return out + + # ── Claude Code CLI backend ─────────────────────────────────────────────────── class ClaudeCliBackend(CliBackend): @@ -1821,11 +1960,14 @@ def get_backend( model: str = "", claude_path: str = "claude", codex_path: str = "", + pi_path: str = "", cursor_path: str = "", azure_endpoint: str = "", project_dir: str = "", ) -> Backend: n = (name or "mock").strip().lower() + if n in {"pi", "pi_cli", "pi_coding_agent", "pi-coding-agent"}: + return PiCliBackend(model=model, pi_path=pi_path or "pi") if n in {"claude", "anthropic", "claude_cli", "claude_code"}: return ClaudeCliBackend(model=model, claude_path=claude_path) if n in {"codex", "codex_cli", "openai_codex"}: @@ -1858,6 +2000,7 @@ def build_backend( target_backend: str = "", target_model: str = "", codex_path: str = "", + pi_path: str = "", cursor_path: str = "", azure_endpoint: str = "", preferences: str = "", @@ -1876,6 +2019,7 @@ def build_backend( backend, model=model, codex_path=codex_path, + pi_path=pi_path, cursor_path=cursor_path, azure_endpoint=azure_endpoint, project_dir=project_dir, @@ -1883,10 +2027,12 @@ def build_backend( be.preferences = preferences return be tgt = get_backend(target_backend or backend, model=target_model or model, - codex_path=codex_path, cursor_path=cursor_path, azure_endpoint=azure_endpoint, + codex_path=codex_path, pi_path=pi_path, cursor_path=cursor_path, + azure_endpoint=azure_endpoint, project_dir=project_dir) opt = get_backend(optimizer_backend or backend, model=optimizer_model or model, - codex_path=codex_path, cursor_path=cursor_path, azure_endpoint=azure_endpoint, + codex_path=codex_path, pi_path=pi_path, cursor_path=cursor_path, + azure_endpoint=azure_endpoint, project_dir=project_dir) opt.preferences = preferences # reflect runs on the optimizer dual = DualBackend(target=tgt, optimizer=opt) diff --git a/skillopt_sleep/config.py b/skillopt_sleep/config.py index 3c105673..982a6b75 100644 --- a/skillopt_sleep/config.py +++ b/skillopt_sleep/config.py @@ -19,6 +19,7 @@ HOME_STATE_DIR = os.path.expanduser("~/.skillopt-sleep") CLAUDE_HOME = os.path.expanduser("~/.claude") CODEX_HOME = os.path.expanduser("~/.codex") +PI_HOME = os.path.expanduser("~/.pi") CURSOR_HOME = os.path.expanduser("~/.cursor") @@ -26,9 +27,12 @@ # ── scope ────────────────────────────────────────────────────────────── "claude_home": CLAUDE_HOME, "codex_home": CODEX_HOME, + "pi_home": PI_HOME, "cursor_home": CURSOR_HOME, "vscode_workspace_storage": "", # "" => auto-detect platform defaults - "transcript_source": "claude", # "claude" | "codex" | "copilot" | "cursor" | "auto" + # Explicit sources also include copilot, cursor, and pi. ``auto`` keeps + # the established Codex-then-Claude precedence for backward compatibility. + "transcript_source": "claude", "projects": "invoked", # "invoked" | "all" | [list of abs paths] "invoked_project": "", # filled at runtime (cwd) when projects == "invoked" "lookback_hours": 72, # harvest window when no prior sleep recorded @@ -39,7 +43,7 @@ "val_fraction": 0.34, # real tasks reserved to gate updates "test_fraction": 0.0, # real tasks reserved as the final held-out measure # ── optimizer ────────────────────────────────────────────────────────── - "backend": "mock", # "mock" | "claude" | "codex" | "copilot" | "cursor" + "backend": "mock", # "mock" | "claude" | "codex" | "copilot" | "cursor" | "pi" "model": "", # backend-specific; "" => backend default # Dual-backend split (both empty => single backend above plays all roles). # target = the model whose skill is deployed (runs `attempt` rollouts); @@ -51,6 +55,7 @@ "azure_endpoint": "", # explicit endpoint for azure/compat backends "gate_mode": "on", # "on" (validation-gated) | "off" (greedy, no hard filter) "codex_path": "", # "" => auto-detect the real @openai/codex binary + "pi_path": "", # "" => use `pi` on PATH "cursor_path": "", # "" => auto-detect the Cursor Agent CLI "edit_budget": 4, # textual learning rate (max edits/night) "preferences": "", # free-text house rules injected into reflect as a prior @@ -123,6 +128,11 @@ def transcripts_dir(self) -> str: def codex_archived_sessions_dir(self) -> str: return os.path.join(self.data["codex_home"], "archived_sessions") + @property + def pi_sessions_dir(self) -> str: + pi_home = os.path.abspath(os.path.expanduser(str(self.data["pi_home"]))) + return os.path.join(pi_home, "agent", "sessions") + @property def cursor_projects_dir(self) -> str: cursor_home = os.path.abspath(os.path.expanduser(str(self.data["cursor_home"]))) diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py index 006547fd..744cc602 100644 --- a/skillopt_sleep/cycle.py +++ b/skillopt_sleep/cycle.py @@ -48,6 +48,7 @@ def _make_model_key(cfg: SleepConfig) -> str: target_backend=cfg.get("target_backend", ""), target_model=cfg.get("target_model", ""), codex_path=cfg.get("codex_path", ""), + pi_path=cfg.get("pi_path", ""), cursor_path=cfg.get("cursor_path", ""), azure_endpoint=cfg.get("azure_endpoint", ""), project_dir=cfg.get("invoked_project", "") or os.getcwd(), @@ -233,6 +234,7 @@ def run_sleep_cycle( target_backend=cfg.get("target_backend", ""), target_model=cfg.get("target_model", ""), codex_path=cfg.get("codex_path", ""), + pi_path=cfg.get("pi_path", ""), cursor_path=cfg.get("cursor_path", ""), azure_endpoint=cfg.get("azure_endpoint", ""), preferences=cfg.get("preferences", ""), diff --git a/skillopt_sleep/harvest_pi.py b/skillopt_sleep/harvest_pi.py new file mode 100644 index 00000000..e5ee8bee --- /dev/null +++ b/skillopt_sleep/harvest_pi.py @@ -0,0 +1,311 @@ +"""SkillOpt-Sleep — pi (pi-coding-agent) session harvesting. + +Reads pi session transcript JSONL files (one per session, stored under +``~/.pi/agent/sessions//.jsonl``) and normalizes them +into :class:`SessionDigest` records without copying tool arguments, private +reasoning blocks (``thinking``), or raw tool outputs. + +pi schema (verified against the upstream session format): + * A session file is a JSONL stream of entries with a ``type`` discriminator. + * ``type == "session"`` — exactly one per file; carries ``cwd`` + ``timestamp``. + * Version 1 sessions are linear. Versions 2 and 3 form an append-only tree + using entry ``id`` / ``parentId`` fields; only the branch ending at the last + entry is the active conversation and is harvested. + * ``type == "message"`` — a conversational turn. ``message.role`` ∈ + {user, assistant, toolResult}; ``message.content`` is either a string or a + list of content blocks. Block types include ``text`` (kept), ``thinking`` + (private reasoning, skipped), and ``toolCall`` (carries ``name``). + * toolResult messages can carry ``isError`` and ``toolName``. Tool names are + retained, while transient per-call errors are not treated as task feedback. + * Other types (``model_change``, ``thinking_level_change``, ``custom``, ...) are + metadata / tool-result payloads and are skipped for digestion. + +This module performs NO writes and NO network calls. +""" +from __future__ import annotations + +import json +import os +import re +from typing import Any, Iterable, List, Optional + +from skillopt_sleep.harvest import ( + _detect_feedback, + _is_headless_replay, + _is_meta_prompt, + _project_matches, + _text_from_content, +) +from skillopt_sleep.staging import redact_secrets +from skillopt_sleep.types import SessionDigest + + +def _redact_secrets(text: str) -> str: + """Backward-compatible string wrapper around shared secret redaction.""" + return str(redact_secrets(text)) + + +def _sanitize_tool_name(name: str) -> str: + return re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(name))[:80] + + +def _read_pi_jsonl(path: str) -> Optional[List[dict[str, Any]]]: + """Read one Pi transcript, rejecting the whole session on corruption. + + For tree-shaped v2/v3 sessions, silently skipping a malformed record could + turn an older entry into the apparent active leaf. Pi transcripts therefore + use stricter parsing than the legacy shared harvester: blank lines are fine, + but every non-blank line must be a JSON object and the complete file must be + readable. + """ + records: List[dict[str, Any]] = [] + try: + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + record = json.loads(line) + if not isinstance(record, dict): + return None + records.append(record) + except (OSError, UnicodeError, ValueError): + return None + return records + + +def _pi_tool_names_from_content(content: Any) -> List[str]: + """Extract tool names from pi content blocks. + + pi uses ``{"type": "toolCall", "name": ...}`` (cf. Claude's ``tool_use``). + """ + names: List[str] = [] + if isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "toolCall" and b.get("name"): + names.append(_sanitize_tool_name(str(b["name"]))) + return names + + +def _active_branch_entries(records: List[dict[str, Any]]) -> List[dict[str, Any]]: + """Return the linear history for v1 or the active leaf branch for v2/v3. + + Tree sessions are treated as an integrity boundary: a malformed graph is + skipped in full instead of exposing an arbitrary reachable suffix to the + optimizer. Multiple roots remain valid because navigating back to the + beginning of a Pi session can legitimately create one. + """ + if ( + not records + or any(not isinstance(rec, dict) for rec in records) + or records[0].get("type") != "session" + ): + return [] + if sum(rec.get("type") == "session" for rec in records) != 1: + return [] + + header = records[0] + entries = records[1:] + header_id = header.get("id") + if not isinstance(header_id, str) or not header_id: + return [] + # Legacy v1 headers predate the version field; Pi itself interprets an + # absent field as v1 and migrates those sessions on load. + if "version" not in header: + return entries + version = header["version"] + # ``bool`` is an ``int`` subclass in Python but is not a schema version. + if type(version) is not int or version not in (1, 2, 3): + return [] + if version == 1: + return entries + if not entries: + return [] + + by_id: dict[str, dict[str, Any]] = {} + for rec in entries: + entry_id = rec.get("id") + if not isinstance(entry_id, str) or not entry_id or entry_id in by_id: + return [] + if "parentId" not in rec: + return [] + parent_id = rec["parentId"] + if parent_id is not None: + if not isinstance(parent_id, str) or not parent_id: + return [] + # Pi's writer is append-only: every child is appended after its + # parent. Reject forward references rather than accepting a graph + # that the official writer cannot produce. + if parent_id not in by_id: + return [] + by_id[entry_id] = rec + + branch: List[dict[str, Any]] = [] + seen: set[str] = set() + current: Optional[dict[str, Any]] = entries[-1] + + while current is not None: + entry_id = current["id"] + if entry_id in seen: + return [] + seen.add(entry_id) + branch.append(current) + + parent_id = current["parentId"] + if parent_id is None: + break + current = by_id[parent_id] + + branch.reverse() + return branch + + +def _dedup(xs: Iterable[str]) -> List[str]: + seen: set = set() + out: List[str] = [] + for x in xs: + if x not in seen: + seen.add(x) + out.append(x) + return out + + +def digest_pi_session(path: str, project: str = "") -> Optional[SessionDigest]: + """Build a :class:`SessionDigest` from one pi session transcript.""" + records = _read_pi_jsonl(path) + if not records: + return None + active_entries = _active_branch_entries(records) + session_id = os.path.splitext(os.path.basename(path))[0] + started = "" + ended = "" + session_project = "" + user_prompts: List[str] = [] + assistant_finals: List[str] = [] + tools: List[str] = [] + feedback: List[str] = [] + n_user = 0 + n_asst = 0 + + header = records[0] if records[0].get("type") == "session" else None + if isinstance(header, dict): + ts = header.get("timestamp") + if isinstance(ts, str) and ts: + started = ts + ended = ts + cwd = header.get("cwd") + if isinstance(cwd, str) and cwd: + session_project = cwd + + for rec in active_entries: + rtype = rec.get("type") + ts = rec.get("timestamp") + if isinstance(ts, str) and ts: + if not started: + started = ts + ended = ts + if rtype != "message": + continue + + msg = rec.get("message") + if not isinstance(msg, dict): + continue + role = msg.get("role") + content = msg.get("content") + + if role == "user": + text = _text_from_content(content) + text = _redact_secrets(text).strip() + if text and not _is_meta_prompt(text): + n_user += 1 + user_prompts.append(text) + feedback.extend(_detect_feedback(text)) + elif role == "assistant": + n_asst += 1 + tools.extend(_pi_tool_names_from_content(content)) + text = _text_from_content(content) + if text.strip(): + assistant_finals.append(_redact_secrets(text).strip()) + elif role == "toolResult": + # Corroborating tool-name source: pi records the resolved tool name + # on the result, which catches calls even when the toolCall block's + # `name` was absent. (toolName extraction only; see note below on isError.) + tool_name = msg.get("toolName") + if isinstance(tool_name, str) and tool_name: + tools.append(_sanitize_tool_name(tool_name)) + # NOTE: pi also carries `isError` (bool) here — whether that one tool + # invocation failed mechanically. We deliberately do NOT surface it + # as a feedback signal: intermediate tool errors are normal in + # agentic coding and are frequently followed by recovery and a + # successful final result. Treating every recovered error as + # `neg:` feedback would mislabel successful sessions as failures and + # poison the miner's task-outcome labels. Task outcome should be + # inferred from the user's judgment of the *final* result (the + # lexical feedback phrases above), not from transient tool mechanics. + + if project and not _project_matches(session_project or "", "invoked", project): + return None + if n_user == 0 and n_asst == 0: + return None + + digest = SessionDigest( + session_id=session_id, + project=session_project, + started_at=started, + ended_at=ended, + user_prompts=user_prompts, + assistant_finals=assistant_finals[-5:], + tools_used=_dedup(tools), + files_touched=[], # not extractable from pi transcripts without heuristics + feedback_signals=_dedup(feedback), + n_user_turns=n_user, + n_assistant_turns=n_asst, + raw_path=path, + ) + if _is_headless_replay(digest): + return None + return digest + + +def harvest_pi( + sessions_dir: str, + *, + scope: Any = "all", + invoked_project: str = "", + since_iso: Optional[str] = None, + limit: int = 0, +) -> List[SessionDigest]: + """Walk ``~/.pi/agent/sessions`` (one subdir per project slug) and return digests. + + Parameters mirror :func:`skillopt_sleep.harvest.harvest`. + """ + digests: List[SessionDigest] = [] + if not os.path.isdir(sessions_dir): + return digests + + paths: List[tuple[float, str]] = [] + for root, _dirs, files in os.walk(sessions_dir): + for fn in files: + if fn.endswith(".jsonl"): + path = os.path.join(root, fn) + try: + paths.append((os.path.getmtime(path), path)) + except OSError: + # A session can disappear while Pi rotates or cleans its + # store; skip that file without aborting the whole harvest. + continue + paths.sort(key=lambda item: item[0], reverse=True) + + project_hint = invoked_project if scope == "invoked" else "" + for _mtime, path in paths: + digest = digest_pi_session(path, project=project_hint) + if digest is None: + continue + if not _project_matches(digest.project or "", scope, invoked_project): + continue + if since_iso and digest.ended_at and digest.ended_at < since_iso: + continue + digests.append(digest) + if limit and len(digests) >= limit: + break + return digests diff --git a/skillopt_sleep/harvest_sources.py b/skillopt_sleep/harvest_sources.py index a43ebaca..2cb4e384 100644 --- a/skillopt_sleep/harvest_sources.py +++ b/skillopt_sleep/harvest_sources.py @@ -4,9 +4,10 @@ from typing import Optional from skillopt_sleep.harvest import harvest -from skillopt_sleep.harvest_copilot import harvest_copilot from skillopt_sleep.harvest_codex import harvest_codex +from skillopt_sleep.harvest_copilot import harvest_copilot from skillopt_sleep.harvest_cursor import harvest_cursor +from skillopt_sleep.harvest_pi import harvest_pi from skillopt_sleep.types import SessionDigest @@ -39,6 +40,14 @@ def harvest_for_config(cfg, *, since_iso: Optional[str] = None, limit: int = 0) since_iso=since_iso, limit=limit, ) + if source == "pi": + return harvest_pi( + cfg.pi_sessions_dir, + scope=scope, + invoked_project=invoked_project, + since_iso=since_iso, + limit=limit, + ) if source == "auto": codex_digests = harvest_codex( cfg.codex_archived_sessions_dir, diff --git a/tests/test_backend_pi.py b/tests/test_backend_pi.py new file mode 100644 index 00000000..d72c4878 --- /dev/null +++ b/tests/test_backend_pi.py @@ -0,0 +1,324 @@ +"""Tests for the pi CLI backend (`--backend pi`).""" +from __future__ import annotations + +import os +import subprocess +from unittest import mock + +from skillopt_sleep.backend import ( + _NO_WINDOW, + DualBackend, + PiCliBackend, + build_backend, + get_backend, +) +from skillopt_sleep.types import TaskRecord + + +class _FakeProc: + def __init__(self, stdout: str, stderr: str = "", returncode: int = 0): + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + +def test_get_backend_pi_aliases(): + for alias in ("pi", "pi_cli", "pi_coding_agent", "pi-coding-agent", "PI"): + be = get_backend(alias, model="zai/glm-5.2") + assert isinstance(be, PiCliBackend), alias + assert be.name == "pi" + + +def test_default_model_from_env(monkeypatch): + monkeypatch.setenv("SKILLOPT_SLEEP_PI_MODEL", "zai/glm-5.2") + be = PiCliBackend() + assert be.model == "zai/glm-5.2" + assert be.pi_path == "pi" + + +def test_call_builds_isolated_command_and_returns_stdout(): + be = PiCliBackend(model="zai/glm-5.2", pi_path="/usr/local/bin/pi") + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured.update(kwargs) + return _FakeProc("answer text") + + with mock.patch("skillopt_sleep.backend.subprocess.run", side_effect=fake_run): + out = be._call("do the thing") + + assert out == "answer text" + cmd = captured["cmd"] + assert cmd[0:2] == ["/usr/local/bin/pi", "-p"] + # Prompts go over stdin rather than argv (important for long/Windows calls). + assert captured["input"] == "do the thing" + assert "do the thing" not in cmd + # Isolation flags must be present (no ambient skills/context/tools). + assert "--no-tools" in cmd + assert "--no-skills" in cmd + assert "--no-context-files" in cmd + assert "--no-extensions" in cmd + assert "--no-prompt-templates" in cmd + assert "--no-themes" in cmd + assert "--no-session" in cmd + assert cmd[cmd.index("--system-prompt") + 1] == "" + assert cmd[cmd.index("--append-system-prompt") + 1] == "" + assert "--model" in cmd and "zai/glm-5.2" in cmd + # ran from a clean temp cwd, not inherited + assert captured["cwd"] is not None and captured["cwd"] != "" + assert captured["creationflags"] == _NO_WINDOW + assert captured["env"]["PI_OFFLINE"] == "1" + assert captured["env"]["PI_SKIP_VERSION_CHECK"] == "1" + assert captured["env"]["PI_TELEMETRY"] == "0" + + +def test_path_expands_and_resolves_windows_shim(monkeypatch): + monkeypatch.setattr(os.path, "expanduser", lambda value: "/home/u/bin/pi" if value == "~/bin/pi" else value) + with mock.patch("shutil.which", return_value="C:\\npm\\pi.CMD") as which: + be = PiCliBackend(pi_path="~/bin/pi") + which.assert_called_once_with("/home/u/bin/pi") + assert be.pi_path == "C:\\npm\\pi.CMD" + + +def test_call_records_auth_error_from_nonzero_exit(): + be = PiCliBackend() + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + return_value=_FakeProc( + "", stderr="No API key found for openai", returncode=1 + ), + ): + out = be._call("hi") + assert out == "" # empty stdout + assert "No API key found for openai" in be.last_call_error + + +def test_successful_short_answers_that_mention_cli_errors_are_preserved(): + answers = ( + "Not logged in means the session needs authentication.", + "Authentication required is an error message.", + "Invalid API key should be reported to the user.", + "Unauthorized requests receive HTTP 401.", + "The provider not found error comes from configuration.", + "No provider is required for this local operation.", + ) + be = PiCliBackend() + + for answer in answers: + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + return_value=_FakeProc(answer), + ): + assert be._call("explain the error") == answer + assert be.last_call_error == "" + + +def test_call_records_nonzero_exit_even_with_stdout(): + be = PiCliBackend() + proc = _FakeProc("misleading answer", stderr="pi: unknown option", returncode=2) + with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=proc): + assert be._call("hi") == "" + assert "exited 2" in be.last_call_error + assert "misleading answer" not in be.last_call_error + + +def test_call_records_empty_success_response(): + be = PiCliBackend() + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + return_value=_FakeProc(" \n"), + ): + assert be._call("hi") == "" + assert be.last_call_error == "Pi CLI returned an empty response" + + +def test_call_redacts_stderr_from_empty_success(caplog): + be = PiCliBackend() + secret = "sk-1234567890abcdefghij" + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + return_value=_FakeProc("", stderr=f"warning: key={secret}"), + ): + assert be._call("hi") == "" + assert "warning" in be.last_call_error + assert secret not in be.last_call_error + assert secret not in caplog.text + + +def test_call_preserves_nonempty_success_with_stderr_warning(): + be = PiCliBackend() + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + return_value=_FakeProc("answer", stderr="provider warning"), + ): + assert be._call("hi") == "answer" + assert be.last_call_error == "" + + +def test_call_redacts_secrets_in_error(caplog): + be = PiCliBackend() + secret = "sk-1234567890abcdefghij" + proc = _FakeProc("", stderr=f"Invalid API key: {secret}", returncode=1) + with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=proc): + assert be._call("hi") == "" + assert secret not in be.last_call_error + assert secret not in caplog.text + + +def test_call_records_timeout(): + be = PiCliBackend(timeout=1) + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + side_effect=subprocess.TimeoutExpired("pi", 1), + ): + assert be._call("hi") == "" + assert "timed out" in be.last_call_error + + +def test_call_normalizes_and_redacts_timeout_bytes(caplog): + be = PiCliBackend(timeout=1) + secret = "sk-1234567890abcdefghij" + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + side_effect=subprocess.TimeoutExpired( + "pi", 1, stderr=f"API key: {secret}".encode() + ), + ): + assert be._call("hi") == "" + assert "timed out" in be.last_call_error + assert "b'" not in be.last_call_error + assert secret not in be.last_call_error + assert secret not in caplog.text + + +def test_call_records_and_redacts_spawn_failure(caplog): + be = PiCliBackend() + secret = "sk-1234567890abcdefghij" + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + side_effect=OSError(f"cannot launch with token={secret}"), + ): + assert be._call("hi") == "" + assert secret not in be.last_call_error + assert secret not in caplog.text + + +def test_failed_empty_response_is_not_cached(): + be = PiCliBackend() + with mock.patch.object(be, "_call", side_effect=["", "recovered"]) as call: + assert be._cached_call("attempt:key", "prompt") == "" + assert be._cached_call("attempt:key", "prompt") == "recovered" + assert call.call_count == 2 + + +def test_success_clears_previous_call_error(): + be = PiCliBackend() + be.last_call_error = "an older failure" + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + return_value=_FakeProc("recovered"), + ): + assert be._call("hi") == "recovered" + assert be.last_call_error == "" + + +def test_cache_and_retry_token_accounting(): + prompt = "p" * 20 + response = "r" * 12 + be = PiCliBackend() + with mock.patch.object(be, "_call", return_value=response) as call: + assert be._cached_call("attempt:success", prompt) == response + spent = be.tokens_used() + assert spent == len(prompt) // 4 + len(response) // 4 + assert be._cached_call("attempt:success", prompt) == response + assert call.call_count == 1 + assert be.tokens_used() == spent + + retrying = PiCliBackend() + with mock.patch.object(retrying, "_call", side_effect=["", response]) as call: + assert retrying._cached_call("attempt:retry", prompt) == "" + after_failure = retrying.tokens_used() + assert after_failure == len(prompt) // 4 + assert retrying._cached_call("attempt:retry", prompt) == response + after_recovery = retrying.tokens_used() + assert retrying._cached_call("attempt:retry", prompt) == response + assert call.call_count == 2 + assert after_recovery == after_failure + len(prompt) // 4 + len(response) // 4 + assert retrying.tokens_used() == after_recovery + + +def test_cached_success_clears_stale_call_error_without_spending_tokens(): + be = PiCliBackend() + prompt = "cached prompt" + with mock.patch.object(be, "_call", return_value="cached answer") as call: + assert be._cached_call("attempt:cached", prompt) == "cached answer" + spent = be.tokens_used() + + be.last_call_error = "unrelated later failure" + assert be._cached_call("attempt:cached", prompt) == "cached answer" + + assert call.call_count == 1 + assert be.last_call_error == "" + assert be.tokens_used() == spent + + +def test_dual_backend_tokens_are_summed_once(): + target = PiCliBackend() + optimizer = PiCliBackend() + dual = DualBackend(target=target, optimizer=optimizer) + + with ( + mock.patch.object(target, "_call", return_value="t" * 8), + mock.patch.object(optimizer, "_call", return_value="o" * 12), + ): + target._cached_call("attempt:target", "a" * 16) + optimizer._cached_call("judge:optimizer", "b" * 20) + + expected = target.tokens_used() + optimizer.tokens_used() + assert expected > 0 + assert dual.tokens_used() == expected + + +def test_dual_attempt_failure_retries_then_caches_without_double_counting(): + target = PiCliBackend() + optimizer = PiCliBackend() + dual = DualBackend(target=target, optimizer=optimizer) + task = TaskRecord(id="pi-retry", project="/repo", intent="fix the test") + + failed = _FakeProc("", stderr="Authentication required", returncode=1) + recovered = _FakeProc("fixed response") + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + side_effect=[failed, recovered], + ) as run: + assert dual.attempt(task, "", "") == "" + assert target.last_call_error + after_failure = dual.tokens_used() + + assert dual.attempt(task, "", "") == "fixed response" + assert target.last_call_error == "" + after_recovery = dual.tokens_used() + + assert dual.attempt(task, "", "") == "fixed response" + + assert run.call_count == 2 + assert after_recovery > after_failure + assert dual.tokens_used() == after_recovery + assert optimizer.tokens_used() == 0 + + +def test_pi_path_reaches_single_and_dual_backends(): + single = build_backend(backend="pi", pi_path="/opt/pi") + assert isinstance(single, PiCliBackend) + assert single.pi_path == "/opt/pi" + + dual = build_backend( + backend="mock", + optimizer_backend="pi", + target_backend="pi", + pi_path="/opt/pi", + ) + assert isinstance(dual.optimizer, PiCliBackend) + assert isinstance(dual.target, PiCliBackend) + assert dual.optimizer.pi_path == dual.target.pi_path == "/opt/pi" diff --git a/tests/test_harvest_pi.py b/tests/test_harvest_pi.py new file mode 100644 index 00000000..20496c65 --- /dev/null +++ b/tests/test_harvest_pi.py @@ -0,0 +1,378 @@ +"""Tests for the pi (pi-coding-agent) transcript harvester.""" +from __future__ import annotations + +import json +import os + +from skillopt_sleep.harvest_pi import _redact_secrets, digest_pi_session, harvest_pi + + +def _write_session(tmp_path, slug, name, entries): + d = tmp_path / slug + d.mkdir(parents=True, exist_ok=True) + p = d / f"{name}.jsonl" + with open(p, "w") as f: + for rec in entries: + f.write(json.dumps(rec) + "\n") + return str(p) + + +PI_SESSION = [ + {"type": "session", "version": 3, "id": "s1", "timestamp": "2026-06-23T11:52:04.333Z", "cwd": "/home/u/proj"}, + {"type": "model_change", "id": "m", "parentId": None, "timestamp": "2026-06-23T11:52:05.000Z", "modelId": "gpt-x"}, + {"type": "message", "id": "a1", "parentId": "m", "timestamp": "2026-06-23T11:52:06.000Z", + "message": {"role": "user", "content": [{"type": "text", "text": "fix the failing tests"}]}}, + {"type": "message", "id": "a2", "parentId": "a1", "timestamp": "2026-06-23T11:52:07.000Z", + "message": {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "private reasoning"}, + {"type": "text", "text": "Running the suite now."}, + {"type": "toolCall", "id": "call_1", "name": "bash", "arguments": {"command": "pytest"}}, + ]}}, + {"type": "message", "id": "a3", "parentId": "a2", "timestamp": "2026-06-23T11:52:08.000Z", + "message": {"role": "toolResult", "toolCallId": "call_1", "toolName": "bash", + "content": [{"type": "text", "text": "1 failed"}], "isError": True}}, + {"type": "message", "id": "a4", "parentId": "a3", "timestamp": "2026-06-23T11:52:30.000Z", + "message": {"role": "user", "content": "thanks, that works now"}}, + {"type": "message", "id": "a5", "parentId": "a4", "timestamp": "2026-06-23T11:52:31.000Z", + "message": {"role": "assistant", "content": [{"type": "text", "text": "Glad it's fixed."}]}}, +] + + +def test_digest_extracts_fields(): + d = digest_pi_session("/tmp/abc-123.jsonl") # missing file -> None + assert d is None + + +def test_digest_full_session(tmp_path): + p = _write_session(tmp_path, "--home-u-proj", "abc-123", PI_SESSION) + d = digest_pi_session(p) + assert d is not None + assert d.project == "/home/u/proj" + assert d.n_user_turns == 2 + assert d.n_assistant_turns == 2 + assert "bash" in d.tools_used # from toolCall block + assert any("works" in f for f in d.feedback_signals) # pos feedback + assert all( + not f.startswith("neg:tool_error") for f in d.feedback_signals + ) # isError deliberately NOT surfaced (recovered errors ≠ task failure) + assert all("private reasoning" not in f for f in d.feedback_signals) + # thinking blocks must not leak into finals + assert "private reasoning" not in " ".join(d.assistant_finals) + assert d.started_at.startswith("2026-06-23T11:52:04") + assert d.ended_at.startswith("2026-06-23T11:52:31") + + +def test_harvest_scope_filter(tmp_path): + _write_session(tmp_path, "--home-u-proj", "abc-123", PI_SESSION) + other = list(PI_SESSION) + other[0] = dict(PI_SESSION[0], cwd="/other/place") + _write_session(tmp_path, "--other", "xyz", other) + + all_scope = harvest_pi(str(tmp_path), scope="all") + assert len(all_scope) == 2 + invoked = harvest_pi(str(tmp_path), scope="invoked", invoked_project="/home/u/proj") + assert len(invoked) == 1 + assert invoked[0].project == "/home/u/proj" + + +def test_harvest_skips_session_removed_during_discovery(tmp_path, monkeypatch): + p = _write_session(tmp_path, "vanishing-project", "vanishing", PI_SESSION) + real_getmtime = os.path.getmtime + + def remove_then_stat(path): + if os.fspath(path) == os.fspath(p): + os.unlink(path) + return real_getmtime(path) + + monkeypatch.setattr(os.path, "getmtime", remove_then_stat) + + assert harvest_pi(str(tmp_path), scope="all") == [] + + +def test_secret_redaction(): + out = _redact_secrets("Authorization: Bearer sk-1234567890abcdefghij") + assert "sk-1234567890abcdefghij" not in out + assert "[REDACTED_OPENAI_KEY]" in out + + +def test_shared_secret_redaction_covers_github_tokens(): + token = "ghp_1234567890abcdefghijklmnop" + out = _redact_secrets(f"token leaked in output: {token}") + assert token not in out + assert "[REDACTED_GITHUB_TOKEN]" in out + + +def test_v1_session_remains_linear(tmp_path): + entries = [ + {"type": "session", "version": 1, "id": "v1", "timestamp": "2026-06-23T12:00:00Z", "cwd": "/v1/project"}, + {"type": "message", "timestamp": "2026-06-23T12:00:01Z", "message": {"role": "user", "content": "first request"}}, + {"type": "message", "timestamp": "2026-06-23T12:00:02Z", "message": {"role": "assistant", "content": "first answer"}}, + {"type": "message", "timestamp": "2026-06-23T12:00:03Z", "message": {"role": "user", "content": "second request"}}, + {"type": "message", "timestamp": "2026-06-23T12:00:04Z", "message": {"role": "assistant", "content": "second answer"}}, + ] + p = _write_session(tmp_path, "v1-project", "linear", entries) + + d = digest_pi_session(p) + + assert d is not None + assert d.user_prompts == ["first request", "second request"] + assert d.assistant_finals == ["first answer", "second answer"] + + +def test_unknown_session_version_fails_closed(tmp_path): + entries = [ + {"type": "session", "version": 4, "id": "future", "timestamp": "2026-06-23T12:00:00Z", "cwd": "/future/project"}, + {"type": "message", "id": "u", "parentId": None, "timestamp": "2026-06-23T12:00:01Z", "message": {"role": "user", "content": "future request"}}, + {"type": "message", "id": "a", "parentId": "u", "timestamp": "2026-06-23T12:00:02Z", "message": {"role": "assistant", "content": "future answer"}}, + ] + p = _write_session(tmp_path, "future-project", "unknown", entries) + + assert digest_pi_session(p) is None + + +def test_v3_harvests_only_the_active_branch(tmp_path): + entries = [ + {"type": "session", "version": 3, "id": "session", "timestamp": "2026-06-23T12:00:00Z", "cwd": "/branch/project"}, + {"type": "message", "id": "root", "parentId": None, "timestamp": "2026-06-23T12:00:01Z", "message": {"role": "user", "content": "shared request"}}, + {"type": "message", "id": "old-user", "parentId": "root", "timestamp": "2026-06-23T12:00:02Z", "message": {"role": "user", "content": "abandoned prompt, this is wrong"}}, + {"type": "message", "id": "old-answer", "parentId": "old-user", "timestamp": "2026-06-23T12:00:03Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "abandoned final"}, {"type": "toolCall", "name": "abandoned/tool"}]}}, + {"type": "message", "id": "old-result", "parentId": "old-answer", "timestamp": "2026-06-23T12:00:04Z", "message": {"role": "toolResult", "toolName": "abandoned result", "content": "old output"}}, + {"type": "message", "id": "new-user", "parentId": "root", "timestamp": "2026-06-23T12:00:05Z", "message": {"role": "user", "content": "active prompt"}}, + {"type": "message", "id": "new-answer", "parentId": "new-user", "timestamp": "2026-06-23T12:00:06Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "active final"}, {"type": "toolCall", "name": "active-tool"}]}}, + ] + p = _write_session(tmp_path, "branch-project", "branching", entries) + + d = digest_pi_session(p) + + assert d is not None + assert d.user_prompts == ["shared request", "active prompt"] + assert d.assistant_finals == ["active final"] + assert d.tools_used == ["active-tool"] + assert not d.feedback_signals + assert d.started_at == "2026-06-23T12:00:00Z" + assert d.ended_at == "2026-06-23T12:00:06Z" + + +def test_v3_cycle_fails_closed(tmp_path): + entries = [ + {"type": "session", "version": 3, "id": "cycle", "timestamp": "2026-06-23T12:00:00Z", "cwd": "/cycle/project"}, + {"type": "message", "id": "a", "parentId": "b", "timestamp": "2026-06-23T12:00:01Z", "message": {"role": "user", "content": "cycle request"}}, + {"type": "message", "id": "b", "parentId": "a", "timestamp": "2026-06-23T12:00:10Z", "message": {"role": "assistant", "content": "cycle answer"}}, + ] + p = _write_session(tmp_path, "cycle-project", "cycle", entries) + + assert digest_pi_session(p) is None + + +def test_v3_missing_parent_fails_closed(tmp_path): + entries = [ + {"type": "session", "version": 3, "id": "missing", "timestamp": "2026-06-23T12:00:00Z", "cwd": "/missing/project"}, + {"type": "message", "id": "unrelated", "parentId": None, "timestamp": "2026-06-23T12:00:01Z", "message": {"role": "user", "content": "abandoned prompt"}}, + {"type": "message", "id": "reachable-user", "parentId": "missing", "timestamp": "2026-06-23T12:00:05Z", "message": {"role": "user", "content": "reachable request"}}, + {"type": "message", "id": "leaf", "parentId": "reachable-user", "timestamp": "2026-06-23T12:00:10Z", "message": {"role": "assistant", "content": "reachable answer"}}, + ] + p = _write_session(tmp_path, "missing-project", "missing", entries) + + assert digest_pi_session(p) is None + + +def test_v2_harvests_only_the_active_branch(tmp_path): + entries = [ + {"type": "session", "version": 2, "id": "v2", "timestamp": "2026-06-23T12:00:00Z", "cwd": "/v2/project"}, + {"type": "message", "id": "root", "parentId": None, "timestamp": "2026-06-23T12:00:01Z", "message": {"role": "user", "content": "shared"}}, + {"type": "message", "id": "old", "parentId": "root", "timestamp": "2026-06-23T12:00:02Z", "message": {"role": "assistant", "content": "abandoned"}}, + {"type": "message", "id": "active", "parentId": "root", "timestamp": "2026-06-23T12:00:05Z", "message": {"role": "assistant", "content": "active"}}, + ] + p = _write_session(tmp_path, "v2-project", "branching", entries) + + d = digest_pi_session(p) + + assert d is not None + assert d.user_prompts == ["shared"] + assert d.assistant_finals == ["active"] + + +def test_tree_session_requires_first_unique_header(tmp_path): + misplaced = [ + {"type": "message", "id": "u", "parentId": None, "message": {"role": "user", "content": "request"}}, + {"type": "session", "version": 3, "id": "misplaced", "cwd": "/bad/project"}, + ] + duplicate = [PI_SESSION[0], dict(PI_SESSION[0]), *PI_SESSION[1:]] + + p1 = _write_session(tmp_path, "bad-project", "misplaced", misplaced) + p2 = _write_session(tmp_path, "bad-project", "duplicate", duplicate) + + assert digest_pi_session(p1) is None + assert digest_pi_session(p2) is None + + +def test_tree_session_rejects_invalid_ids_and_parents(tmp_path): + base_header = {"type": "session", "version": 3, "id": "bad", "cwd": "/bad/project"} + cases = { + "missing-id": [{"type": "message", "parentId": None, "message": {"role": "user", "content": "request"}}], + "duplicate-id": [ + {"type": "message", "id": "same", "parentId": None, "message": {"role": "user", "content": "request"}}, + {"type": "message", "id": "same", "parentId": None, "message": {"role": "assistant", "content": "answer"}}, + ], + "missing-parent-field": [{"type": "message", "id": "u", "message": {"role": "user", "content": "request"}}], + "empty-parent": [{"type": "message", "id": "u", "parentId": "", "message": {"role": "user", "content": "request"}}], + "non-string-parent": [{"type": "message", "id": "u", "parentId": 7, "message": {"role": "user", "content": "request"}}], + } + + for name, body in cases.items(): + p = _write_session(tmp_path, f"bad-{name}", name, [base_header, *body]) + assert digest_pi_session(p) is None, name + + +def test_tree_session_rejects_complete_forward_reference(tmp_path): + entries = [ + {"type": "session", "version": 3, "id": "forward", "cwd": "/forward/project"}, + {"type": "message", "id": "old-child", "parentId": "old-root", "message": {"role": "assistant", "content": "abandoned answer"}}, + {"type": "message", "id": "old-root", "parentId": None, "message": {"role": "user", "content": "abandoned request"}}, + {"type": "message", "id": "active-root", "parentId": None, "message": {"role": "user", "content": "active request"}}, + {"type": "message", "id": "active-leaf", "parentId": "active-root", "message": {"role": "assistant", "content": "active answer"}}, + ] + p = _write_session(tmp_path, "forward-project", "forward", entries) + + assert digest_pi_session(p) is None + + +def test_corrupt_abandoned_branch_fails_closed(tmp_path): + header = {"type": "session", "version": 3, "id": "corrupt", "cwd": "/corrupt/project"} + active = [ + {"type": "message", "id": "active-root", "parentId": None, "message": {"role": "user", "content": "active request"}}, + {"type": "message", "id": "active-leaf", "parentId": "active-root", "message": {"role": "assistant", "content": "active answer"}}, + ] + corrupt_branches = { + "cycle": [ + {"type": "message", "id": "bad-a", "parentId": "bad-b", "message": {"role": "user", "content": "abandoned"}}, + {"type": "message", "id": "bad-b", "parentId": "bad-a", "message": {"role": "assistant", "content": "abandoned"}}, + ], + "missing-parent": [ + {"type": "message", "id": "bad-child", "parentId": "absent", "message": {"role": "assistant", "content": "abandoned"}}, + ], + } + + for name, branch in corrupt_branches.items(): + p = _write_session( + tmp_path, + f"corrupt-{name}", + name, + [header, *branch, *active], + ) + assert digest_pi_session(p) is None, name + + +def test_legacy_session_without_version_remains_linear(tmp_path): + entries = [ + {"type": "session", "id": "legacy", "cwd": "/legacy/project"}, + {"type": "message", "message": {"role": "user", "content": "request"}}, + {"type": "message", "message": {"role": "assistant", "content": "answer"}}, + ] + p = _write_session(tmp_path, "legacy-project", "legacy", entries) + + d = digest_pi_session(p) + + assert d is not None + assert d.user_prompts == ["request"] + assert d.assistant_finals == ["answer"] + + +def test_explicit_session_version_requires_supported_integer(tmp_path): + body = [ + {"type": "message", "id": "u", "parentId": None, "message": {"role": "user", "content": "request"}}, + {"type": "message", "id": "a", "parentId": "u", "message": {"role": "assistant", "content": "answer"}}, + ] + for name, version in (("null", None), ("bool", True), ("float", 3.0), ("string", "3")): + header = {"type": "session", "version": version, "id": name, "cwd": "/bad/project"} + p = _write_session(tmp_path, f"bad-version-{name}", name, [header, *body]) + assert digest_pi_session(p) is None, name + + +def test_malformed_final_jsonl_line_fails_closed(tmp_path): + p = tmp_path / "truncated.jsonl" + with open(p, "w", encoding="utf-8") as f: + for record in PI_SESSION: + f.write(json.dumps(record) + "\n") + f.write('{"type":"message","id":"new-active-leaf"') + + assert digest_pi_session(str(p)) is None + + +def test_malformed_middle_jsonl_line_fails_closed(tmp_path): + p = tmp_path / "corrupt-middle.jsonl" + with open(p, "w", encoding="utf-8") as f: + f.write(json.dumps(PI_SESSION[0]) + "\n") + f.write("not-json\n") + for record in PI_SESSION[1:]: + f.write(json.dumps(record) + "\n") + + assert digest_pi_session(str(p)) is None + + +def test_non_object_jsonl_record_fails_closed(tmp_path): + p = tmp_path / "non-object.jsonl" + with open(p, "w", encoding="utf-8") as f: + f.write(json.dumps(PI_SESSION[0]) + "\n") + f.write("[]\n") + for record in PI_SESSION[1:]: + f.write(json.dumps(record) + "\n") + + assert digest_pi_session(str(p)) is None + + +def test_blank_jsonl_lines_remain_valid(tmp_path): + p = tmp_path / "blank-lines.jsonl" + with open(p, "w", encoding="utf-8") as f: + f.write("\n") + for record in PI_SESSION: + f.write(json.dumps(record) + "\n\n") + + digest = digest_pi_session(str(p)) + + assert digest is not None + assert digest.user_prompts == ["fix the failing tests", "thanks, that works now"] + + +def test_tree_session_allows_multiple_roots(tmp_path): + entries = [ + {"type": "session", "version": 3, "id": "roots", "cwd": "/roots/project"}, + {"type": "message", "id": "old-root", "parentId": None, "message": {"role": "user", "content": "abandoned"}}, + {"type": "message", "id": "new-root", "parentId": None, "message": {"role": "user", "content": "active request"}}, + {"type": "message", "id": "leaf", "parentId": "new-root", "message": {"role": "assistant", "content": "active answer"}}, + ] + p = _write_session(tmp_path, "roots-project", "roots", entries) + + d = digest_pi_session(p) + + assert d is not None + assert d.user_prompts == ["active request"] + assert d.assistant_finals == ["active answer"] + + +def test_tool_call_name_is_sanitized(tmp_path): + entries = [ + {"type": "session", "version": 1, "id": "tool-call", "timestamp": "2026-06-23T12:00:00Z", "cwd": "/tool/project"}, + {"type": "message", "timestamp": "2026-06-23T12:00:01Z", "message": {"role": "user", "content": "run a tool"}}, + {"type": "message", "timestamp": "2026-06-23T12:00:10Z", "message": {"role": "assistant", "content": [{"type": "toolCall", "name": "bad tool/"}]}}, + ] + p = _write_session(tmp_path, "tool-project", "tool-call", entries) + + d = digest_pi_session(p) + + assert d is not None + assert d.tools_used == ["bad_tool_arg_"] + + +def test_tool_result_name_is_sanitized(tmp_path): + entries = [ + {"type": "session", "version": 1, "id": "tool-result", "timestamp": "2026-06-23T12:00:00Z", "cwd": "/tool/project"}, + {"type": "message", "timestamp": "2026-06-23T12:00:01Z", "message": {"role": "user", "content": "run a tool"}}, + {"type": "message", "timestamp": "2026-06-23T12:00:10Z", "message": {"role": "toolResult", "toolName": "bad result/", "content": "done"}}, + ] + p = _write_session(tmp_path, "tool-project", "tool-result", entries) + + d = digest_pi_session(p) + + assert d is not None + assert d.tools_used == ["bad_result_arg_"] diff --git a/tests/test_model_change_warning.py b/tests/test_model_change_warning.py index 5989579a..3ffc82bc 100644 --- a/tests/test_model_change_warning.py +++ b/tests/test_model_change_warning.py @@ -102,6 +102,23 @@ def fail_to_build(**_kwargs): ) +def test_model_key_forwards_pi_executable_path(monkeypatch) -> None: + cycle_module = importlib.import_module("skillopt_sleep.cycle") + captured = {} + + real_build = cycle_module.build_backend + + def capture_without_recursion(**kwargs): + captured.update(kwargs) + return real_build(backend="mock") + + monkeypatch.setattr(cycle_module, "build_backend", capture_without_recursion) + cfg = load_config(backend="pi", pi_path="/opt/custom/pi") + + assert cycle_module._make_model_key(cfg) == "mock::" + assert captured["pi_path"] == "/opt/custom/pi" + + def test_legacy_unresolved_model_key_migrates_without_false_warning( tmp_path, capsys ) -> None: diff --git a/tests/test_pi_integration.py b/tests/test_pi_integration.py new file mode 100644 index 00000000..30895ba7 --- /dev/null +++ b/tests/test_pi_integration.py @@ -0,0 +1,83 @@ +"""Configuration and source-routing coverage for Pi integration.""" +from __future__ import annotations + +import argparse +import os +from unittest import mock + +from skillopt_sleep.__main__ import _add_common, _cfg_from_args +from skillopt_sleep.config import load_config +from skillopt_sleep.harvest_sources import harvest_for_config +from skillopt_sleep.types import SessionDigest + + +def test_cli_maps_pi_backend_source_and_paths(monkeypatch): + parser = argparse.ArgumentParser() + _add_common(parser) + args = parser.parse_args( + [ + "--backend", + "pi", + "--source", + "pi", + "--pi-home", + "~/.pi-test", + "--pi-path", + "~/bin/pi", + ] + ) + monkeypatch.setattr("skillopt_sleep.config._user_config_path", lambda: None) + + cfg = _cfg_from_args(args) + + assert cfg.get("backend") == "pi" + assert cfg.get("transcript_source") == "pi" + assert cfg.get("pi_home") == os.path.abspath(os.path.expanduser("~/.pi-test")) + assert cfg.get("pi_path") == os.path.abspath(os.path.expanduser("~/bin/pi")) + assert cfg.pi_sessions_dir == os.path.join( + os.path.abspath(os.path.expanduser("~/.pi-test")), "agent", "sessions" + ) + + +def test_explicit_pi_source_routes_only_to_pi_harvester(): + cfg = load_config( + transcript_source="pi", + projects="invoked", + invoked_project="/repo/project", + pi_home="/tmp/pi-home", + ) + expected = [SessionDigest(session_id="pi-session", project="/repo/project")] + with ( + mock.patch("skillopt_sleep.harvest_sources.harvest_pi", return_value=expected) as pi, + mock.patch("skillopt_sleep.harvest_sources.harvest_codex") as codex, + mock.patch("skillopt_sleep.harvest_sources.harvest") as claude, + ): + actual = harvest_for_config(cfg, since_iso="2026-01-01T00:00:00Z", limit=3) + + assert actual == expected + pi.assert_called_once_with( + "/tmp/pi-home/agent/sessions", + scope="invoked", + invoked_project="/repo/project", + since_iso="2026-01-01T00:00:00Z", + limit=3, + ) + codex.assert_not_called() + claude.assert_not_called() + + +def test_auto_source_does_not_silently_add_pi_precedence(): + cfg = load_config( + transcript_source="auto", + projects="invoked", + invoked_project="/repo/project", + ) + expected = [SessionDigest(session_id="claude-session", project="/repo/project")] + with ( + mock.patch("skillopt_sleep.harvest_sources.harvest_codex", return_value=[]), + mock.patch("skillopt_sleep.harvest_sources.harvest", return_value=expected), + mock.patch("skillopt_sleep.harvest_sources.harvest_pi") as pi, + ): + assert harvest_for_config(cfg) == expected + + pi.assert_not_called() diff --git a/tests/test_plugin_sync.py b/tests/test_plugin_sync.py index 6df09e1d..6d0f7a7b 100644 --- a/tests/test_plugin_sync.py +++ b/tests/test_plugin_sync.py @@ -4,6 +4,8 @@ """ import json import os +import subprocess +import sys import unittest REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) @@ -107,12 +109,40 @@ def test_cursor_docs_keep_scheduling_explicit_and_target_relative(self): def test_openclaw_wrapper_matches_shared_backend_signature(self): text = _read(OPENCLAW_RUNNER) + self.assertIn('claude_path="claude"', text) + self.assertIn('pi_path=""', text) self.assertIn('cursor_path=""', text) + self.assertIn('azure_endpoint=""', text) self.assertIn('project_dir=""', text) + self.assertIn("claude_path=claude_path", text) + self.assertIn("pi_path=pi_path", text) self.assertIn("cursor_path=cursor_path", text) + self.assertIn("azure_endpoint=azure_endpoint", text) self.assertIn("project_dir=project_dir", text) self.assertNotIn("**kwargs", text) + script = f""" +import runpy +import sys +sys.path.insert(0, {os.path.dirname(OPENCLAW_RUNNER)!r}) +runpy.run_path({OPENCLAW_RUNNER!r}, run_name="openclaw_runner_test") +from skillopt_sleep.backend import build_backend +backend = build_backend( + backend="mock", + pi_path="unused-pi", + azure_endpoint="https://unused.invalid", +) +assert backend.name == "mock", backend.name +""" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=REPO, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + def test_all_skill_mds_mention_all_backends(self): for name, path in PLUGIN_SKILL_MDS.items(): text = _read(path)