diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 00000000..55a86596 --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "skillopt", + "owner": { + "name": "Yifan Yang", + "email": "yifanyang@microsoft.com" + }, + "metadata": { + "description": "Official SkillOpt plugins for usage-driven, validation-gated agent improvement." + }, + "plugins": [ + { + "name": "skillopt-sleep", + "source": "plugins/cursor", + "description": "Review recent Cursor sessions, replay recurring work, and stage validation-gated improvements to a Cursor skill for explicit adoption." + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b06b962..c21ad963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to SkillOpt are documented here. This project adheres to ## [Unreleased] ### Added +- Native SkillOpt-Sleep support for Cursor, including a local plugin command + and skill, Cursor transcript harvesting, and an optional Cursor Agent CLI + backend. Cursor tool-aware replay remains disabled pending live permission- + boundary validation. - **Cursor Agent research target harness** (`cursor_exec`) for running supported benchmark rollouts through an installed, authenticated `cursor-agent`, with sandboxed workspaces, structured trace capture, and diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 1b24a2d8..e35d555d 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -26,9 +26,10 @@ checkout for those files. !!! important "PyPI versus `main`" 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, and the Sleep - `--preferences` flag landed after that release and require a source install - from `main` until the next release. + 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. ### Source checkout @@ -128,6 +129,13 @@ Anthropic API client. Install and authenticate `claude`, and set `CLAUDE_CLI_BIN` only if the executable is not available as `claude` on `PATH`. `ANTHROPIC_API_KEY` is one authentication option the CLI may consume. +The SkillOpt-Sleep `cursor` backend similarly requires a separately installed +and authenticated `cursor-agent`; harvesting with `--source cursor` alone does +not. Set `SKILLOPT_SLEEP_CURSOR_PATH` when the executable is not on `PATH`, and +`SKILLOPT_SLEEP_CURSOR_MODEL` to override its model. Cursor plugin installation +and the explicit project skill target are documented in the +[Cursor integration guide](https://github.com/microsoft/SkillOpt/blob/main/plugins/cursor/README.md). + OpenAI-compatible servers have three distinct entry points: 1. The research engine's generic `openai_compatible` backend uses diff --git a/docs/guideline.html b/docs/guideline.html index 2973dec7..801ccaf6 100644 --- a/docs/guideline.html +++ b/docs/guideline.html @@ -295,9 +295,10 @@

Install

Release boundary This guide tracks main. PyPI currently serves 0.2.0; the generic research openai_compatible backend, Sleep handoff, - SkillOpt-Sleep support for non-Azure OpenAI-compatible endpoints, and the - Sleep --preferences flag require a source install from - main until the next release. + SkillOpt-Sleep support for non-Azure OpenAI-compatible endpoints, the + Sleep --preferences flag, and Cursor source/backend/plugin + support require a source install from main until the next + release.

See the installation guide for platform notes and dependency boundaries.

@@ -451,8 +452,25 @@

SkillOpt-Sleep: safe first run

--project scopes collection but does not automatically choose a project's skill file. Use --target-skill-path when you intend to evolve a particular SKILL.md. Transcript source - (claude, codex, or auto) and replay - backend are independent settings.

+ (claude, codex, cursor, or + auto) and replay backend are independent settings. The existing + auto precedence remains Codex then Claude; select Cursor + explicitly with --source cursor.

+

Cursor transcripts default to + ~/.cursor/projects/<workspace>/agent-transcripts; override + that home with --cursor-home. Model-driven replay requires an + installed, authenticated cursor-agent and + --backend cursor; use --cursor-path when the CLI is + not on PATH. Target + .cursor/skills/skillopt-sleep-learned/SKILL.md explicitly so + adoption updates a project skill rather than the plugin's workflow skill.

+

The Cursor backend inserts that skill text into prompts; it does not + invoke the file as a native skill. Ordinary calls run in read-only Ask mode + in an empty temporary workspace and cannot inspect files under + --project. Cursor tasks containing a tool_called + check fail before Agent mode starts; use another backend for those tasks. + This validates textual guidance, not end-to-end repository, browser, + service, or filesystem workflows.

For subscription-based workflows that should not launch an API or model subprocess, use --backend handoff and follow the generated prompt/answer loop. Read the @@ -468,6 +486,7 @@

Agent integrations

Claude CodeShared-engine plugin and handoff commandREADME CodexShared-engine skill shellREADME + CursorNative command and skill, local transcript source, and Cursor Agent backendREADME GitHub CopilotShared-engine Sleep MCP plus a separate research MCPREADME DevinShared-engine MCP with Devin transcript conversionREADME OpenClawIndependent community/reference adaptation; review locally before useREADME @@ -491,6 +510,7 @@

Advanced Sleep controls

dream_rollouts1Single rollout by default; values above 1 enable experimental contrastive replay. dream_factor0Synthetic task variants are off by default. recall_k0Historical associative recall is off by default. + replay_modemockReporting label for prompt replay; fresh-worktree replay is not implemented. @@ -499,6 +519,13 @@

Advanced Sleep controls

reward/budget controls as advanced features that require task-specific validation. The reported experiments and their exact settings are in RESULTS.md.

+

The managed scheduler persists only project, backend, time, and optional + auto-adopt. For Cursor schedules, put transcript_source, + cursor_home, cursor_path, model, and + target_skill_path in + ~/.skillopt-sleep/config.json. Use an absolute Cursor CLI path + and verify authentication for the scheduled account because schedulers may + run with a minimal environment.

@@ -511,6 +538,13 @@

Data, privacy, and adoption safety

is not a guarantee that every outbound model prompt is free of sensitive content. In particular, do not treat raw coding-agent transcripts as pre-sanitized. +
  • The Cursor source excludes tool arguments and outputs, retaining only + user/assistant text, explicit turn errors, and tool names. The Cursor + backend still sends transcript-derived prompts through + cursor-agent to Cursor's selected model provider.
  • +
  • A real-backend dry-run still performs provider calls; it + suppresses staging rather than spend. Session and task limits are not hard + provider-call, token, time, or monetary budgets.
  • Updates are staged for review by default. Use --auto-adopt only when you have an independent rollback and validation process.
  • diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 57269f48..69fa943c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3,8 +3,9 @@ > **Version note.** This reference tracks `main`. PyPI 0.2.0 does not yet > include the generic research `openai_compatible` backend, Sleep handoff, > Sleep support for non-Azure OpenAI-compatible endpoints, the Sleep -> `--preferences` flag, or the research `cursor_exec` target harness; use a -> source install from `main` for those features until the next release. +> `--preferences` flag, the research `cursor_exec` target harness, or Cursor +> source/backend/plugin support; use a source install from `main` for those +> features until the next release. ## Training @@ -125,11 +126,13 @@ Actions are `run`, `dry-run`, `status`, `adopt`, `harvest`, `schedule`, and | Argument | Description | |---|---| -| `--project PATH` | Project to evolve (default: current directory) | +| `--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\|auto` | Transcript source | -| `--backend mock\|claude\|codex\|copilot\|handoff\|azure_openai` | Replay/optimizer backend | +| `--source claude\|codex\|cursor\|auto` | Transcript source; `auto` keeps Codex-then-Claude precedence and does not select Cursor | +| `--backend mock\|claude\|codex\|copilot\|cursor\|handoff\|azure_openai` | Replay/optimizer backend | | `--model NAME` | Backend-specific model override | +| `--cursor-home PATH` | Override `~/.cursor` for Cursor transcript harvesting | +| `--cursor-path PATH` | Path to the installed Cursor 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 | @@ -139,6 +142,82 @@ 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 | +### Cursor source and backend + +`--source cursor` reads local Cursor JSONL transcripts from +`~/.cursor/projects//agent-transcripts/*/*.jsonl`. Invoked scope uses +Cursor's recorded workspace path, including when `--project` is a nested +directory, and falls back to the sanitized storage name when metadata is not +available. `--scope all` scans every workspace below `cursor_home`. The +harvester retains user/assistant text, explicit turn errors, and tool names, +while excluding tool arguments, tool outputs, and non-message records. It +redacts known secret patterns and filters SkillOpt-generated replay sessions, +but redaction is not a guarantee that outbound prompts contain no sensitive +data. + +`--backend cursor` launches an installed, authenticated `cursor-agent`, sends +prompts over stdin, and parses its JSON result. SkillOpt reads the target skill +and includes its text in replay prompts; it does not invoke that file as a native +Cursor skill. Ordinary mining, replay, judging, and reflection calls use +read-only Ask mode in a new empty temporary workspace. Project file reads, file +writes, and MCP tools are denied. `--project` does not change that execution +workspace. + +Cursor tool-aware replay is temporarily disabled pending live Cursor +permission-boundary validation. A task with a `tool_called` check fails nonzero +before Agent mode starts and does not stage, adopt, cache, persist state, or +advance the harvest checkpoint. Use another backend for such tasks. The current +Cursor backend therefore does not provide end-to-end validation for skills that +need repository inspection, real CLIs, browsers, running services, or file +changes. + +There is no implemented fresh-worktree Cursor replay. If a report says +`replay: mock`, that is the prompt-replay label and does not mean the mock model +backend was selected. Both `run` and `dry-run` perform real-backend provider +calls; `dry-run` suppresses staging, adoption, and persisted state changes, not +spend. Session and task limits do not impose hard provider-call, token, time, or +monetary budgets. +Cursor and its selected model provider can receive the prompt content. + +Cursor-specific settings are available through the CLI, config, and environment: + +| Purpose | CLI | `~/.skillopt-sleep/config.json` | Environment | +|---|---|---|---| +| Transcript home | `--cursor-home PATH` | `"cursor_home": "/path/to/.cursor"` | none | +| Agent executable | `--cursor-path PATH` | `"cursor_path": "/path/to/cursor-agent"` | `SKILLOPT_SLEEP_CURSOR_PATH` | +| Model | `--model NAME` | `"model": "NAME"` | `SKILLOPT_SLEEP_CURSOR_MODEL` | + +Use `cursor-agent --list-models` to inspect model identifiers available to the +authenticated account. When cost depends on a model variant, confirm the billed +variant in Cursor's usage reporting rather than relying only on its display +name. + +Target the learned project skill explicitly so accepted updates are visible to +Cursor without modifying the plugin's own `skillopt-sleep` workflow skill: + +```bash +skillopt-sleep run --project "$(pwd)" \ + --source cursor --backend cursor \ + --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \ + --max-sessions 5 --max-tasks 3 --progress +``` + +The first harvest uses a 72-hour lookback unless `--lookback-hours` is set. A +value of `0` considers all available history while still respecting +`--max-sessions`. A stateful `run`, including a run that mines no tasks, records +a new harvest checkpoint; subsequent runs use that checkpoint rather than the +initial lookback. Use `harvest` or `dry-run` to verify counts before the first +stateful run. + +The managed `schedule` command persists the project, backend, time, and optional +auto-adopt setting only. It does not copy source, Cursor paths, model, or target +skill flags into the scheduled command. Put `transcript_source`, `cursor_home`, +`cursor_path`, `model`, and `target_skill_path` in the user config before +scheduling Cursor. Keep `target_skill_path` project-relative as +`.cursor/skills/skillopt-sleep-learned/SKILL.md`, prefer an absolute +`cursor_path`, and verify authentication for the scheduled account because cron +and Task Scheduler may have a minimal environment. + Backend-specific setup for compatible endpoints is documented in [OpenAI-compatible endpoints for SkillOpt-Sleep](../sleep/openai-compatible-endpoints.md). diff --git a/docs/sleep/README.md b/docs/sleep/README.md index 2e36eb16..6e4d7856 100644 --- a/docs/sleep/README.md +++ b/docs/sleep/README.md @@ -17,7 +17,7 @@ normal agent requests. One "night": ``` -harvest Claude Code / Codex transcripts → mine recurring tasks → replay offline +harvest Claude Code / Codex / Cursor transcripts → mine recurring tasks → replay in isolated model calls → consolidate (reflect → bounded edit → GATE on real held-out tasks) → stage proposal → (you) adopt ``` @@ -33,6 +33,15 @@ experience → long-term competence). > review your transcript source and provider policy before running on sensitive > projects. For a reviewable workflow, harvest to a task file, inspect/redact it, mark > it `"reviewed": true`, and then replay that file with the real backend. +> +> The Cursor source reads local user/assistant message text, explicit turn errors, +> and tool names, but excludes tool arguments, tool outputs, and non-message records. +> Known secret-shaped strings are redacted as defense in depth. The Cursor backend +> sends prompts through `cursor-agent`; ordinary calls use read-only Ask mode in an +> empty temporary workspace with project files denied. Cursor tool-aware replay is +> temporarily disabled pending live permission-boundary validation. +> Cursor and the model provider selected by Cursor may therefore receive +> transcript-derived content. ## How to use it @@ -48,13 +57,14 @@ 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. Sleep handoff, non-Azure OpenAI-compatible endpoints, and -> `--preferences` landed later and require a source install from `main` until -> the next release. +> commands above. Cursor source/backend/plugin 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, Copilot, and Devin wrap -the shared engine. OpenClaw is a separate reference adaptation and has its own setup. +standalone, pip-only way to run a cycle. Claude Code, Codex, Cursor, Copilot, and +Devin wrap the shared engine. OpenClaw is a separate reference adaptation and has +its own setup. One engine, thin per-agent shells (see [`plugins/`](https://github.com/microsoft/SkillOpt/tree/main/plugins)): @@ -62,10 +72,62 @@ One engine, thin per-agent shells (see [`plugins/`](https://github.com/microsoft |---|---|---| | **Claude Code** | [`plugins/claude-code`](https://github.com/microsoft/SkillOpt/tree/main/plugins/claude-code) | `/plugin marketplace add ./plugins/claude-code` → `/skillopt-sleep` | | **Codex** | [`plugins/codex`](https://github.com/microsoft/SkillOpt/tree/main/plugins/codex) | `bash plugins/codex/install.sh` → `skillopt-sleep` skill | +| **Cursor** | [`plugins/cursor`](https://github.com/microsoft/SkillOpt/tree/main/plugins/cursor) | `bash plugins/cursor/install.sh` → `/skillopt-sleep` | | **Copilot** | [`plugins/copilot`](https://github.com/microsoft/SkillOpt/tree/main/plugins/copilot) | register `plugins/copilot/mcp_server.py` as an MCP server | | **Devin** | [`plugins/devin`](https://github.com/microsoft/SkillOpt/tree/main/plugins/devin) | register `plugins/devin/mcp_server.py` as an MCP server | | **OpenClaw** | [`plugins/openclaw`](https://github.com/microsoft/SkillOpt/tree/main/plugins/openclaw) | adapt the reference wrapper and paths for your installation | +### Cursor + +Cursor transcript harvesting and model execution are independent. Use +`--source cursor` to read +`~/.cursor/projects//agent-transcripts/*/*.jsonl`; `--scope invoked` +uses Cursor's recorded workspace path, with the sanitized storage directory as +a fallback, while `--scope all` scans every Cursor workspace. Use +`--cursor-home` for a different Cursor home. `--source auto` keeps its existing +Codex-then-Claude precedence and does not select Cursor. + +`--backend cursor` requires an installed, authenticated `cursor-agent`. If it is +not on `PATH`, select it with `--cursor-path`, `SKILLOPT_SLEEP_CURSOR_PATH`, or +the `cursor_path` config key. Select a model with `--model` or +`SKILLOPT_SLEEP_CURSOR_MODEL`. Point adoption at a project Cursor skill rather +than at the plugin's workflow skill: + +```bash +skillopt-sleep run --project "$(pwd)" \ + --source cursor --backend cursor \ + --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \ + --max-sessions 5 --max-tasks 3 --progress +``` + +The target skill is supplied to Cursor as prompt text; it is not invoked as a +native skill. `--project` selects transcript scope, target files, state, and +staging, but ordinary Cursor calls cannot inspect that project's files. The +current backend therefore evaluates textual guidance rather than end-to-end +repository, CLI, browser, or service workflows. + +Cursor tool-aware replay is temporarily disabled pending live Cursor +permission-boundary validation. If a task contains a `tool_called` check, the +Cursor backend exits nonzero before starting Agent mode and does not stage, +adopt, or advance state. Use another backend for those tasks. + +The initial harvest window is 72 hours. Set `--lookback-hours N` explicitly when +older sessions should be considered; `0` scans all history subject to the +session limit. A stateful `run`, even with no mined tasks, advances the harvest +checkpoint. Use `harvest` or `dry-run` to inspect counts first. A real-backend +`dry-run` still incurs provider calls and spend, and session/task limits are not +hard call, token, time, or monetary budgets. + +The managed scheduler records only the project, backend, time, and optional +auto-adopt setting. It does not preserve Cursor source, home, CLI path, model, or +target-skill flags. Before `skillopt-sleep schedule --backend cursor`, put +`transcript_source`, `cursor_home`, `cursor_path`, `model`, and +`target_skill_path` in `~/.skillopt-sleep/config.json`. The target may remain +project-relative as `.cursor/skills/skillopt-sleep-learned/SKILL.md`. Use an +absolute `cursor_path` and verify that the scheduled account is already +authenticated, because cron and Task Scheduler may run with a minimal +environment. + To use DeepSeek, vLLM, Ollama, or another Chat Completions server, see **[OpenAI-compatible endpoints](openai-compatible-endpoints.md)**. That guide also documents the separate HTTPS-only boundary for Azure managed-identity credentials. diff --git a/plugins/README.md b/plugins/README.md index 51230220..61d222c9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -10,13 +10,14 @@ runtime dependency on the paper's `skillopt/` experiment package. ## Available integrations -Four integrations wrap the shared `skillopt_sleep` CLI. OpenClaw is a separate +Five integrations wrap the shared `skillopt_sleep` CLI. OpenClaw is a separate reference adaptation with its own backend and setup assumptions. | Platform | Folder | Mechanism | Status | |---|---|---|---| | **Claude Code** | [`claude-code/`](claude-code) | marketplace plugin, commands, skill, and hooks | installable shared-engine integration | | **Codex** | [`codex/`](codex) | user-level skill and shared runner | installable shared-engine integration | +| **Cursor** | [`cursor/`](cursor) | native command and skill, project skill target, and shared runner | installable shared-engine integration | | **GitHub Copilot** | [`copilot/`](copilot) | MCP server exposing seven `sleep_*` tools | shared-engine MCP integration | | **Devin** | [`devin/`](devin) | MCP server plus Devin transcript conversion | shared-engine MCP integration | | **OpenClaw** | [`openclaw/`](openclaw) | custom DeepSeek/Ollama wrapper | independent reference adaptation; review and adapt before use | @@ -30,6 +31,7 @@ for your workflow. |---|---|---| | **Claude Code** | from the repository root, `/plugin marketplace add ./plugins/claude-code`, then `/plugin install skillopt-sleep@skillopt-sleep` | `/skillopt-sleep status` | | **Codex** | `bash plugins/codex/install.sh` | ask Codex to use the `skillopt-sleep` skill | +| **Cursor** | `bash plugins/cursor/install.sh` (macOS/Linux) or `powershell -File plugins/cursor/install.ps1` (Windows) | `/skillopt-sleep status` | | **Copilot** | register `plugins/copilot/mcp_server.py` using its example MCP config | ask Copilot to run `sleep_status` | | **Devin** | register `plugins/devin/mcp_server.py` using its example MCP config | ask Devin to run `sleep_status` | | **OpenClaw** | follow and adapt [`openclaw/README.md`](openclaw/README.md) | validate paths, credentials, and tasks locally | @@ -44,9 +46,9 @@ an importable `skillopt_sleep` module. Install with `uv tool install skillopt` o `pip install skillopt` when using that fallback. > **Version note.** This integration reference tracks `main`. PyPI 0.2.0 -> supports the base Sleep CLI, while handoff, Sleep support for non-Azure -> OpenAI-compatible endpoints, and `--preferences` require a source checkout -> from `main` until the next release. +> 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. ## One sleep cycle @@ -66,6 +68,17 @@ optimization. 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. +- 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 + secret-shaped strings are redacted, but this is defense in depth rather than + a guarantee that outbound prompts are secret-free. +- The Cursor backend sends prompts through the installed, authenticated + `cursor-agent` CLI. Ordinary calls use read-only Ask mode in a new empty + temporary workspace with project file access denied. Cursor tasks containing + `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. - 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. @@ -101,9 +114,11 @@ Common implemented flags include: | Flag | Default | Purpose | |---|---|---| -| `--backend mock\|claude\|codex\|copilot\|handoff\|azure_openai` | `mock` | select who performs model calls | +| `--backend mock\|claude\|codex\|cursor\|copilot\|handoff\|azure_openai` | `mock` | select who performs model calls | | `--model NAME` | backend default | select a backend-specific model | -| `--source claude\|codex\|auto` | `claude` | select the transcript source | +| `--source claude\|codex\|cursor\|auto` | `claude` | select the transcript source; `auto` retains Codex-then-Claude precedence and does not select Cursor | +| `--cursor-home PATH` | `~/.cursor` | override the Cursor transcript home | +| `--cursor-path PATH` | auto-detect `cursor-agent` | select the Cursor 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 | @@ -119,6 +134,14 @@ The nightly CLI does **not** currently expose `--gate`, `--rollouts-k`, `--optimizer-model`, `--target-model`, `--budget-tokens`, or `--budget-minutes`. Do not pass experiment-harness flags to the main CLI. +For the Cursor backend, `--project` also selects target files, state, and the +staging location, but it does not make that directory the Cursor Agent execution +workspace. The target skill is inserted as prompt text rather than invoked as a +native skill. Real-backend `dry-run` performs the same mining and replay model +calls while suppressing staging, adoption, and persisted state changes. The +current Sleep cycle does not implement fresh-worktree replay; a `replay: mock` +report label describes prompt replay and is independent of `--backend mock`. + ### Preferences `--preferences` is the main user-facing steering knob: @@ -130,6 +153,26 @@ python -m skillopt_sleep run --backend codex --project "$(pwd)" \ Preferences guide reflection but remain subject to the validation gate. +### Cursor source and backend + +Cursor transcript harvesting is explicit: use `--source cursor` rather than +`--source auto`. Invoked-project scope uses Cursor's recorded workspace path, +with the sanitized storage directory as a fallback; `--scope all` scans every +Cursor workspace under `~/.cursor/projects`. The model-driven backend requires +an installed, authenticated `cursor-agent`; use `--cursor-path`, +`SKILLOPT_SLEEP_CURSOR_PATH`, or the `cursor_path` config key when it is not on +`PATH`, and use `--model` or `SKILLOPT_SLEEP_CURSOR_MODEL` to choose a model. + +Target the project skill explicitly so accepted learning becomes visible to +Cursor without changing the plugin's own workflow skill: + +```bash +python -m skillopt_sleep run --project "$(pwd)" \ + --source cursor --backend cursor \ + --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \ + --max-sessions 5 --max-tasks 3 --progress +``` + ### Advanced config The JSON/YAML config under `~/.skillopt-sleep/` supports additional engine keys, @@ -138,6 +181,15 @@ including `gate_mode`, `gate_metric`, `dream_rollouts`, `dream_factor`, `recall_ unsupported CLI flags listed above. Shipping defaults are conservative: `gate_mode="on"`, `dream_rollouts=1`, `dream_factor=0`, and `recall_k=0`. +The managed `schedule` command stores only the project, backend, time, and +optional auto-adopt setting. It does not copy `--source`, `--cursor-home`, +`--cursor-path`, `--model`, or `--target-skill-path` into the scheduled command. +For a Cursor schedule, set `transcript_source`, `cursor_home`, `cursor_path`, +`model`, and `target_skill_path` in `~/.skillopt-sleep/config.json` first. Keep +the target project-relative, use an absolute CLI path because cron and Task +Scheduler may have a minimal `PATH`, and confirm that `cursor-agent` is +authenticated for the account that runs the job. + ### Handoff backend `--backend handoff` keeps model subprocesses out of the engine. It writes pending diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json new file mode 100644 index 00000000..97252bbd --- /dev/null +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "name": "skillopt-sleep", + "displayName": "SkillOpt-Sleep", + "version": "0.1.0", + "description": "Review recent Cursor sessions, replay recurring work, and stage validation-gated improvements to a Cursor skill for explicit adoption.", + "author": { + "name": "Yifan Yang", + "email": "yifanyang@microsoft.com" + }, + "homepage": "https://github.com/microsoft/SkillOpt", + "repository": "https://github.com/microsoft/SkillOpt", + "license": "MIT", + "keywords": [ + "skillopt", + "cursor", + "self-improvement", + "memory-consolidation", + "sleep", + "skills", + "offline-optimization" + ], + "category": "developer-tools", + "tags": [ + "automation", + "memory", + "transcripts" + ], + "commands": "./commands/", + "skills": "./skills/" +} diff --git a/plugins/cursor/LICENSE b/plugins/cursor/LICENSE new file mode 100644 index 00000000..cf7bcb2b --- /dev/null +++ b/plugins/cursor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/cursor/README.md b/plugins/cursor/README.md new file mode 100644 index 00000000..498fd36c --- /dev/null +++ b/plugins/cursor/README.md @@ -0,0 +1,246 @@ +# SkillOpt-Sleep - Cursor integration + +Give Cursor an on-demand or explicitly scheduled sleep cycle: review recent +local Cursor sessions, replay recurring tasks through a selected backend, and +stage validation-gated improvements to a project Cursor skill. Nothing runs at +session end, and nothing live changes until the user adopts an accepted staged +proposal (unless they explicitly request `--auto-adopt`). + +This package is a native Cursor plugin containing a command and an agent skill. +It does not install hooks or an MCP server. + +## Requirements + +- Cursor with plugin and agent-skill support. +- Python 3.10 or newer. +- Either a SkillOpt source checkout or an installed `skillopt-sleep` command. +- For `--backend cursor`, an installed and authenticated Cursor Agent CLI + (`cursor-agent`). The default `mock` backend needs no provider login or spend. + +The plugin and transcript harvester work on native Windows. Cursor documents +the Agent CLI for Windows through WSL; run provider-backed `--backend cursor` +inside WSL unless a native `cursor-agent` is available in your environment. + +## Install the local plugin + +Clone the repository, then run the installer for your platform. + +macOS or Linux: + +```bash +git clone https://github.com/microsoft/SkillOpt.git +cd SkillOpt +bash plugins/cursor/install.sh +export SKILLOPT_SLEEP_REPO="$(pwd)" +``` + +Windows PowerShell: + +```powershell +git clone https://github.com/microsoft/SkillOpt.git +Set-Location SkillOpt +powershell -File plugins/cursor/install.ps1 +[System.Environment]::SetEnvironmentVariable("SKILLOPT_SLEEP_REPO", "$(pwd)", "User") +``` + +The installer copies the plugin to +`~/.cursor/plugins/local/skillopt-sleep` (or +`%USERPROFILE%\.cursor\plugins\local\skillopt-sleep`). Quit and reopen Cursor +after changing user environment variables, then confirm that **SkillOpt-Sleep** +appears in Settings > Plugins under Installed. + +The plugin and engine have separate installation boundaries. The copied plugin +teaches Cursor how to operate SkillOpt-Sleep; the engine still runs from the +source checkout through `plugins/run-sleep.sh` / `plugins/run-sleep.ps1`, or +from an installed command: + +```bash +uv tool install skillopt +# or: python -m pip install skillopt +``` + +Use a release that includes Cursor source/backend support when choosing the +installed-command route. The source-checkout route uses the implementation in +the checkout directly. + +## Use from Cursor + +Run the native command, for example: + +```text +/skillopt-sleep status +/skillopt-sleep dry-run --backend mock --max-sessions 5 --max-tasks 3 +/skillopt-sleep run --backend cursor --max-sessions 5 --max-tasks 3 --progress +/skillopt-sleep adopt +``` + +The `skillopt-sleep` agent skill remains independently available if a Cursor +version does not surface plugin commands. + +The native command's default Cursor-visible target is: + +```text +.cursor/skills/skillopt-sleep-learned/SKILL.md +``` + +Use `--target-skill-path` with that value on harvest, dry-run, and run commands. +Without an explicit target, the shared engine defaults to a Claude-managed +skill, which Cursor does not load as a project skill. + +### Source-checkout commands + +macOS or Linux: + +```bash +bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" status --project "$(pwd)" +bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" dry-run \ + --project "$(pwd)" --source cursor --backend mock \ + --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md +bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" run \ + --project "$(pwd)" --source cursor --backend cursor \ + --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \ + --max-sessions 5 --max-tasks 3 --progress +``` + +Windows PowerShell: + +```powershell +powershell -File "$env:SKILLOPT_SLEEP_REPO\plugins\run-sleep.ps1" status --project "$(pwd)" +powershell -File "$env:SKILLOPT_SLEEP_REPO\plugins\run-sleep.ps1" dry-run ` + --project "$(pwd)" --source cursor --backend mock ` + --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md +powershell -File "$env:SKILLOPT_SLEEP_REPO\plugins\run-sleep.ps1" run ` + --project "$(pwd)" --source cursor --backend cursor ` + --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md ` + --max-sessions 5 --max-tasks 3 --progress +``` + +### Installed-command equivalents + +```bash +skillopt-sleep status --project "$(pwd)" +skillopt-sleep dry-run --project "$(pwd)" --source cursor --backend mock \ + --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md +skillopt-sleep run --project "$(pwd)" --source cursor --backend cursor \ + --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \ + --max-sessions 5 --max-tasks 3 --progress +skillopt-sleep adopt --project "$(pwd)" +``` + +`--source cursor` reads local JSONL transcripts below +`~/.cursor/projects//agent-transcripts/`. Use +`--cursor-home /path/to/.cursor` for a different Cursor home. Invoked scope +selects the current workspace; `--scope all` includes every Cursor workspace. +The source converter retains user/assistant text, tool names, and explicit turn +errors, but excludes raw tool arguments and outputs. + +The first harvest uses a 72-hour lookback by default. Use +`--lookback-hours N` to choose a wider initial window, or +`--lookback-hours 0` to consider all available history while still respecting +`--max-sessions`. A successful `run`, including one that mines no tasks, +records a new harvest checkpoint. Later runs use that checkpoint instead of +the initial lookback. Use `harvest` or `dry-run` to inspect session and task +counts before the first stateful run; neither action advances the checkpoint. + +`--backend cursor` invokes the authenticated Cursor Agent CLI. Use +`--cursor-path /path/to/cursor-agent` or `SKILLOPT_SLEEP_CURSOR_PATH` if it is +not on PATH, and `--model` or `SKILLOPT_SLEEP_CURSOR_MODEL` to override its +model. Check available identifiers with `cursor-agent --list-models` and verify +the billed variant in Cursor's usage reporting when cost matters. + +## What Cursor replay evaluates + +SkillOpt reads the target skill and inserts its text into mined task prompts. It +does not invoke that file as a native Cursor skill or execute commands described +by the skill. All ordinary Cursor model calls, including mining, replay, +judging, and reflection, run in a new empty temporary workspace in read-only Ask +mode. File reads, file writes, and MCP tools are denied. `--project` selects the +transcript scope, target files, state, and staging location; it does not make the +project the Cursor Agent execution workspace. + +Cursor tool-aware replay is temporarily disabled pending live Cursor +permission-boundary validation. Tasks containing a `tool_called` check fail +nonzero before Agent mode starts. The failed replay does not add a cache entry, +stage, adopt, persist state, or advance the harvest checkpoint. Use another +backend for those tasks. + +This replay is useful for textual procedures, response conventions, and output +formats. It is not an end-to-end evaluation of skills that depend on repository +inspection, real CLIs, browsers, running services, or filesystem changes. There +is currently no Cursor option that enables a fresh project worktree or real +project tools. The `replay: mock` report label refers to the prompt-replay mode, +not to the selected model backend. + +The shared engine also supports `mock`, `claude`, `codex`, `copilot`, +`handoff`, and `azure_openai` backends. Cursor is the native model-driven +choice for this integration; `mock` remains the no-provider default. + +## Review sensitive data before provider calls + +Harvesting is local and read-only, and `--backend mock` makes no provider calls. +Known secret-shaped strings are redacted from harvested Cursor content, and raw +tool payloads are excluded, but pattern-based redaction is not a guarantee. +A real backend sends truncated transcript excerpts and derived tasks to that +backend's provider for mining, replay, judging, and reflection. + +Both `run` and `dry-run` perform those real-backend calls; `dry-run` prevents +staging but does not prevent provider spend. `--max-sessions` and `--max-tasks` +bound harvested work, not provider calls, tokens, elapsed time, or money. One +task can require several attempt, judge, and reflection calls. Start with small +limits and review the provider's usage reporting before increasing them. + +For sensitive work, split the flow at the review boundary: + +```bash +skillopt-sleep harvest --project "$(pwd)" --source cursor \ + --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md \ + --max-sessions 5 --max-tasks 3 --output reviewed-tasks.json + +skillopt-sleep dry-run --project "$(pwd)" --backend cursor \ + --tasks-file reviewed-tasks.json --progress --json +``` + +Inspect and redact the JSON, then set its top-level `"reviewed"` field to +`true`. Real backends reject task files that remain unreviewed. Keep raw +transcripts, credentials, and task files out of commits. + +## Scheduling + +Runs remain user-triggered unless the user explicitly schedules them. Before +scheduling, put the Cursor source and target in +`~/.skillopt-sleep/config.json`, because the scheduler persists the project, +backend, time, and optional auto-adopt flag, but not command-line source or +target overrides: + +```json +{ + "transcript_source": "cursor", + "target_skill_path": ".cursor/skills/skillopt-sleep-learned/SKILL.md", + "cursor_home": "/absolute/path/to/.cursor", + "backend": "cursor" +} +``` + +Then schedule or remove the managed entry: + +```bash +skillopt-sleep schedule --project "$(pwd)" --backend cursor --hour 3 --minute 17 +skillopt-sleep unschedule --project "$(pwd)" +``` + +On Unix this uses cron; on Windows it uses Task Scheduler. Scheduled runs stage +proposals for later review by default. Do not add `--auto-adopt` unless the user +has explicitly chosen unattended adoption. + +## Adoption and memory + +`run` stages accepted proposals under +`/.skillopt-sleep/staging//`. Read the staged `report.md` +and show the held-out baseline-to-candidate score plus exact edits before +running `adopt`. Adoption backs up an existing target before replacing it. + +The shared engine may also propose project `CLAUDE.md` memory updates; existing +memory behavior is unchanged. To restrict a Cursor setup to the explicit Cursor +skill, set `"evolve_memory": false` in `~/.skillopt-sleep/config.json`. + +There is deliberately no session-end hook or automatic plugin execution. diff --git a/plugins/cursor/commands/skillopt-sleep.md b/plugins/cursor/commands/skillopt-sleep.md new file mode 100644 index 00000000..a62020fc --- /dev/null +++ b/plugins/cursor/commands/skillopt-sleep.md @@ -0,0 +1,19 @@ +# SkillOpt-Sleep + +Use the bundled `skillopt-sleep` skill to run or manage SkillOpt-Sleep for the +current Cursor workspace. + +Requested action: `$ARGUMENTS` + +If no action was supplied, use `status`. Preserve all options supplied after +the action. For `harvest`, `dry-run`, and `run`, ensure the engine receives: + +```text +--project --scope invoked --source cursor --target-skill-path .cursor/skills/skillopt-sleep-learned/SKILL.md +``` + +Do not add `--backend cursor` unless the user requested provider-backed replay; +the repository default remains the no-provider `mock` backend. Follow the +skill's runner selection, review, data-boundary, scheduling, and adoption rules. +Never edit the learned skill or `CLAUDE.md` directly as a substitute for the +engine's staged adoption flow. diff --git a/plugins/cursor/install.ps1 b/plugins/cursor/install.ps1 new file mode 100644 index 00000000..dd7b232e --- /dev/null +++ b/plugins/cursor/install.ps1 @@ -0,0 +1,39 @@ +# Install the SkillOpt-Sleep Cursor integration as a local Cursor plugin on Windows. +# Idempotent; prints what it does. + +$ErrorActionPreference = "Stop" + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$CursorHome = if ($env:CURSOR_HOME) { $env:CURSOR_HOME } else { Join-Path $env:USERPROFILE ".cursor" } +$PluginDir = Join-Path $CursorHome "plugins\local\skillopt-sleep" +$SourceDir = Join-Path $RepoRoot "plugins\cursor" +$ManifestDir = Join-Path $PluginDir ".cursor-plugin" +$CommandDir = Join-Path $PluginDir "commands" +$SkillDir = Join-Path $PluginDir "skills\skillopt-sleep" + +Write-Output "[install] repo: $RepoRoot" + +New-Item -ItemType Directory -Path $ManifestDir -Force | Out-Null +New-Item -ItemType Directory -Path $CommandDir -Force | Out-Null +New-Item -ItemType Directory -Path $SkillDir -Force | Out-Null +Copy-Item (Join-Path $SourceDir ".cursor-plugin\plugin.json") (Join-Path $ManifestDir "plugin.json") -Force +Copy-Item (Join-Path $SourceDir "commands\skillopt-sleep.md") (Join-Path $CommandDir "skillopt-sleep.md") -Force +Copy-Item (Join-Path $SourceDir "skills\skillopt-sleep\SKILL.md") (Join-Path $SkillDir "SKILL.md") -Force +Copy-Item (Join-Path $SourceDir "README.md") (Join-Path $PluginDir "README.md") -Force +Copy-Item (Join-Path $SourceDir "LICENSE") (Join-Path $PluginDir "LICENSE") -Force + +Write-Output "[install] plugin manifest -> $(Join-Path $ManifestDir 'plugin.json')" +Write-Output "[install] command -> $(Join-Path $CommandDir 'skillopt-sleep.md')" +Write-Output "[install] skill -> $(Join-Path $SkillDir 'SKILL.md')" +Write-Output "" +Write-Output "[install] Quit and reopen Cursor. The plugin should appear in Settings >" +Write-Output "Plugins under Installed." +Write-Output "" +Write-Output "For source-checkout runs, add this user environment variable:" +Write-Output " [System.Environment]::SetEnvironmentVariable('SKILLOPT_SLEEP_REPO', '$RepoRoot', 'User')" +Write-Output "" +Write-Output "Alternatively, install a SkillOpt release that includes Cursor support so the" +Write-Output "skillopt-sleep command is on PATH." +Write-Output "" +Write-Output "Done. Try in Cursor:" +Write-Output " /skillopt-sleep status" diff --git a/plugins/cursor/install.sh b/plugins/cursor/install.sh new file mode 100755 index 00000000..9b75d70e --- /dev/null +++ b/plugins/cursor/install.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Install the SkillOpt-Sleep Cursor integration as a local Cursor plugin. +# Idempotent; prints what it does. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CURSOR_HOME="${CURSOR_HOME:-$HOME/.cursor}" +PLUGIN_DIR="$CURSOR_HOME/plugins/local/skillopt-sleep" +SOURCE_DIR="$REPO_ROOT/plugins/cursor" + +echo "[install] repo: $REPO_ROOT" + +mkdir -p "$PLUGIN_DIR/.cursor-plugin" "$PLUGIN_DIR/commands" "$PLUGIN_DIR/skills/skillopt-sleep" +cp "$SOURCE_DIR/.cursor-plugin/plugin.json" "$PLUGIN_DIR/.cursor-plugin/plugin.json" +cp "$SOURCE_DIR/commands/skillopt-sleep.md" "$PLUGIN_DIR/commands/skillopt-sleep.md" +cp "$SOURCE_DIR/skills/skillopt-sleep/SKILL.md" "$PLUGIN_DIR/skills/skillopt-sleep/SKILL.md" +cp "$SOURCE_DIR/README.md" "$PLUGIN_DIR/README.md" +cp "$SOURCE_DIR/LICENSE" "$PLUGIN_DIR/LICENSE" + +echo "[install] plugin manifest -> $PLUGIN_DIR/.cursor-plugin/plugin.json" +echo "[install] command -> $PLUGIN_DIR/commands/skillopt-sleep.md" +echo "[install] skill -> $PLUGIN_DIR/skills/skillopt-sleep/SKILL.md" + +cat < +Plugins under Installed. + +For source-checkout runs, add this to your shell profile: + export SKILLOPT_SLEEP_REPO="$REPO_ROOT" + +Alternatively, install a SkillOpt release that includes Cursor support so the +\`skillopt-sleep\` command is on PATH. + +Done. Try in Cursor: + /skillopt-sleep status +EOF diff --git a/plugins/cursor/skills/skillopt-sleep/SKILL.md b/plugins/cursor/skills/skillopt-sleep/SKILL.md new file mode 100644 index 00000000..7caa8fc3 --- /dev/null +++ b/plugins/cursor/skills/skillopt-sleep/SKILL.md @@ -0,0 +1,218 @@ +--- +name: skillopt-sleep +description: "Use when the user wants Cursor to learn from recent local sessions, asks for an offline sleep or dream cycle, wants to consolidate recurring work into a Cursor skill, or requests SkillOpt-Sleep status, harvest, dry-run, run, scheduling, review, or adoption. Drives the validation-gated skillopt_sleep engine with Cursor transcripts and the optional Cursor Agent CLI backend." +--- + +# SkillOpt-Sleep for Cursor + +SkillOpt-Sleep reviews recent local Cursor sessions, mines recurring tasks, +replays those tasks, and proposes bounded improvements to a project Cursor +skill. With the default gate enabled, a proposal is accepted only when it +improves the held-out score. A normal run stages the proposal for review; +nothing live changes until explicit adoption. There is no model-weight training. + +This plugin has no session-end hook and no MCP server. Run the cycle only when +the user asks, or install a schedule only when the user explicitly requests one. + +## Cursor target + +Always use this project-relative target for Cursor-visible learning: + +```text +.cursor/skills/skillopt-sleep-learned/SKILL.md +``` + +Pass it through `--target-skill-path` on `harvest`, `dry-run`, and `run`. +Without an explicit target, the shared engine uses a Claude-managed skill under +`~/.claude/skills`, which is not the intended Cursor project skill. + +The shared engine can also evolve project `CLAUDE.md`. If that secondary memory +target is unwanted, set `"evolve_memory": false` in +`~/.skillopt-sleep/config.json` before running. + +## Choose the runner + +Use one of these supported command paths consistently: + +1. Source checkout on macOS/Linux: + `bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" ...` +2. Source checkout on Windows: + `powershell -File "$env:SKILLOPT_SLEEP_REPO\plugins\run-sleep.ps1" ...` +3. Installed engine on any platform: + `skillopt-sleep ...` + +If `SKILLOPT_SLEEP_REPO` is not set and `skillopt-sleep` is unavailable, stop +and explain that the engine must be installed or a SkillOpt checkout must be +selected. Do not substitute a hand-written edit for the engine workflow. + +## Core workflow + +1. **Harvest** local Cursor JSONL transcripts read-only. +2. **Mine** recurring, checkable task records from session digests. +3. **Replay** tasks under the current skill and memory through the selected + backend. +4. **Reflect** on failures and propose bounded edits. +5. **Gate** the candidate on held-out real tasks. +6. **Stage** accepted proposals under + `/.skillopt-sleep/staging//`. +7. **Adopt** only after review, backing up existing live targets first. + +## Commands + +Use the installed-command form below, or replace `skillopt-sleep` with the +platform-specific source runner described above. + +```bash +TARGET_SKILL=.cursor/skills/skillopt-sleep-learned/SKILL.md + +# Inspect current state and the latest staged proposal. +skillopt-sleep status --project "$(pwd)" + +# Inspect mined tasks without provider spend. +skillopt-sleep harvest --project "$(pwd)" --source cursor \ + --target-skill-path "$TARGET_SKILL" --max-sessions 5 --max-tasks 3 + +# First smoke check: deterministic and no provider calls. +skillopt-sleep dry-run --project "$(pwd)" --source cursor --backend mock \ + --target-skill-path "$TARGET_SKILL" --max-sessions 5 --max-tasks 3 --json + +# Model-driven optimization through the authenticated Cursor Agent CLI. +skillopt-sleep run --project "$(pwd)" --source cursor --backend cursor \ + --target-skill-path "$TARGET_SKILL" \ + --max-sessions 5 --max-tasks 3 --progress + +# Apply the latest accepted staged proposal after review. +skillopt-sleep adopt --project "$(pwd)" +``` + +Actions are `status`, `harvest`, `dry-run`, `run`, `adopt`, `schedule`, and +`unschedule`. + +- Default backend is `mock`, which is deterministic and makes no provider calls. +- `--backend cursor` uses the user's authenticated Cursor Agent CLI budget for + model-driven mining, replay, judging, and reflection. +- `--source cursor` reads + `~/.cursor/projects//agent-transcripts/*/*.jsonl`. +- `--cursor-home PATH` overrides the Cursor home used for harvesting. +- `--scope invoked` selects the current workspace; `--scope all` includes every + Cursor workspace. +- `--cursor-path PATH` or `SKILLOPT_SLEEP_CURSOR_PATH` selects a non-default + `cursor-agent` executable. +- `--model NAME` or `SKILLOPT_SLEEP_CURSOR_MODEL` overrides the Cursor model. +- Check model identifiers with `cursor-agent --list-models`; when cost matters, + verify the billed variant in Cursor's usage reporting. +- Keep live runs bounded with `--max-sessions`, `--max-tasks`, and `--progress`. +- A held-out gain is evidence for that run, not a promise of general improvement. + +The first harvest uses a 72-hour lookback. Use `--lookback-hours N` for a wider +initial window or `--lookback-hours 0` for all available history. A stateful +`run`, including a no-task run, records a harvest checkpoint; later runs use the +checkpoint rather than the initial lookback. Inspect counts with `harvest` or +`dry-run` before the first real run because those actions do not advance state. + +Available backends are: + +- `mock` - deterministic, with no provider calls (default); +- `cursor` - the authenticated Cursor Agent CLI; +- `claude` - the authenticated Claude CLI; +- `codex` - the authenticated Codex CLI; +- `copilot` - the authenticated GitHub Copilot CLI; +- `handoff` - prompt/answer files for an interactive agent session; +- `azure_openai` - the configured Azure OpenAI endpoint. + +SkillOpt reads the target skill and inserts its text into replay prompts; it does +not invoke the file as a native Cursor skill. Ordinary Cursor backend calls run +in a new empty temporary workspace in read-only Ask mode. File reads, file +writes, and MCP tools are denied. `--project` controls harvesting, target files, +state, and staging; it is not the Cursor Agent execution workspace. + +Cursor tool-aware replay is temporarily disabled pending live Cursor +permission-boundary validation. A task containing a `tool_called` check fails +nonzero before Agent mode starts. The failed replay does not add a cache entry, +stage, adopt, persist state, or advance the harvest checkpoint. Use another +backend for those tasks. Do not claim that repository- or tool-dependent +behavior was validated. The current engine does not implement a fresh-worktree +replay for Cursor. + +A real-backend `dry-run` still makes provider calls; it only suppresses staging. +Session and task limits are workload bounds, not hard limits on calls, tokens, +time, or money. Start with small limits. + +## Reviewable data path + +Cursor harvesting retains user/assistant text, tool names, and explicit turn +errors while excluding raw tool arguments, tool outputs, and non-message +records. Known secret-shaped strings are redacted, but pattern-based redaction +cannot guarantee that a transcript is safe to send to a provider. + +For sensitive sessions, export tasks before any real-backend replay: + +```bash +TARGET_SKILL=.cursor/skills/skillopt-sleep-learned/SKILL.md +skillopt-sleep harvest --project "$(pwd)" --source cursor \ + --target-skill-path "$TARGET_SKILL" \ + --max-sessions 5 --max-tasks 3 --output reviewed-tasks.json +``` + +Inspect and redact the file, then set its top-level `"reviewed"` field to +`true`. Only then run: + +```bash +skillopt-sleep dry-run --project "$(pwd)" --backend cursor \ + --tasks-file reviewed-tasks.json --progress --json +``` + +Real backends reject task files that remain unreviewed. Never include raw +transcripts, credentials, secrets, or sensitive task content in messages, +commits, or generated summaries. + +## Scheduling + +Scheduling is opt-in. The scheduler persists project, backend, time, and the +optional auto-adopt flag, but not `--source`, Cursor path/home/model overrides, +or `--target-skill-path`. Before scheduling a Cursor cycle, set at least these values in +`~/.skillopt-sleep/config.json`: + +```json +{ + "transcript_source": "cursor", + "target_skill_path": ".cursor/skills/skillopt-sleep-learned/SKILL.md", + "backend": "cursor" +} +``` + +Then run: + +```bash +skillopt-sleep schedule --project "$(pwd)" --backend cursor --hour 3 --minute 17 +skillopt-sleep unschedule --project "$(pwd)" +``` + +The scheduler uses cron on Unix and Task Scheduler on Windows. Scheduled runs +stage proposals by default. Use `--auto-adopt` only when the user has explicitly +requested unattended adoption. + +## Report results + +For `dry-run` and `run`, report: + +- session and task counts; +- held-out baseline and candidate scores; +- gate action and accepted/rejected edit counts; +- exact proposed edits; +- staging directory, when one was created. + +Read staged `report.md` before summarizing a run. Offer adoption only after the +user reviews an accepted proposal that is still staged. Never claim broad +improvement from one run. + +## Hard rules + +- Harvest is read-only. Never edit Cursor transcript files. +- Never hand-edit the target skill or `CLAUDE.md` as a substitute for adoption. +- Do not run a real backend on sensitive content without confirming its data + boundary or using the reviewed-task workflow. +- Do not add a session-end hook or imply that installing this plugin schedules + anything. +- Show validation evidence before recommending adoption. +- Treat generated edits as proposals, not as source of truth. diff --git a/plugins/openclaw/run_sleep.py b/plugins/openclaw/run_sleep.py index 516d7585..3ec50da2 100755 --- a/plugins/openclaw/run_sleep.py +++ b/plugins/openclaw/run_sleep.py @@ -35,10 +35,16 @@ # Patch get_backend to know about our backend _orig_get_backend = _b.get_backend -def get_backend(name, model="", codex_path=""): +def get_backend(name, model="", codex_path="", cursor_path="", project_dir=""): if name == "openclaw-deepseek": return OpenClawDeepSeekBackend(model=model or "deepseek-v4-pro") - return _orig_get_backend(name, model=model, codex_path=codex_path) + return _orig_get_backend( + name, + model=model, + codex_path=codex_path, + cursor_path=cursor_path, + project_dir=project_dir, + ) _b.get_backend = get_backend diff --git a/skillopt_sleep/__main__.py b/skillopt_sleep/__main__.py index 5a761b66..d3bbf43d 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|handoff - --source claude|codex|auto + --backend mock|claude|codex|copilot|cursor|handoff + --source claude|codex|cursor|auto --model NAME --lookback-hours N --auto-adopt @@ -28,6 +28,7 @@ import sys from typing import Any, Dict +from skillopt_sleep.backend import CursorBackendError from skillopt_sleep.config import load_config from skillopt_sleep.cycle import run_sleep_cycle from skillopt_sleep.harvest_sources import harvest_for_config @@ -70,13 +71,15 @@ 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", "handoff", + choices=["", "mock", "claude", "codex", "copilot", "cursor", "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("--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("--source", default="", choices=["", "claude", "codex", "auto"], + p.add_argument("--cursor-home", default="", help="override ~/.cursor for Cursor session harvest") + p.add_argument("--source", default="", choices=["", "claude", "codex", "cursor", "auto"], help="session transcript source") p.add_argument("--lookback-hours", type=int, default=None, help="harvest window in hours; 0 = scan full history") @@ -110,10 +113,14 @@ 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, "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, "cursor_home", ""): + overrides["cursor_home"] = os.path.abspath(os.path.expanduser(args.cursor_home)) if getattr(args, "source", ""): overrides["transcript_source"] = args.source lh = getattr(args, "lookback_hours", None) @@ -164,7 +171,11 @@ def cmd_run(args, dry: bool = False) -> int: return 2 if cfg.get("backend", "mock") == "handoff": return _run_handoff(cfg, args, seed_tasks=tasks, task_meta=task_meta, dry=dry) - outcome = run_sleep_cycle(cfg, seed_tasks=tasks, dry_run=dry) + try: + outcome = run_sleep_cycle(cfg, seed_tasks=tasks, dry_run=dry) + except CursorBackendError as exc: + print(f"[sleep] Cursor backend failed: {_redact_deep(str(exc))}", file=sys.stderr) + return 1 _print_run_report(outcome, args, task_meta) return 0 diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py index cd130a48..dff640d3 100644 --- a/skillopt_sleep/backend.py +++ b/skillopt_sleep/backend.py @@ -1007,6 +1007,27 @@ def resolve_copilot_path(explicit: str = "") -> str: return found or "copilot" +def resolve_cursor_path(explicit: str = "") -> str: + """Find the Cursor Agent CLI (``cursor-agent``).""" + if explicit: + return os.path.expanduser(explicit) + env = os.environ.get("SKILLOPT_SLEEP_CURSOR_PATH") + if env: + return os.path.expanduser(env) + import shutil + + found = shutil.which("cursor-agent") + return found or "cursor-agent" + + +class CursorBackendError(RuntimeError): + """A redacted Cursor Agent process or response failure.""" + + def __init__(self, message: str, *, retryable: bool = False) -> None: + super().__init__(message) + self.retryable = retryable + + class CopilotCliBackend(CliBackend): """Drives the GitHub Copilot CLI in non-interactive mode. @@ -1207,6 +1228,221 @@ def attempt_with_tools(self, task, skill, memory, tools): pass +class CursorCliBackend(CliBackend): + """Drive an authenticated Cursor Agent CLI in an isolated workspace. + + Cursor's JSON print format has one final ``result`` object. Ordinary calls + use Ask mode, which is read-only. Tool-aware replay is disabled until the + Cursor Agent permission boundary has been validated against the live CLI. + """ + + name = "cursor" + _AUTH_ERROR_MARKERS = ( + "not authenticated", + "authentication required", + "not logged in", + "please log in", + "login required", + "unauthorized", + "invalid api key", + "401", + "403", + ) + _CONFIG_ERROR_MARKERS = ( + "unknown option", + "invalid option", + "invalid model", + "unsupported model", + "model not found", + "not available for your account", + "invalid configuration", + ) + _ASK_DENY = ["Read(**)", "Write(**)", "Mcp(*:*)"] + + def __init__(self, model: str = "", cursor_path: str = "", timeout: int = 240) -> None: + super().__init__(model=model or os.environ.get("SKILLOPT_SLEEP_CURSOR_MODEL", ""), timeout=timeout) + self.cursor_path = resolve_cursor_path(cursor_path) + + def _command(self, workspace: str) -> List[str]: + cmd = [ + self.cursor_path, + "-p", + "--output-format", + "json", + "--trust", + "--workspace", + workspace, + "--mode", + "ask", + ] + if self.model: + cmd += ["--model", self.model] + return cmd + + @staticmethod + def _terminal_result(raw: str) -> Optional[Dict[str, Any]]: + candidates = [raw.strip()] if raw.strip() else [] + candidates.extend(line.strip() for line in raw.splitlines() if line.strip().startswith("{")) + for candidate in reversed(candidates): + try: + obj = json.loads(candidate) + except Exception: + continue + if isinstance(obj, dict) and obj.get("type") == "result": + return obj + return None + + @classmethod + def _parse_json_response(cls, raw: str) -> str: + """Return text from the terminal successful Cursor result.""" + terminal = cls._terminal_result(raw) + if terminal is None or terminal.get("is_error") is True: + return "" + result = terminal.get("result") + if isinstance(result, str): + return result.strip() + return "" + + def _error(self, message: str, *, retryable: bool = False) -> CursorBackendError: + import logging + + from skillopt_sleep.staging import redact_secrets + + self.last_call_error = str(redact_secrets(message))[:500] + logging.getLogger("skillopt_sleep").warning("Cursor Agent call failed: %s", self.last_call_error) + return CursorBackendError(self.last_call_error, retryable=retryable) + + @staticmethod + def _isolated_environment(runtime_dir: str) -> Dict[str, str]: + config_dir = os.path.join(runtime_dir, "config") + data_dir = os.path.join(runtime_dir, "data") + os.makedirs(config_dir, exist_ok=True) + os.makedirs(data_dir, exist_ok=True) + with open(os.path.join(config_dir, "cli-config.json"), "w", encoding="utf-8") as f: + json.dump( + { + "version": 1, + "editor": {"vimMode": False}, + "permissions": { + "allow": [], + "deny": CursorCliBackend._ASK_DENY, + }, + "approvalMode": "allowlist", + "sandbox": { + "mode": "disabled", + "networkAccess": "user_config_only", + "networkAllowlist": [], + }, + "network": {"useHttp1ForAgent": False}, + "hasChangedDefaultModel": False, + "attribution": { + "attributeCommitsToAgent": False, + "attributePRsToAgent": False, + }, + }, + f, + indent=2, + ) + env = os.environ.copy() + env["CURSOR_CONFIG_DIR"] = config_dir + env["CURSOR_DATA_DIR"] = data_dir + return env + + def _invoke_once(self, prompt: str, workspace: str) -> str: + import shutil + + from skillopt_sleep.harvest_cursor import CURSOR_REPLAY_SENTINEL + + self.last_call_error = "" + replay_prompt = CURSOR_REPLAY_SENTINEL + "\n\n" + prompt + runtime_dir = "" + try: + runtime_dir = tempfile.mkdtemp(prefix="skillopt_sleep_cursor_runtime_") + proc = subprocess.run( + self._command(workspace), + capture_output=True, + creationflags=_NO_WINDOW, + text=True, + encoding="utf-8", + errors="replace", + timeout=self.timeout, + cwd=workspace, + input=replay_prompt, + env=self._isolated_environment(runtime_dir), + ) + except subprocess.TimeoutExpired: + raise self._error( + f"Cursor Agent timed out after {self.timeout}s", + retryable=True, + ) + except Exception as exc: + raise self._error(f"Cursor Agent spawn failed: {exc}") + finally: + if runtime_dir: + shutil.rmtree(runtime_dir, ignore_errors=True) + + if proc.returncode != 0: + raise self._error( + f"Cursor Agent exited {proc.returncode}: {(proc.stderr or '')[:500]}" + ) + terminal = self._terminal_result(proc.stdout or "") + if terminal is not None and terminal.get("is_error") is True: + detail = terminal.get("result") or terminal.get("error") or proc.stderr or "" + raise self._error(f"Cursor Agent returned an error result: {str(detail)[:300]}") + output = self._parse_json_response(proc.stdout or "") + if not output: + detail = (proc.stderr or "").strip() + if any(marker in detail.casefold() for marker in self._AUTH_ERROR_MARKERS): + raise self._error( + "Cursor Agent authentication failed" + + (f": {detail[:300]}" if detail else "") + ) + if any(marker in detail.casefold() for marker in self._CONFIG_ERROR_MARKERS): + raise self._error( + "Cursor Agent configuration failed" + + (f": {detail[:300]}" if detail else "") + ) + raise self._error( + "Cursor Agent returned no usable JSON response" + + (f": {detail[:300]}" if detail else ""), + retryable=True, + ) + self.last_call_error = "" + return output + + def _call(self, prompt: str, *, max_tokens: int = 1024) -> str: + del max_tokens + workspace = tempfile.mkdtemp(prefix="skillopt_sleep_cursor_") + try: + for attempt in range(2): + try: + return self._invoke_once(prompt, workspace) + except CursorBackendError as exc: + if not exc.retryable or attempt == 1: + raise + raise AssertionError("unreachable") + finally: + try: + import shutil + + shutil.rmtree(workspace, ignore_errors=True) + except Exception: + pass + + def attempt_with_tools( + self, + task: TaskRecord, + skill: str, + memory: str, + tools: List[str], + ) -> Tuple[str, List[str]]: + del task, skill, memory, tools + raise self._error( + "Cursor tool-aware replay is temporarily disabled pending live " + "Cursor permission-boundary validation" + ) + + class DualBackend(Backend): """Route operations to two backends, à la SkillOpt's target vs optimizer. @@ -1564,6 +1800,7 @@ def get_backend( model: str = "", claude_path: str = "claude", codex_path: str = "", + cursor_path: str = "", azure_endpoint: str = "", project_dir: str = "", ) -> Backend: @@ -1579,6 +1816,8 @@ def get_backend( return AzureResponsesBackend(deployment=model, endpoints=eps) if n in {"copilot", "github_copilot", "copilot_cli", "gh_copilot"}: return CopilotCliBackend(model=model) + if n in {"cursor", "cursor_agent", "cursor_cli"}: + return CursorCliBackend(model=model, cursor_path=cursor_path) if n in {"handoff", "session", "file"}: # Lazy import: handoff_backend imports CliBackend from this module. from skillopt_sleep.handoff_backend import HandoffBackend @@ -1598,6 +1837,7 @@ def build_backend( target_backend: str = "", target_model: str = "", codex_path: str = "", + cursor_path: str = "", azure_endpoint: str = "", preferences: str = "", project_dir: str = "", @@ -1615,16 +1855,17 @@ def build_backend( backend, model=model, codex_path=codex_path, + cursor_path=cursor_path, azure_endpoint=azure_endpoint, project_dir=project_dir, ) be.preferences = preferences return be tgt = get_backend(target_backend or backend, model=target_model or model, - codex_path=codex_path, azure_endpoint=azure_endpoint, + codex_path=codex_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, azure_endpoint=azure_endpoint, + codex_path=codex_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 4a1c992f..772017e4 100644 --- a/skillopt_sleep/config.py +++ b/skillopt_sleep/config.py @@ -19,13 +19,15 @@ HOME_STATE_DIR = os.path.expanduser("~/.skillopt-sleep") CLAUDE_HOME = os.path.expanduser("~/.claude") CODEX_HOME = os.path.expanduser("~/.codex") +CURSOR_HOME = os.path.expanduser("~/.cursor") DEFAULTS: Dict[str, Any] = { # ── scope ────────────────────────────────────────────────────────────── "claude_home": CLAUDE_HOME, "codex_home": CODEX_HOME, - "transcript_source": "claude", # "claude" | "codex" | "auto" + "cursor_home": CURSOR_HOME, + "transcript_source": "claude", # "claude" | "codex" | "cursor" | "auto" "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 @@ -36,15 +38,16 @@ "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" + "backend": "mock", # "mock" | "claude" | "codex" | "copilot" | "cursor" "model": "", # backend-specific; "" => backend default "gate_mode": "on", # "on" (validation-gated) | "off" (greedy, no hard filter) "codex_path": "", # "" => auto-detect the real @openai/codex binary + "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 "gate_metric": "mixed", # hard | soft | mixed (mixed best for tiny holdouts) "gate_mixed_weight": 0.5, - "replay_mode": "mock", # "mock" (sandboxed prompt) | "fresh" (worktree) + "replay_mode": "mock", # report label; fresh-worktree replay is not implemented # ── dream + recall (opt-in; defaults reproduce the prior single-shot loop) ─ "dream_rollouts": 1, # >1 => multi-rollout contrastive reflection per task "dream_factor": 0, # >0 => add N synthetic variants of each task to the dream @@ -108,6 +111,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 cursor_projects_dir(self) -> str: + cursor_home = os.path.abspath(os.path.expanduser(str(self.data["cursor_home"]))) + return os.path.join(cursor_home, "projects") + @property def history_path(self) -> str: return os.path.join(self.data["claude_home"], "history.jsonl") diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py index f199211d..a94b8ec0 100644 --- a/skillopt_sleep/cycle.py +++ b/skillopt_sleep/cycle.py @@ -118,6 +118,7 @@ def run_sleep_cycle( cfg.get("backend", "mock"), model=cfg.get("model", ""), codex_path=cfg.get("codex_path", ""), + cursor_path=cfg.get("cursor_path", ""), project_dir=project, ) backend.preferences = cfg.get("preferences", "") diff --git a/skillopt_sleep/harvest_cursor.py b/skillopt_sleep/harvest_cursor.py new file mode 100644 index 00000000..c4379a49 --- /dev/null +++ b/skillopt_sleep/harvest_cursor.py @@ -0,0 +1,316 @@ +"""Read Cursor Agent transcripts and normalize them into session digests. + +Cursor writes workspace-scoped JSONL under +``~/.cursor/projects//agent-transcripts//.jsonl``. +The observed local records contain user/assistant messages and tool-use metadata +but no timestamps, so this harvester uses each file's mtime as its end time. +Tool inputs and outputs are intentionally never copied. +""" +from __future__ import annotations + +import json +import os +import re +from datetime import datetime, timezone +from typing import Any, Iterable, List, Optional + +from skillopt_sleep.harvest import ( + _detect_feedback, + _is_meta_prompt, + _iter_jsonl, +) +from skillopt_sleep.staging import redact_secrets +from skillopt_sleep.types import SessionDigest + +CURSOR_REPLAY_SENTINEL = "" +_CURSOR_USER_QUERY_RE = re.compile( + r"\s*(.*?)\s*\s*\Z", + re.DOTALL, +) + + +def cursor_project_slug(project: str) -> str: + """Return the filesystem-safe workspace name used under Cursor projects.""" + normalized = os.path.abspath(os.path.expanduser(project)) + return re.sub(r"[^A-Za-z0-9]+", "-", normalized).strip("-") + + +def _text_from_content(content: Any) -> str: + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + return "\n".join( + str(block["text"]) + for block in content + if isinstance(block, dict) + and block.get("type") == "text" + and block.get("text") + ) + + +def _tool_names(content: Any) -> List[str]: + if not isinstance(content, list): + return [] + names: List[str] = [] + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + name = block.get("name") + if isinstance(name, str) and name: + names.append(re.sub(r"[^A-Za-z0-9_.:-]+", "_", name)[:80]) + return names + + +def _sanitize_text(text: str) -> str: + sanitized = str(redact_secrets(text)).replace("\x00", "").strip() + user_query = _CURSOR_USER_QUERY_RE.search(sanitized) + if user_query: + sanitized = user_query.group(1).strip() + if not sanitized or _is_meta_prompt(sanitized): + return "" + return sanitized + + +def _dedup(values: Iterable[str]) -> List[str]: + seen = set() + result: List[str] = [] + for value in values: + if value not in seen: + seen.add(value) + result.append(value) + return result + + +def _mtime_iso(path: str) -> str: + try: + return ( + datetime.fromtimestamp(os.path.getmtime(path), tz=timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + except OSError: + return "" + + +def _mtime(path: str) -> Optional[float]: + try: + return os.path.getmtime(path) + except OSError: + return None + + +def _iso_epoch(value: Optional[str]) -> Optional[float]: + if not value: + return None + try: + normalized = value[:-1] + "+00:00" if value.endswith("Z") else value + parsed = datetime.fromisoformat(normalized) + # Existing state timestamps are local-time strings without an offset. + return parsed.timestamp() + except (TypeError, ValueError, OSError): + return None + + +def digest_cursor_transcript(path: str, *, project: str = "") -> Optional[SessionDigest]: + """Build a digest without retaining Cursor tool arguments or outputs.""" + session_id = os.path.splitext(os.path.basename(path))[0] + user_prompts: List[str] = [] + assistant_finals: List[str] = [] + tools: List[str] = [] + feedback: List[str] = [] + n_user = 0 + n_assistant = 0 + + for record in _iter_jsonl(path): + if not isinstance(record, dict): + continue + if record.get("type") == "turn_ended": + if record.get("status") == "error": + feedback.append("neg:cursor_turn_error") + continue + + message = record.get("message") + if not isinstance(message, dict): + continue + role = record.get("role") or message.get("role") + content = message.get("content") + if role == "user": + text = _sanitize_text(_text_from_content(content)) + if text: + n_user += 1 + user_prompts.append(text) + feedback.extend(_detect_feedback(text)) + elif role == "assistant": + n_assistant += 1 + tools.extend(_tool_names(content)) + text = _sanitize_text(_text_from_content(content)) + if text: + assistant_finals.append(text) + + if n_user == 0 and n_assistant == 0: + return None + + return SessionDigest( + session_id=session_id, + project=project, + ended_at=_mtime_iso(path), + user_prompts=user_prompts, + assistant_finals=assistant_finals[-5:], + tools_used=_dedup(tools), + files_touched=[], + feedback_signals=feedback, + n_user_turns=n_user, + n_assistant_turns=n_assistant, + raw_path=path, + ) + + +def _workspace_path(project_dir: str) -> str: + metadata_path = os.path.join(project_dir, ".workspace-trusted") + try: + with open(metadata_path, encoding="utf-8") as f: + metadata = json.load(f) + except (OSError, ValueError): + return "" + if not isinstance(metadata, dict): + return "" + workspace = metadata.get("workspacePath") + if not isinstance(workspace, str) or not workspace.strip(): + return "" + workspace = os.path.expanduser(workspace.strip()) + if not os.path.isabs(workspace): + return "" + return os.path.abspath(workspace) + + +def _normalized_path(path: str) -> str: + return os.path.normcase(os.path.realpath(os.path.abspath(os.path.expanduser(path)))) + + +def _is_workspace_ancestor(workspace: str, invoked: str) -> bool: + try: + workspace_norm = _normalized_path(workspace) + invoked_norm = _normalized_path(invoked) + return os.path.commonpath([workspace_norm, invoked_norm]) == workspace_norm + except (OSError, ValueError): + return False + + +def _available_project_dirs(projects_dir: str) -> List[tuple[str, str, str]]: + try: + names = sorted(os.listdir(projects_dir)) + except OSError: + return [] + result: List[tuple[str, str, str]] = [] + for name in names: + project_dir = os.path.join(projects_dir, name) + if os.path.isdir(os.path.join(project_dir, "agent-transcripts")): + result.append((project_dir, name, _workspace_path(project_dir))) + return result + + +def _project_dirs(projects_dir: str, scope: Any, invoked_project: str) -> List[tuple[str, str]]: + available = _available_project_dirs(projects_dir) + if scope == "all": + return [ + (project_dir, workspace or name) + for project_dir, name, workspace in available + ] + + projects: List[str] + if isinstance(scope, (list, tuple)): + projects = [str(project) for project in scope] + else: + projects = [invoked_project] if invoked_project else [] + + selected: List[tuple[str, str]] = [] + seen = set() + for project in projects: + absolute_project = os.path.abspath(os.path.expanduser(project)) + matches = [ + (project_dir, workspace) + for project_dir, _name, workspace in available + if workspace and _is_workspace_ancestor(workspace, absolute_project) + ] + candidate = absolute_project + while candidate: + fallback = os.path.join(projects_dir, cursor_project_slug(candidate)) + if os.path.isdir(os.path.join(fallback, "agent-transcripts")): + matches.append((fallback, candidate)) + break + parent = os.path.dirname(candidate) + if parent == candidate: + break + candidate = parent + + if matches: + longest = max(len(_normalized_path(workspace)) for _project_dir, workspace in matches) + choices = [ + (project_dir, workspace) + for project_dir, workspace in matches + if len(_normalized_path(workspace)) == longest + ] + else: + fallback = os.path.join(projects_dir, cursor_project_slug(absolute_project)) + choices = [(fallback, absolute_project)] + for project_dir, workspace in choices: + if project_dir not in seen: + selected.append((project_dir, workspace)) + seen.add(project_dir) + return selected + + +def _is_cursor_replay(digest: SessionDigest) -> bool: + return any(prompt.lstrip().startswith(CURSOR_REPLAY_SENTINEL) for prompt in digest.user_prompts) + + +def harvest_cursor( + projects_dir: str, + *, + scope: Any = "all", + invoked_project: str = "", + since_iso: Optional[str] = None, + limit: int = 0, +) -> List[SessionDigest]: + """Return Cursor session digests for the selected workspace scope.""" + if not os.path.isdir(projects_dir): + return [] + + candidates: List[tuple[str, str, float]] = [] + for project_dir, project in _project_dirs(projects_dir, scope, invoked_project): + transcripts_dir = os.path.join(project_dir, "agent-transcripts") + try: + session_names = sorted(os.listdir(transcripts_dir)) + except OSError: + continue + for session_name in session_names: + session_dir = os.path.join(transcripts_dir, session_name) + if not os.path.isdir(session_dir): + continue + try: + filenames = sorted(os.listdir(session_dir)) + except OSError: + continue + for filename in filenames: + path = os.path.join(session_dir, filename) + if not filename.endswith(".jsonl") or not os.path.isfile(path): + continue + modified = _mtime(path) + if modified is not None: + candidates.append((path, project, modified)) + candidates.sort(key=lambda item: (-item[2], item[0])) + + since_epoch = _iso_epoch(since_iso) + digests: List[SessionDigest] = [] + for path, project, modified in candidates: + if since_epoch is not None and modified <= since_epoch: + continue + digest = digest_cursor_transcript(path, project=project) + if digest is None or _is_cursor_replay(digest): + 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 501aa285..0b949601 100644 --- a/skillopt_sleep/harvest_sources.py +++ b/skillopt_sleep/harvest_sources.py @@ -5,6 +5,7 @@ from skillopt_sleep.harvest import harvest from skillopt_sleep.harvest_codex import harvest_codex +from skillopt_sleep.harvest_cursor import harvest_cursor from skillopt_sleep.types import SessionDigest @@ -21,6 +22,14 @@ def harvest_for_config(cfg, *, since_iso: Optional[str] = None, limit: int = 0) since_iso=since_iso, limit=limit, ) + if source == "cursor": + return harvest_cursor( + cfg.cursor_projects_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/skillopt_sleep/mine.py b/skillopt_sleep/mine.py index 44830574..9a258274 100644 --- a/skillopt_sleep/mine.py +++ b/skillopt_sleep/mine.py @@ -20,6 +20,7 @@ from collections import Counter from typing import Any, Callable, List, Optional, Set, Tuple +from skillopt_sleep.backend import CursorBackendError from skillopt_sleep.types import SessionDigest, TaskRecord @@ -300,6 +301,8 @@ def mine( if llm_miner is not None: try: tasks = llm_miner(digests) or [] + except CursorBackendError: + raise except Exception: tasks = [] if not tasks: diff --git a/skillopt_sleep/replay.py b/skillopt_sleep/replay.py index e15f3dfe..62404962 100644 --- a/skillopt_sleep/replay.py +++ b/skillopt_sleep/replay.py @@ -4,8 +4,9 @@ them, producing the (hard, soft) signal SkillOpt's gate consumes. Single-shot text replay by default. Tasks whose rule judge requires a tool -call (gbrain's `tool_called`) are run through the backend's real tool loop -(attempt_with_tools), so tool use is verified honestly rather than self-reported. +call (gbrain's `tool_called`) use the backend's tool-aware path. Backends that +cannot enforce that execution boundary fail explicitly rather than scoring a +self-reported tool call. """ from __future__ import annotations @@ -143,4 +144,3 @@ def multi_objective_reward( if total_w <= 0: return acc return (w_acc * acc + w_tokens * tok_score + w_latency * lat_score) / total_w - diff --git a/skillopt_sleep/types.py b/skillopt_sleep/types.py index 6cfa6239..d25e3f4c 100644 --- a/skillopt_sleep/types.py +++ b/skillopt_sleep/types.py @@ -17,8 +17,8 @@ class SessionDigest: """A normalized summary of one local agent session transcript. - Produced by source-specific harvesters from Claude Code transcripts or - Codex Desktop archived sessions. + Produced by source-specific harvesters from Claude Code transcripts, Codex + Desktop archived sessions, or Cursor Agent transcripts. """ session_id: str diff --git a/tests/test_plugin_sync.py b/tests/test_plugin_sync.py index f7850e26..6df09e1d 100644 --- a/tests/test_plugin_sync.py +++ b/tests/test_plugin_sync.py @@ -2,6 +2,7 @@ Run: python3 -m pytest tests/test_plugin_sync.py -v """ +import json import os import unittest @@ -10,6 +11,7 @@ PLUGIN_SKILL_MDS = { "claude-code": os.path.join(REPO, "plugins/claude-code/skills/skillopt-sleep/SKILL.md"), "codex": os.path.join(REPO, "plugins/codex/skills/skillopt-sleep/SKILL.md"), + "cursor": os.path.join(REPO, "plugins/cursor/skills/skillopt-sleep/SKILL.md"), "openclaw": os.path.join(REPO, "plugins/openclaw/SKILL.md"), } @@ -17,6 +19,14 @@ COPILOT_INSTRUCTIONS = os.path.join(REPO, "plugins/copilot/copilot-instructions.snippet.md") CANONICAL_BACKENDS = {"mock", "claude", "codex", "copilot"} +CURSOR_MANIFEST = os.path.join(REPO, "plugins/cursor/.cursor-plugin/plugin.json") +CURSOR_MARKETPLACE = os.path.join(REPO, ".cursor-plugin/marketplace.json") +CURSOR_COMMAND = os.path.join(REPO, "plugins/cursor/commands/skillopt-sleep.md") +CURSOR_README = os.path.join(REPO, "plugins/cursor/README.md") +CURSOR_INSTALL_SH = os.path.join(REPO, "plugins/cursor/install.sh") +CURSOR_INSTALL_PS1 = os.path.join(REPO, "plugins/cursor/install.ps1") +CURSOR_LICENSE = os.path.join(REPO, "plugins/cursor/LICENSE") +OPENCLAW_RUNNER = os.path.join(REPO, "plugins/openclaw/run_sleep.py") def _read(path): @@ -27,6 +37,82 @@ def _read(path): class TestPluginParity(unittest.TestCase): + def test_cursor_plugin_manifest_and_marketplace_registration(self): + with open(CURSOR_MANIFEST, encoding="utf-8") as f: + manifest = json.load(f) + with open(CURSOR_MARKETPLACE, encoding="utf-8") as f: + marketplace = json.load(f) + + self.assertEqual(manifest["name"], "skillopt-sleep") + self.assertEqual(manifest["skills"], "./skills/") + self.assertEqual(manifest["commands"], "./commands/") + self.assertNotIn("hooks", manifest) + self.assertNotIn("mcpServers", manifest) + allowed_manifest_keys = { + "name", "displayName", "description", "version", "author", + "publisher", "homepage", "repository", "license", "logo", + "keywords", "category", "tags", "commands", "agents", "skills", + "rules", "hooks", "mcpServers", + } + self.assertEqual(set(manifest) - allowed_manifest_keys, set()) + registered = next( + (plugin for plugin in marketplace["plugins"] if plugin.get("name") == "skillopt-sleep"), + None, + ) + self.assertIsNotNone(registered) + self.assertEqual(registered["source"], "plugins/cursor") + self.assertEqual(registered["name"], manifest["name"]) + self.assertTrue(os.path.isdir(os.path.join(REPO, registered["source"]))) + + def test_cursor_skill_has_frontmatter_target_and_cursor_guidance(self): + text = _read(PLUGIN_SKILL_MDS["cursor"]) + self.assertTrue(text.startswith("---\n")) + self.assertIn("name: skillopt-sleep", text) + self.assertIn(".cursor/skills/skillopt-sleep-learned/SKILL.md", text) + self.assertIn("--source cursor", text) + self.assertIn("--backend cursor", text) + + def test_cursor_command_is_thin_and_preserves_safety_defaults(self): + text = _read(CURSOR_COMMAND) + self.assertIn("$ARGUMENTS", text) + self.assertIn("use `status`", text) + self.assertIn("--source cursor", text) + self.assertIn("--scope invoked", text) + self.assertIn(".cursor/skills/skillopt-sleep-learned/SKILL.md", text) + self.assertIn("`mock` backend", text) + self.assertNotIn("--auto-adopt", text) + + def test_cursor_installers_package_command_skill_readme_and_license(self): + for installer in (CURSOR_INSTALL_SH, CURSOR_INSTALL_PS1): + text = _read(installer) + for filename in ( + "plugin.json", + "commands/skillopt-sleep.md" if installer.endswith(".sh") else "commands\\skillopt-sleep.md", + "skills/skillopt-sleep/SKILL.md" if installer.endswith(".sh") else "skills\\skillopt-sleep\\SKILL.md", + "README.md", + "LICENSE", + ): + self.assertIn(filename, text, f"{installer} does not package {filename}") + + self.assertEqual(_read(CURSOR_LICENSE), _read(os.path.join(REPO, "LICENSE"))) + + def test_cursor_docs_keep_scheduling_explicit_and_target_relative(self): + for path in (CURSOR_README, PLUGIN_SKILL_MDS["cursor"]): + text = _read(path) + self.assertIn('"target_skill_path": ".cursor/skills/', text) + self.assertIn("no session-end hook", text.lower()) + self.assertIn("`tool_called`", text) + self.assertIn("temporarily disabled", text.lower()) + self.assertIn("before agent mode", text.lower()) + + def test_openclaw_wrapper_matches_shared_backend_signature(self): + text = _read(OPENCLAW_RUNNER) + self.assertIn('cursor_path=""', text) + self.assertIn('project_dir=""', text) + self.assertIn("cursor_path=cursor_path", text) + self.assertIn("project_dir=project_dir", text) + self.assertNotIn("**kwargs", text) + def test_all_skill_mds_mention_all_backends(self): for name, path in PLUGIN_SKILL_MDS.items(): text = _read(path) diff --git a/tests/test_sleep_engine.py b/tests/test_sleep_engine.py index 36cb88b9..0ddc6ec2 100644 --- a/tests/test_sleep_engine.py +++ b/tests/test_sleep_engine.py @@ -136,6 +136,355 @@ def test_digest_codex_archived_session_sanitizes_and_skips_meta(self): self.assertNotIn("raw args should not copy", joined) self.assertNotIn("raw output should not copy", joined) + def test_digest_cursor_transcript_redacts_and_keeps_only_message_text_and_tool_names(self): + from skillopt_sleep.harvest_cursor import digest_cursor_transcript + + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "cursor-session.jsonl") + self._write_jsonl(path, [ + { + "role": "user", + "message": { + "content": [{ + "type": "text", + "text": ( + "never-copy-attachment-metadata\n" + "\n" + "Deploy with sk-1234567890abcdef and token=local-secret\n" + "" + ), + }], + }, + }, + { + "role": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "shell.execute", + "input": {"token": "never-copy-tool-arguments"}, + }], + }, + }, + { + "role": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Deployment finished."}, + { + "type": "tool_use", + "name": "read_file", + "input": {"path": "never-copy-tool-arguments"}, + }, + ], + }, + }, + {"type": "tool_result", "output": "never-copy-tool-output"}, + {"type": "turn_ended", "status": "error"}, + ]) + with open(path, "a", encoding="utf-8") as f: + f.write("null\n") + f.write("[]\n") + f.write('"non-object"\n') + f.write("{malformed jsonl record\\n") + + digest = digest_cursor_transcript(path, project="/repo/Cursor Project") + + self.assertIsNotNone(digest) + joined = "\n".join(digest.user_prompts + digest.assistant_finals) + self.assertEqual(digest.project, "/repo/Cursor Project") + self.assertEqual(len(digest.user_prompts), 1) + self.assertIn("[REDACTED_OPENAI_KEY]", joined) + self.assertIn("token=[REDACTED]", joined) + self.assertEqual(digest.tools_used, ["shell.execute", "read_file"]) + self.assertIn("neg:cursor_turn_error", digest.feedback_signals) + self.assertNotIn("never-copy-tool-arguments", joined) + self.assertNotIn("never-copy-tool-output", joined) + self.assertNotIn("never-copy-attachment-metadata", joined) + + def test_harvest_cursor_scopes_orders_filters_mtime_and_skips_replays(self): + from skillopt_sleep.__main__ import _cfg_from_args + from skillopt_sleep.harvest_cursor import ( + CURSOR_REPLAY_SENTINEL, + cursor_project_slug, + harvest_cursor, + ) + from skillopt_sleep.harvest_sources import harvest_for_config + + def write_cursor_session(cursor_home, project, session_id, prompt, mtime, extra_prompt=""): + project_dir = os.path.join( + cursor_home, + "projects", + cursor_project_slug(project), + ) + path = os.path.join( + project_dir, + "agent-transcripts", + session_id, + f"{session_id}.jsonl", + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(os.path.join(project_dir, ".workspace-trusted"), "w", encoding="utf-8") as f: + json.dump({"workspacePath": project}, f) + records = [ + { + "role": "user", + "message": { + "content": [{ + "type": "text", + "text": f"\n{prompt}\n", + }], + }, + }, + { + "role": "assistant", + "message": {"content": [{"type": "text", "text": "done"}]}, + }, + ] + if extra_prompt: + records.extend([ + { + "role": "user", + "message": { + "content": [{ + "type": "text", + "text": f"\n{extra_prompt}\n", + }], + }, + }, + { + "role": "assistant", + "message": {"content": [{"type": "text", "text": "done again"}]}, + }, + ]) + self._write_jsonl(path, records) + os.utime(path, (mtime, mtime)) + return path + + with tempfile.TemporaryDirectory() as tmp: + cursor_home = os.path.join(tmp, ".cursor") + project = os.path.join(tmp, "project with spaces") + other_project = os.path.join(tmp, "other project") + old_time = 1_700_000_000 + new_time = old_time + 3_600 + main_path = write_cursor_session( + cursor_home, + project, + "older", + "fix the first issue", + old_time, + ) + subagent_path = os.path.join(os.path.dirname(main_path), "subagents", "worker.jsonl") + os.makedirs(os.path.dirname(subagent_path), exist_ok=True) + self._write_jsonl(subagent_path, [ + {"role": "user", "message": {"content": "machine-generated subagent task"}}, + {"role": "assistant", "message": {"content": "subagent result"}}, + ]) + write_cursor_session(cursor_home, other_project, "newer", "fix the second issue", new_time) + write_cursor_session( + cursor_home, + other_project, + "generated-replay", + CURSOR_REPLAY_SENTINEL + "\n## CURRENT SKILL", + new_time + 1, + extra_prompt="continue the internal replay", + ) + + invoked = harvest_cursor( + os.path.join(cursor_home, "projects"), + scope="invoked", + invoked_project=os.path.join(project, "src", "package"), + ) + all_digests = harvest_cursor( + os.path.join(cursor_home, "projects"), + scope="all", + since_iso="2023-11-14T23:00:00Z", + limit=1, + ) + + Args = type("Args", (), { + "project": project, + "scope": "", + "backend": "cursor", + "model": "", + "codex_path": "", + "cursor_path": "", + "claude_home": "", + "codex_home": "", + "cursor_home": cursor_home, + "source": "cursor", + "lookback_hours": 0, + "edit_budget": 0, + "max_sessions": 0, + "max_tasks": 0, + "target_skill_path": "", + "preferences": "", + "progress": False, + "auto_adopt": False, + }) + cfg = _cfg_from_args(Args()) + configured = harvest_for_config(cfg) + + self.assertEqual([d.session_id for d in invoked], ["older"]) + self.assertEqual([d.session_id for d in all_digests], ["newer"]) + self.assertEqual(invoked[0].project, project) + self.assertEqual(all_digests[0].project, other_project) + self.assertEqual([d.session_id for d in configured], ["older"]) + self.assertEqual(cfg.get("transcript_source"), "cursor") + self.assertEqual(cfg.get("backend"), "cursor") + + def test_harvest_cursor_prefers_longest_workspace_and_falls_back_to_slug(self): + from skillopt_sleep.harvest_cursor import cursor_project_slug, harvest_cursor + + def write_session(projects_dir, storage_name, workspace, session_id): + project_dir = os.path.join(projects_dir, storage_name) + path = os.path.join(project_dir, "agent-transcripts", session_id, f"{session_id}.jsonl") + os.makedirs(os.path.dirname(path), exist_ok=True) + self._write_jsonl(path, [ + {"role": "user", "message": {"content": "please fix this project"}}, + {"role": "assistant", "message": {"content": "fixed"}}, + ]) + if workspace is not None: + with open(os.path.join(project_dir, ".workspace-trusted"), "w", encoding="utf-8") as f: + json.dump(workspace, f) + return path + + with tempfile.TemporaryDirectory() as tmp: + projects_dir = os.path.join(tmp, ".cursor", "projects") + parent = os.path.join(tmp, "repo") + nested = os.path.join(parent, "packages", "app") + write_session(projects_dir, "parent-store", {"workspacePath": parent}, "parent") + write_session(projects_dir, "nested-store", {"workspacePath": nested}, "nested") + fallback = os.path.join(tmp, "fallback") + write_session( + projects_dir, + cursor_project_slug(fallback), + ["invalid metadata shape"], + "fallback", + ) + metadata_free = os.path.join(tmp, "metadata-free") + write_session( + projects_dir, + cursor_project_slug(metadata_free), + None, + "metadata-free", + ) + mixed_parent = os.path.join(tmp, "mixed-parent") + mixed_nested = os.path.join(mixed_parent, "nested") + write_session(projects_dir, "mixed-parent-store", {"workspacePath": mixed_parent}, "mixed-parent") + write_session( + projects_dir, + cursor_project_slug(mixed_nested), + None, + "mixed-nested", + ) + + nested_digests = harvest_cursor( + projects_dir, + scope="invoked", + invoked_project=os.path.join(nested, "src"), + ) + fallback_digests = harvest_cursor( + projects_dir, + scope="invoked", + invoked_project=fallback, + ) + metadata_free_digests = harvest_cursor( + projects_dir, + scope="invoked", + invoked_project=os.path.join(metadata_free, "packages", "app"), + ) + mixed_digests = harvest_cursor( + projects_dir, + scope="invoked", + invoked_project=mixed_nested, + ) + + self.assertEqual([digest.session_id for digest in nested_digests], ["nested"]) + self.assertEqual(nested_digests[0].project, nested) + self.assertEqual([digest.session_id for digest in fallback_digests], ["fallback"]) + self.assertEqual(fallback_digests[0].project, fallback) + self.assertEqual( + [digest.session_id for digest in metadata_free_digests], + ["metadata-free"], + ) + self.assertEqual(metadata_free_digests[0].project, metadata_free) + self.assertEqual([digest.session_id for digest in mixed_digests], ["mixed-nested"]) + self.assertEqual(mixed_digests[0].project, mixed_nested) + + def test_harvest_cursor_uses_numeric_mtime_for_aware_and_local_cutoffs(self): + from datetime import datetime, timedelta, timezone + + from skillopt_sleep.harvest_cursor import cursor_project_slug, harvest_cursor + + with tempfile.TemporaryDirectory() as tmp: + project = os.path.join(tmp, "project") + project_dir = os.path.join(tmp, ".cursor", "projects", cursor_project_slug(project)) + os.makedirs(project_dir) + with open(os.path.join(project_dir, ".workspace-trusted"), "w", encoding="utf-8") as f: + json.dump({"workspacePath": project}, f) + + cutoff = 1_700_000_000 + for session_id, modified in (("before", cutoff - 1), ("equal", cutoff), ("after", cutoff + 1)): + path = os.path.join( + project_dir, + "agent-transcripts", + session_id, + f"{session_id}.jsonl", + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + self._write_jsonl(path, [ + {"role": "user", "message": {"content": f"task {session_id}"}}, + {"role": "assistant", "message": {"content": "done"}}, + ]) + os.utime(path, (modified, modified)) + + aware = datetime.fromtimestamp(cutoff, timezone(timedelta(hours=5))).isoformat() + local = datetime.fromtimestamp(cutoff).replace(microsecond=0).isoformat() + aware_result = harvest_cursor(projects_dir=os.path.dirname(project_dir), since_iso=aware) + local_result = harvest_cursor(projects_dir=os.path.dirname(project_dir), since_iso=local) + + self.assertEqual([digest.session_id for digest in aware_result], ["after"]) + self.assertEqual([digest.session_id for digest in local_result], ["after"]) + + def test_harvest_cursor_filters_only_exact_internal_replay_sentinel(self): + from skillopt_sleep.harvest_cursor import CURSOR_REPLAY_SENTINEL, cursor_project_slug, harvest_cursor + + with tempfile.TemporaryDirectory() as tmp: + project = os.path.join(tmp, "project") + project_dir = os.path.join(tmp, ".cursor", "projects", cursor_project_slug(project)) + for session_id, prompt in ( + ("internal", CURSOR_REPLAY_SENTINEL + "\nrun replay"), + ("real", f"Please explain what {CURSOR_REPLAY_SENTINEL} means"), + ("grader", "You are a strict grader helping me review this response"), + ("skill", "Please explain the ## CURRENT SKILL section"), + ): + path = os.path.join(project_dir, "agent-transcripts", session_id, f"{session_id}.jsonl") + os.makedirs(os.path.dirname(path), exist_ok=True) + self._write_jsonl(path, [ + {"role": "user", "message": {"content": prompt}}, + {"role": "assistant", "message": {"content": "answer"}}, + ]) + + digests = harvest_cursor(os.path.join(tmp, ".cursor", "projects"), scope="all") + + self.assertEqual( + sorted(digest.session_id for digest in digests), + ["grader", "real", "skill"], + ) + + def test_auto_source_keeps_existing_codex_then_claude_precedence(self): + from skillopt_sleep.harvest_sources import harvest_for_config + + cfg = load_config(transcript_source="auto", 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_cursor") as cursor_harvest: + self.assertEqual(harvest_for_config(cfg), expected) + + cursor_harvest.assert_not_called() + def test_harvest_codex_filters_project_and_cli_source(self): from skillopt_sleep.__main__ import _cfg_from_args from skillopt_sleep.harvest_sources import harvest_for_config @@ -478,6 +827,18 @@ def test_mine_oversamples_before_target_filtering(self): "resolve a local Git conflict", }) + def test_cursor_miner_failure_is_not_swallowed(self): + from skillopt_sleep.backend import CursorBackendError + + def failed_miner(_digests): + raise CursorBackendError("Cursor Agent authentication failed") + + with self.assertRaises(CursorBackendError): + mine( + [self._digest(["configure an MCP server"], ["neg:failed"])], + llm_miner=failed_miner, + ) + class TestConsolidateGate(unittest.TestCase): def test_accepts_helpful_rejects_harmful(self): @@ -1113,6 +1474,384 @@ def test_attempt_with_tools_honest_detection(self): shutil.rmtree(stub_dir, ignore_errors=True) +class TestCursorBackend(unittest.TestCase): + """Pure-logic tests for CursorCliBackend without a Cursor login.""" + + def test_alias_and_environment_resolution(self): + from skillopt_sleep.backend import CursorCliBackend, get_backend, resolve_cursor_path + + for name in ("cursor", "cursor_agent", "cursor_cli"): + self.assertIsInstance(get_backend(name), CursorCliBackend, name) + with mock.patch.dict(os.environ, { + "SKILLOPT_SLEEP_CURSOR_PATH": "/tmp/cursor-agent", + "SKILLOPT_SLEEP_CURSOR_MODEL": "cursor-small", + }, clear=False): + self.assertEqual(resolve_cursor_path(), "/tmp/cursor-agent") + self.assertEqual(CursorCliBackend().model, "cursor-small") + + def test_cursor_path_overrides_expand_user_home(self): + from skillopt_sleep.__main__ import _cfg_from_args + from skillopt_sleep.backend import resolve_cursor_path + + Args = type("Args", (), { + "project": "", + "scope": "", + "backend": "", + "model": "", + "codex_path": "", + "cursor_path": "~/.local/bin/cursor-agent", + "claude_home": "", + "codex_home": "", + "cursor_home": "~/.cursor-custom", + "source": "", + "lookback_hours": None, + "edit_budget": 0, + "max_sessions": 0, + "max_tasks": 0, + "target_skill_path": "", + "preferences": "", + "progress": False, + "auto_adopt": False, + }) + + cfg = _cfg_from_args(Args()) + self.assertEqual( + cfg.get("cursor_path"), + os.path.abspath(os.path.expanduser("~/.local/bin/cursor-agent")), + ) + self.assertEqual( + cfg.cursor_projects_dir, + os.path.join(os.path.expanduser("~/.cursor-custom"), "projects"), + ) + + direct_cfg = load_config( + cursor_home="~/.cursor-config", + cursor_path="~/.cursor-config/bin/cursor-agent", + ) + self.assertEqual( + direct_cfg.cursor_projects_dir, + os.path.join(os.path.expanduser("~/.cursor-config"), "projects"), + ) + self.assertEqual( + resolve_cursor_path(direct_cfg.get("cursor_path")), + os.path.expanduser("~/.cursor-config/bin/cursor-agent"), + ) + with mock.patch.dict( + os.environ, + {"SKILLOPT_SLEEP_CURSOR_PATH": "~/.cursor-env/bin/cursor-agent"}, + clear=False, + ): + self.assertEqual( + resolve_cursor_path(), + os.path.expanduser("~/.cursor-env/bin/cursor-agent"), + ) + + def test_read_only_call_uses_stdin_ask_mode_and_terminal_result(self): + from skillopt_sleep.backend import CursorCliBackend + from skillopt_sleep.harvest_cursor import CURSOR_REPLAY_SENTINEL + + calls = [] + runtime_configs = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + config_dir = kwargs["env"]["CURSOR_CONFIG_DIR"] + data_dir = kwargs["env"]["CURSOR_DATA_DIR"] + with open(os.path.join(config_dir, "cli-config.json"), encoding="utf-8") as f: + runtime_configs.append(json.load(f)) + self.assertTrue(os.path.isdir(data_dir)) + + class Proc: + returncode = 0 + stdout = ( + '{"type":"message","result":"intermediate"}\n' + '{"type":"result","subtype":"success","is_error":false,' + '"result":"final answer"}\n' + ) + stderr = "" + + return Proc() + + backend = CursorCliBackend(cursor_path="cursor-agent-test", model="cursor-model") + with mock.patch("skillopt_sleep.backend.subprocess.run", side_effect=fake_run): + self.assertEqual(backend._call("solve this"), "final answer") + + cmd, kwargs = calls[0] + self.assertEqual(cmd[0], "cursor-agent-test") + self.assertIn("-p", cmd) + self.assertEqual(cmd[cmd.index("--output-format") + 1], "json") + self.assertEqual(cmd[cmd.index("--mode") + 1], "ask") + self.assertIn("--trust", cmd) + self.assertEqual(cmd[cmd.index("--workspace") + 1], kwargs["cwd"]) + self.assertTrue(os.path.basename(kwargs["cwd"]).startswith("skillopt_sleep_cursor_")) + self.assertNotIn("--force", cmd) + self.assertNotIn("--sandbox", cmd) + self.assertEqual(cmd[cmd.index("--model") + 1], "cursor-model") + self.assertTrue(kwargs["input"].startswith(CURSOR_REPLAY_SENTINEL + "\n\n")) + self.assertTrue(kwargs["input"].endswith("solve this")) + self.assertNotEqual(kwargs["env"]["CURSOR_CONFIG_DIR"], os.path.expanduser("~/.cursor")) + self.assertEqual(runtime_configs[0]["approvalMode"], "allowlist") + self.assertEqual(runtime_configs[0]["permissions"]["allow"], []) + self.assertEqual( + runtime_configs[0]["permissions"]["deny"], + ["Read(**)", "Write(**)", "Mcp(*:*)"], + ) + self.assertEqual(runtime_configs[0]["sandbox"]["mode"], "disabled") + self.assertFalse(os.path.exists(os.path.dirname(kwargs["env"]["CURSOR_CONFIG_DIR"]))) + self.assertEqual(backend.last_call_error, "") + self.assertEqual( + CursorCliBackend._parse_json_response('{"type":"message","result":"not terminal"}'), + "", + ) + + def test_nonzero_and_error_results_fail_once_with_redacted_diagnostics(self): + from skillopt_sleep.backend import CursorBackendError, CursorCliBackend + + backend = CursorCliBackend(cursor_path="cursor-agent-test", timeout=7) + + class BadProc: + returncode = 9 + stdout = "not-json" + stderr = "Authorization: Bearer cursor-secret-value" + + with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=BadProc()) as run: + with self.assertRaises(CursorBackendError): + backend._call("solve this") + self.assertEqual(run.call_count, 1) + self.assertIn("exited 9", backend.last_call_error) + self.assertIn("[REDACTED]", backend.last_call_error) + self.assertNotIn("cursor-secret-value", backend.last_call_error) + + class ErrorProc: + returncode = 0 + stdout = '{"type":"result","is_error":true,"result":"api_key=cursor-secret"}' + stderr = "" + + with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=ErrorProc()) as run: + with self.assertRaises(CursorBackendError): + backend._call("solve this") + self.assertEqual(run.call_count, 1) + self.assertIn("error result", backend.last_call_error) + self.assertIn("[REDACTED]", backend.last_call_error) + self.assertNotIn("cursor-secret", backend.last_call_error) + + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + side_effect=OSError("missing cursor-agent"), + ) as run: + with self.assertRaises(CursorBackendError): + backend._call("solve this") + self.assertEqual(run.call_count, 1) + self.assertIn("spawn failed", backend.last_call_error) + + def test_read_only_timeout_and_malformed_output_retry_once(self): + import subprocess + + from skillopt_sleep.backend import CursorBackendError, CursorCliBackend + + backend = CursorCliBackend(cursor_path="cursor-agent-test", timeout=7) + + class GoodProc: + returncode = 0 + stdout = '{"type":"result","is_error":false,"result":"recovered"}' + stderr = "" + + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + side_effect=[subprocess.TimeoutExpired(["cursor-agent-test"], 7), GoodProc()], + ) as run: + self.assertEqual(backend._call("solve this"), "recovered") + self.assertEqual(run.call_count, 2) + self.assertEqual(backend.last_call_error, "") + + class MalformedProc: + returncode = 0 + stdout = "still not json" + stderr = "" + + with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=MalformedProc()) as run: + with self.assertRaises(CursorBackendError): + backend._call("solve this") + self.assertEqual(run.call_count, 2) + self.assertIn("no usable JSON response", backend.last_call_error) + + class AuthProc: + returncode = 0 + stdout = "" + stderr = "Not authenticated. Please log in with token=cursor-secret" + + with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=AuthProc()) as run: + with self.assertRaises(CursorBackendError): + backend._call("solve this") + self.assertEqual(run.call_count, 1) + self.assertIn("authentication failed", backend.last_call_error) + self.assertNotIn("cursor-secret", backend.last_call_error) + + class ConfigProc: + returncode = 0 + stdout = "" + stderr = "Unsupported model: cursor-unknown" + + with mock.patch("skillopt_sleep.backend.subprocess.run", return_value=ConfigProc()) as run: + with self.assertRaises(CursorBackendError): + backend._call("solve this") + self.assertEqual(run.call_count, 1) + self.assertIn("configuration failed", backend.last_call_error) + + def test_failed_cursor_call_is_not_cached(self): + from skillopt_sleep.backend import CursorBackendError, CursorCliBackend + + class BadProc: + returncode = 1 + stdout = "" + stderr = "not authenticated" + + class GoodProc: + returncode = 0 + stdout = '{"type":"result","is_error":false,"result":"answer"}' + stderr = "" + + backend = CursorCliBackend(cursor_path="cursor-agent-test") + task = TaskRecord(id="cache", project="/p", intent="answer this") + with mock.patch( + "skillopt_sleep.backend.subprocess.run", + side_effect=[BadProc(), GoodProc()], + ) as run: + with self.assertRaises(CursorBackendError): + backend.attempt(task, skill="", memory="") + self.assertEqual(backend.attempt(task, skill="", memory=""), "answer") + self.assertEqual(backend.attempt(task, skill="", memory=""), "answer") + + self.assertEqual(run.call_count, 2) + + def test_tool_aware_replay_fails_before_cursor_subprocess(self): + from skillopt_sleep.backend import CursorBackendError, CursorCliBackend + + backend = CursorCliBackend(cursor_path="cursor-agent-test") + task = TaskRecord(id="cursor-tools", project="/p", intent="search") + with mock.patch("skillopt_sleep.backend.subprocess.run") as run: + with self.assertRaisesRegex( + CursorBackendError, + "Cursor tool-aware replay is temporarily disabled", + ): + backend.attempt_with_tools(task, skill="", memory="", tools=["search"]) + run.assert_not_called() + self.assertIn("temporarily disabled", backend.last_call_error) + self.assertEqual(backend._cache, {}) + + def test_tool_aware_cli_run_fails_without_writes_or_checkpoint(self): + import contextlib + import io + + from skillopt_sleep.__main__ import main + from skillopt_sleep.backend import CursorCliBackend + from skillopt_sleep.tasks_file import make_tasks_payload, write_tasks_file + + with tempfile.TemporaryDirectory() as tmp: + project = os.path.join(tmp, "project") + claude_home = os.path.join(tmp, ".claude") + target = os.path.join( + project, + ".cursor", + "skills", + "skillopt-sleep-learned", + "SKILL.md", + ) + os.makedirs(project) + task = TaskRecord( + id="cursor-tool-task", + project=project, + intent="Search before answering", + reference_kind="rule", + judge={"checks": [{"op": "tool_called", "arg": "search"}]}, + split="val", + ) + payload = make_tasks_payload( + [task], + project=project, + transcript_source="cursor", + target_skill_path=target, + ) + payload["reviewed"] = True + tasks_path = write_tasks_file(os.path.join(tmp, "tasks.json"), payload) + backend = CursorCliBackend(cursor_path="cursor-agent-test") + stderr = io.StringIO() + + with mock.patch( + "skillopt_sleep.cycle.get_backend", + return_value=backend, + ): + with mock.patch("skillopt_sleep.backend.subprocess.run") as run: + with contextlib.redirect_stderr(stderr): + rc = main([ + "run", + "--project", project, + "--claude-home", claude_home, + "--backend", "cursor", + "--tasks-file", tasks_path, + "--target-skill-path", target, + "--auto-adopt", + ]) + + self.assertEqual(rc, 1) + self.assertIn("Cursor tool-aware replay is temporarily disabled", stderr.getvalue()) + run.assert_not_called() + self.assertEqual(backend._cache, {}) + self.assertFalse(os.path.exists(os.path.join(tmp, ".skillopt-sleep"))) + self.assertFalse(os.path.exists(os.path.join(project, ".skillopt-sleep"))) + self.assertFalse(os.path.exists(target)) + + def test_cursor_failure_aborts_without_state_or_staging_and_cli_returns_nonzero(self): + import contextlib + import io + + from skillopt_sleep.__main__ import main + from skillopt_sleep.backend import CursorBackendError, CursorCliBackend + + with tempfile.TemporaryDirectory() as tmp: + project = os.path.join(tmp, "project") + os.makedirs(project) + cfg = load_config( + backend="cursor", + invoked_project=project, + projects="invoked", + claude_home=os.path.join(tmp, ".claude"), + target_skill_path=".cursor/skills/skillopt-sleep-learned/SKILL.md", + ) + backend = CursorCliBackend(cursor_path="cursor-agent-test") + task = TaskRecord( + id="failure", + project=project, + intent="answer this", + reference_kind="exact", + reference="answer", + split="val", + ) + with mock.patch.object( + backend, + "_call", + side_effect=CursorBackendError("Cursor Agent exited 1: token [REDACTED]"), + ): + with self.assertRaises(CursorBackendError): + run_sleep_cycle(cfg, seed_tasks=[task], backend=backend) + + self.assertFalse(os.path.exists(cfg.state_path)) + self.assertFalse(os.path.exists(os.path.join(project, ".skillopt-sleep"))) + self.assertFalse(os.path.exists(cfg.managed_skill_path())) + + stderr = io.StringIO() + with mock.patch( + "skillopt_sleep.__main__.run_sleep_cycle", + side_effect=CursorBackendError("Cursor Agent exited 1: token=cursor-secret"), + ), contextlib.redirect_stderr(stderr): + rc = main(["dry-run", "--project", project, "--backend", "cursor"]) + + self.assertEqual(rc, 1) + self.assertIn("Cursor backend failed", stderr.getvalue()) + self.assertIn("[REDACTED]", stderr.getvalue()) + self.assertNotIn("cursor-secret", stderr.getvalue()) + + class TestClaudeCliBackendBare(unittest.TestCase): """Issue #68: --bare must be conditional on ANTHROPIC_API_KEY."""