Merge pull request #159 from jamesmdavies88/feat/cursor-skillopt-sleep

Add native Cursor support to SkillOpt-Sleep
This commit is contained in:
Yifan Yang
2026-07-22 01:06:16 +08:00
committed by GitHub
26 changed files with 2328 additions and 42 deletions

View File

@@ -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."
}
]
}

View File

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

View File

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

View File

@@ -295,9 +295,10 @@ python -m pip install -e ".[dev]" # tests and linting</code></pre>
<strong>Release boundary</strong>
This guide tracks <code>main</code>. PyPI currently serves 0.2.0; the
generic research <code>openai_compatible</code> backend, Sleep handoff,
SkillOpt-Sleep support for non-Azure OpenAI-compatible endpoints, and the
Sleep <code>--preferences</code> flag require a source install from
<code>main</code> until the next release.
SkillOpt-Sleep support for non-Azure OpenAI-compatible endpoints, the
Sleep <code>--preferences</code> flag, and Cursor source/backend/plugin
support require a source install from <code>main</code> until the next
release.
</div>
<p>See the <a href="https://github.com/microsoft/SkillOpt/blob/main/docs/guide/installation.md">installation guide</a>
for platform notes and dependency boundaries.</p>
@@ -451,8 +452,25 @@ skillopt-sleep adopt --project "$PWD"</code></pre>
<p><code>--project</code> scopes collection but does not automatically
choose a project's skill file. Use <code>--target-skill-path</code> when
you intend to evolve a particular <code>SKILL.md</code>. Transcript source
(<code>claude</code>, <code>codex</code>, or <code>auto</code>) and replay
backend are independent settings.</p>
(<code>claude</code>, <code>codex</code>, <code>cursor</code>, or
<code>auto</code>) and replay backend are independent settings. The existing
<code>auto</code> precedence remains Codex then Claude; select Cursor
explicitly with <code>--source cursor</code>.</p>
<p>Cursor transcripts default to
<code>~/.cursor/projects/&lt;workspace&gt;/agent-transcripts</code>; override
that home with <code>--cursor-home</code>. Model-driven replay requires an
installed, authenticated <code>cursor-agent</code> and
<code>--backend cursor</code>; use <code>--cursor-path</code> when the CLI is
not on <code>PATH</code>. Target
<code>.cursor/skills/skillopt-sleep-learned/SKILL.md</code> explicitly so
adoption updates a project skill rather than the plugin's workflow skill.</p>
<p>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
<code>--project</code>. Cursor tasks containing a <code>tool_called</code>
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.</p>
<p>For subscription-based workflows that should not launch an API or model
subprocess, use <code>--backend handoff</code> and follow the generated
prompt/answer loop. Read the
@@ -468,6 +486,7 @@ skillopt-sleep adopt --project "$PWD"</code></pre>
<tbody>
<tr><td>Claude Code</td><td>Shared-engine plugin and handoff command</td><td><a href="https://github.com/microsoft/SkillOpt/blob/main/plugins/claude-code/README.md">README</a></td></tr>
<tr><td>Codex</td><td>Shared-engine skill shell</td><td><a href="https://github.com/microsoft/SkillOpt/blob/main/plugins/codex/README.md">README</a></td></tr>
<tr><td>Cursor</td><td>Native command and skill, local transcript source, and Cursor Agent backend</td><td><a href="https://github.com/microsoft/SkillOpt/blob/main/plugins/cursor/README.md">README</a></td></tr>
<tr><td>GitHub Copilot</td><td>Shared-engine Sleep MCP plus a separate research MCP</td><td><a href="https://github.com/microsoft/SkillOpt/blob/main/plugins/copilot/README.md">README</a></td></tr>
<tr><td>Devin</td><td>Shared-engine MCP with Devin transcript conversion</td><td><a href="https://github.com/microsoft/SkillOpt/blob/main/plugins/devin/README.md">README</a></td></tr>
<tr><td>OpenClaw</td><td>Independent community/reference adaptation; review locally before use</td><td><a href="https://github.com/microsoft/SkillOpt/blob/main/plugins/openclaw/README.md">README</a></td></tr>
@@ -491,6 +510,7 @@ skillopt-sleep adopt --project "$PWD"</code></pre>
<tr><td><code>dream_rollouts</code></td><td>1</td><td>Single rollout by default; values above 1 enable experimental contrastive replay.</td></tr>
<tr><td><code>dream_factor</code></td><td>0</td><td>Synthetic task variants are off by default.</td></tr>
<tr><td><code>recall_k</code></td><td>0</td><td>Historical associative recall is off by default.</td></tr>
<tr><td><code>replay_mode</code></td><td>mock</td><td>Reporting label for prompt replay; fresh-worktree replay is not implemented.</td></tr>
</tbody>
</table>
</div>
@@ -499,6 +519,13 @@ skillopt-sleep adopt --project "$PWD"</code></pre>
reward/budget controls as advanced features that require task-specific
validation. The reported experiments and their exact settings are in
<a href="https://github.com/microsoft/SkillOpt/blob/main/docs/sleep/RESULTS.md">RESULTS.md</a>.</p>
<p>The managed scheduler persists only project, backend, time, and optional
auto-adopt. For Cursor schedules, put <code>transcript_source</code>,
<code>cursor_home</code>, <code>cursor_path</code>, <code>model</code>, and
<code>target_skill_path</code> in
<code>~/.skillopt-sleep/config.json</code>. Use an absolute Cursor CLI path
and verify authentication for the scheduled account because schedulers may
run with a minimal environment.</p>
</section>
<section id="safety">
@@ -511,6 +538,13 @@ skillopt-sleep adopt --project "$PWD"</code></pre>
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.</li>
<li>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
<code>cursor-agent</code> to Cursor's selected model provider.</li>
<li>A real-backend <code>dry-run</code> still performs provider calls; it
suppresses staging rather than spend. Session and task limits are not hard
provider-call, token, time, or monetary budgets.</li>
<li>Updates are staged for review by default. Use
<code>--auto-adopt</code> only when you have an independent rollback and
validation process.</li>

