mirror of
https://github.com/microsoft/SkillOpt.git
synced 2026-08-03 07:02:46 +08:00
Merge pull request #159 from jamesmdavies88/feat/cursor-skillopt-sleep
Add native Cursor support to SkillOpt-Sleep
This commit is contained in:
17
.cursor-plugin/marketplace.json
Normal file
17
.cursor-plugin/marketplace.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<workspace>/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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
30
plugins/cursor/.cursor-plugin/plugin.json
Normal file
30
plugins/cursor/.cursor-plugin/plugin.json
Normal 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
21
plugins/cursor/LICENSE
Normal 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
246
plugins/cursor/README.md
Normal 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.
|
||||
19
plugins/cursor/commands/skillopt-sleep.md
Normal file
19
plugins/cursor/commands/skillopt-sleep.md
Normal 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.
|
||||
39
plugins/cursor/install.ps1
Normal file
39
plugins/cursor/install.ps1
Normal 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
37
plugins/cursor/install.sh
Executable 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
|
||||
218
plugins/cursor/skills/skillopt-sleep/SKILL.md
Normal file
218
plugins/cursor/skills/skillopt-sleep/SKILL.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
316
skillopt_sleep/harvest_cursor.py
Normal file
316
skillopt_sleep/harvest_cursor.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user