View File

@@ -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/<workspace>/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).

View File

@@ -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/<workspace>/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.

View File

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

View File

@@ -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/"
}

21
plugins/cursor/LICENSE Normal file
View File

@@ -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.

246
plugins/cursor/README.md Normal file
View File

@@ -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/<workspace>/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
`<project>/.skillopt-sleep/staging/<timestamp>/`. 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.

View File

@@ -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 <current workspace> --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.

View File

@@ -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"

37
plugins/cursor/install.sh Executable file
View File

@@ -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 <<EOF
[install] Quit and reopen Cursor. The plugin should appear in Settings >
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

View File

@@ -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" <action> ...`
2. Source checkout on Windows:
`powershell -File "$env:SKILLOPT_SLEEP_REPO\plugins\run-sleep.ps1" <action> ...`
3. Installed engine on any platform:
`skillopt-sleep <action> ...`
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
`<project>/.skillopt-sleep/staging/<timestamp>/`.
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/<workspace>/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.

View File

@@ -35,10 +35,16 @@ _b._BACKENDS["openclaw-deepseek"] = OpenClawDeepSeekBackend
# 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

View File

@@ -13,8 +13,8 @@ Common flags:
--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 os
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

View File

@@ -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 @@ class CopilotCliBackend(CliBackend):
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)

View File

@@ -19,13 +19,15 @@ from typing import Any, Dict, Optional
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 @@ DEFAULTS: Dict[str, Any] = {
"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 @@ class SleepConfig:
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")

View File

@@ -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", "")

View File

@@ -0,0 +1,316 @@
"""Read Cursor Agent transcripts and normalize them into session digests.
Cursor writes workspace-scoped JSONL under
``~/.cursor/projects/<workspace>/agent-transcripts/<session>/<session>.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 = "<skillopt_sleep_internal_replay_v1>"
_CURSOR_USER_QUERY_RE = re.compile(
r"<user_query>\s*(.*?)\s*</user_query>\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

View File

@@ -5,6 +5,7 @@ from typing import Optional
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,

View File

@@ -20,6 +20,7 @@ import re
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:

View File

@@ -4,8 +4,9 @@ Re-run mined TaskRecords offline under a given (skill, memory) and score
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

View File

@@ -17,8 +17,8 @@ from typing import Any, Dict, List
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

View File

@@ -2,6 +2,7 @@
Run: python3 -m pytest tests/test_plugin_sync.py -v
"""
import json
import os
import unittest
@@ -10,6 +11,7 @@ REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
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 @@ MCP_SERVER = os.path.join(REPO, "plugins/copilot/mcp_server.py")
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)

View File

@@ -136,6 +136,355 @@ class TestHarvest(unittest.TestCase):
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": (
"<attached_files>never-copy-attachment-metadata</attached_files>\n"
"<user_query>\n"
"Deploy with sk-1234567890abcdef and token=local-secret\n"
"</user_query>"
),
}],
},
},
{
"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"<user_query>\n{prompt}\n</user_query>",
}],
},
},
{
"role": "assistant",
"message": {"content": [{"type": "text", "text": "done"}]},
},
]
if extra_prompt:
records.extend([
{
"role": "user",
"message": {
"content": [{
"type": "text",
"text": f"<user_query>\n{extra_prompt}\n</user_query>",
}],
},
},
{
"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 @@ Resolve local Git conflicts.
"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 @@ class TestCopilotBackend(unittest.TestCase):
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."""