pi-cursor-sdk 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +68 -14
- package/docs/cursor-live-smoke-checklist.md +271 -0
- package/docs/cursor-model-ux-spec.md +27 -4
- package/docs/cursor-native-tool-replay.md +99 -0
- package/docs/cursor-native-tool-visual-audit.md +183 -0
- package/package.json +6 -2
- package/src/context.ts +214 -16
- package/src/cursor-bridge-contract.ts +27 -0
- package/src/cursor-live-run-accounting.ts +65 -0
- package/src/cursor-mcp-timeout-override.ts +111 -0
- package/src/cursor-native-tool-display.ts +409 -49
- package/src/cursor-pi-tool-bridge.ts +1174 -0
- package/src/cursor-provider.ts +614 -146
- package/src/cursor-question-tool.ts +252 -0
- package/src/cursor-session-agent.ts +372 -0
- package/src/cursor-session-cwd.ts +28 -0
- package/src/cursor-session-scope.ts +65 -0
- package/src/cursor-state.ts +38 -10
- package/src/cursor-tool-names.ts +67 -0
- package/src/cursor-tool-transcript.ts +730 -61
- package/src/cursor-usage-accounting.ts +71 -0
- package/src/index.ts +27 -3
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Cursor native tool replay
|
|
2
|
+
|
|
3
|
+
pi-cursor-sdk has two separate pi-facing paths plus Cursor's own local-agent tool surface:
|
|
4
|
+
|
|
5
|
+
1. **Local pi MCP bridge:** default-on for local Cursor agents. It exposes the current pi session's bridgeable active tools to Cursor through a tokenized `127.0.0.1` MCP endpoint, excluding internal Cursor replay activity names and, by default, overlapping built-in pi tools (`read`, `bash`, `write`, `edit`, `grep`, `find`, `ls`). When Cursor calls one of those MCP tools, pi executes the real pi tool through the normal pi tool path.
|
|
6
|
+
2. **Cursor native tool replay:** display-only. It renders completed Cursor SDK tool activity as pi-native-looking cards using recorded Cursor results.
|
|
7
|
+
|
|
8
|
+
This document is about replay. Replay is not execution and is not the local pi bridge.
|
|
9
|
+
|
|
10
|
+
## Live bridge vs replay
|
|
11
|
+
|
|
12
|
+
| Surface | Names Cursor can call | Names pi shows | IDs | Execution behavior |
|
|
13
|
+
| --- | --- | --- | --- | --- |
|
|
14
|
+
| Local pi MCP bridge | Live MCP names such as `pi__sem_reindex`, only when exposed in the current run | Real pi tool names such as `sem_reindex` | Bridge run and tool IDs begin with `cursor-pi-bridge-*` | Real pi execution through normal pi `toolCall` / `toolResult` flow |
|
|
15
|
+
| Cursor native tool replay | None; replay names are not callable tools | Native-compatible card names or neutral Cursor activity labels | Replay IDs begin with `cursor-replay-*` | Display-only recorded Cursor results; no re-run, file mutation, MCP call, or pi state mutation |
|
|
16
|
+
| Cursor-native host tools/settings/plugins/MCP | Cursor SDK local-agent tool names, as provided by Cursor | Only replay cards or transcript summaries when reported by the SDK | Cursor SDK-owned IDs | Neither pi bridge nor replay execution; owned by the Cursor SDK local agent path |
|
|
17
|
+
|
|
18
|
+
Replay labels, replay cards, and transcript tool names are display-only/context-only. Bridge MCP names are also not pi tool names: Cursor must call the exposed `pi__*` MCP name, while pi history and cards use the real pi tool name.
|
|
19
|
+
|
|
20
|
+
## Local pi bridge summary
|
|
21
|
+
|
|
22
|
+
The bridge is enabled by default when bridgeable active pi tools exist. Cursor sees bridge-owned MCP names such as `pi__sem_reindex`, while pi history and tool cards use the real pi tool name such as `sem_reindex`. The bridge hides overlapping built-in pi tools by default because Cursor already has native equivalents; extension/custom tools and non-overlapping active tools present in pi's active tool registry normally remain exposed. pi-cursor-sdk also registers `cursor_ask_question` for Cursor models when the bridge is enabled, exposed to Cursor as `pi__cursor_ask_question`, so Cursor can ask the user to choose instead of silently defaulting when the pi UI is available. The bridge does not call pi tool `execute()` handlers directly; it queues the request, emits a real pi `toolCall`, waits for the matching pi `toolResult`, and resolves the Cursor MCP call back into the same live Cursor SDK run without creating a new `Agent`, unless the run was disposed, aborted, or cancelled.
|
|
23
|
+
|
|
24
|
+
Rollback, timeout, and diagnostics controls:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
PI_CURSOR_PI_TOOL_BRIDGE=0 pi --model cursor/composer-2.5
|
|
28
|
+
PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1 pi --model cursor/composer-2.5
|
|
29
|
+
PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS=7200 pi --model cursor/composer-2.5
|
|
30
|
+
PI_CURSOR_MCP_TOOL_TIMEOUT_MS=7200000 pi --model cursor/composer-2.5
|
|
31
|
+
PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1 pi --model cursor/composer-2.5
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`PI_CURSOR_PI_TOOL_BRIDGE=0` disables the bridge, including `pi__cursor_ask_question`. `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1` opts in to exposing overlapping pi tool names that Cursor already has native equivalents for (`read`, `bash`, `write`, `edit`, `grep`, `find`, and `ls`). By default those names are hidden even when pi's Cursor replay wrapper has registered them as extension tools; non-overlapping active built-ins remain bridgeable by default. `PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1` emits typed, allowlisted, scrubbed single-line JSONL bridge diagnostics to `process.stderr` with prefix `[pi-cursor-sdk:bridge]`; it is off by default, uses run-safe IDs that are not reused in endpoint paths, and does not print endpoint URLs/path components/tokens, raw args/results, file contents, or secrets. Cursor-native tools, Cursor settings, plugins, and configured Cursor MCP servers still come from the Cursor SDK local agent path. Cloud Cursor agents are out of scope for this bridge.
|
|
35
|
+
|
|
36
|
+
## What gets replayed
|
|
37
|
+
|
|
38
|
+
When Cursor reports completed tool activity, the extension can display recorded results for:
|
|
39
|
+
|
|
40
|
+
- `read`
|
|
41
|
+
- `bash`
|
|
42
|
+
- `grep`
|
|
43
|
+
- `find`
|
|
44
|
+
- `ls`
|
|
45
|
+
- `edit`
|
|
46
|
+
- `write`
|
|
47
|
+
- diagnostics
|
|
48
|
+
- delete
|
|
49
|
+
- todos and plans
|
|
50
|
+
- tasks
|
|
51
|
+
- image generation
|
|
52
|
+
- MCP activity
|
|
53
|
+
|
|
54
|
+
Cursor `glob` activity is displayed through native `find` cards.
|
|
55
|
+
|
|
56
|
+
Edit and write activity replays through pi-facing `edit` and `write` cards only when replay arguments truthfully satisfy the matching pi schema, but still uses recorded Cursor results only. The adapter passes through truthful Cursor paths, content when Cursor reported it, and recorded diff/details; it does not pretend Cursor's editing schema is pi's schema and it fails closed if a recorded replay result is missing. Cursor `StrReplace` with recorded replacement text displays as native-looking `edit`; path-only Cursor `edit` and notebook edit activity fall back to neutral Cursor activity so pi does not reject the replay before recorded-result handling. Cursor `write` displays as native-looking `write`. Diagnostics, delete, todos/plans, task, image, and MCP activity use neutral Cursor activity cards with pi's default success/error tool shell. Neutral Cursor activity cards carry display metadata such as `activityTitle` and `activitySummary`, so partial/collapsed cards can say `Cursor plan`, `Cursor todos`, `Cursor MCP`, or `Cursor edit` instead of only `Cursor activity`. These replay tools only display recorded Cursor results; they never mutate files or execute tool work directly. Replay paths are normalized to workspace-relative paths when possible. Collapsed replay cards include bounded previews for diffs and text details so small edits, todos, task output, and MCP results are visible without expanding; edit previews omit raw unified diff headers and show compact numbered changed/context lines using pi's native diff added/removed/context colors, and write previews use syntax highlighting when pi can infer a language from the path. Image generation replay cards show the saved image path in the collapsed summary and render the image inline when pi terminal image display is enabled and the generated file is still readable.
|
|
57
|
+
|
|
58
|
+
## What replay does not do
|
|
59
|
+
|
|
60
|
+
Native replay is display-only:
|
|
61
|
+
|
|
62
|
+
- pi does not re-run Cursor-side commands.
|
|
63
|
+
- pi does not apply Cursor-side edits or deletes.
|
|
64
|
+
- pi does not call Cursor-side MCP servers.
|
|
65
|
+
- replay-only cards do not update pi state or generate images.
|
|
66
|
+
- replay does not expose pi tool schemas to Cursor; the local pi MCP bridge is the separate path that exposes active pi tools.
|
|
67
|
+
- Cursor workflow tools such as `SwitchMode` and Cursor todo state are not pi workflow controls; reported todo/plan events are displayed as Cursor activity only. Plan/todo replay cards do not drive pi plan-mode state.
|
|
68
|
+
|
|
69
|
+
If a Cursor read completion reports no content, the extension may include a bounded local file preview for safe in-workspace paths. That preview is labeled as a local preview captured at transcript time, not guaranteed Cursor-observed content.
|
|
70
|
+
|
|
71
|
+
Other unsupported Cursor SDK tools may still be described through a bounded scrubbed activity transcript when the SDK reports completed tool-call data. Started Cursor SDK tool calls that never receive a completion event are discarded without a synthetic replay error; missing completion is not itself treated as a Cursor tool failure. Explicit failures remain visible when Cursor reports an error through a completed tool call or step result. Some Cursor-internal workflow actions may only appear in Cursor's own thinking stream or not be reported as replayable SDK tool completions.
|
|
72
|
+
|
|
73
|
+
## Ordering and non-interactive output
|
|
74
|
+
|
|
75
|
+
As Cursor SDK tool completions arrive, the extension mirrors native Codex ordering by ending a tool-use turn, letting pi render the recorded tool results, then continuing with live post-tool Cursor thinking/text, later Cursor tool batches, or Cursor's final answer as the next assistant turn. For plan-mode runs, neutral Cursor plan/todo cards can therefore appear before the final Cursor plan text.
|
|
76
|
+
|
|
77
|
+
Bridged pi tool calls follow the same visible pi `toolUse` turn shape, but they are real pi tool executions rather than replayed Cursor results. Split-run usage accounting keeps Cursor SDK internal counters out of pi usage: each live Cursor prompt is counted once, replay/bridge tool-call turns include visible assistant activity in output estimates, consumed tool results are counted once as input on the following assistant turn, and `usage.totalTokens` remains the replayable Cursor prompt/context estimate.
|
|
78
|
+
|
|
79
|
+
For shell replay, completed `stdout` / `stderr` remain the primary source. If a successful completed shell result is empty and Cursor emitted unambiguous `shell-output-delta` data while exactly one shell call was active, the replay card uses that delta as display-only fallback data. Overlapping shell calls make delta attribution ambiguous, so those fallback deltas are dropped rather than guessed. `(no output)` is kept only when no completed output or safe delta fallback is available.
|
|
80
|
+
|
|
81
|
+
Non-interactive and session consumers still receive bounded scrubbed transcript data so `pi -p` keeps printing normal assistant text.
|
|
82
|
+
|
|
83
|
+
## Synthetic-name policy
|
|
84
|
+
|
|
85
|
+
Synthetic replay names are internal compatibility details. New model-facing prompt text and user-visible cards use native tool names when renderer-compatible, or neutral Cursor activity labels when not. Legacy sessions that already contain old internal replay names are rewritten to safe labels in prompt text and display surfaces.
|
|
86
|
+
|
|
87
|
+
Bridge MCP names are also not pi tool names. Cursor may see names such as `pi__sem_reindex` inside the local MCP bridge, but pi session output uses the real pi tool name.
|
|
88
|
+
|
|
89
|
+
## Conflicts and opt out
|
|
90
|
+
|
|
91
|
+
Native replay wrappers are registered only for tool names not already owned by another extension. If another extension already owns a wrapper name needed for replay, pi-cursor-sdk skips only the conflicting wrapper and uses the scrubbed Cursor activity transcript for that tool instead. Legacy replay wrappers remain registered for old sessions, but their model-facing and user-visible labels are sanitized.
|
|
92
|
+
|
|
93
|
+
Disable native replay registration entirely:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
PI_CURSOR_NATIVE_TOOL_DISPLAY=0 pi --model cursor/composer-2.5
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`PI_CURSOR_REGISTER_NATIVE_TOOLS=0` is also accepted as a registration-only opt-out.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Cursor Native Tool Visual Audit Workflow
|
|
2
|
+
|
|
3
|
+
This workflow verifies Cursor SDK tool replay the way a human sees it in pi's interactive TUI, without stealing macOS focus.
|
|
4
|
+
|
|
5
|
+
Use it before accepting replay-card commits or PRs. Text logs and JSONL are necessary, but they are not enough when the claim is visual parity: always keep before/after PNGs for the exact prompt.
|
|
6
|
+
|
|
7
|
+
## When to use this
|
|
8
|
+
|
|
9
|
+
Use this workflow when changing or reviewing:
|
|
10
|
+
|
|
11
|
+
- Cursor native tool replay cards.
|
|
12
|
+
- Tool-call turn ordering.
|
|
13
|
+
- Tool-result error styling.
|
|
14
|
+
- Truncation, continuation hints, timeout labels, or path display.
|
|
15
|
+
- Any PR claiming native TUI parity.
|
|
16
|
+
|
|
17
|
+
Do not use this for ordinary unit-only logic changes.
|
|
18
|
+
|
|
19
|
+
## Why this workflow exists
|
|
20
|
+
|
|
21
|
+
Earlier manual verification used a visible Terminal window plus `screencapture`. That worked, but it stole system focus and made it easy for the user to type into the audit window by accident.
|
|
22
|
+
|
|
23
|
+
The preferred workflow is now offscreen:
|
|
24
|
+
|
|
25
|
+
1. Spawn `pi` in a pseudo-terminal at a fixed size.
|
|
26
|
+
2. Feed the prompt programmatically.
|
|
27
|
+
3. Save raw ANSI output and plain text output.
|
|
28
|
+
4. Render the terminal buffer through xterm.js in headless Playwright.
|
|
29
|
+
5. Save a PNG screenshot.
|
|
30
|
+
6. Inspect the session JSONL for exact persisted `toolCall` / `toolResult` data.
|
|
31
|
+
|
|
32
|
+
This gives human-like visual evidence without activating Terminal, iTerm, or a browser window.
|
|
33
|
+
|
|
34
|
+
## Tool stack
|
|
35
|
+
|
|
36
|
+
Install the harness outside this repo so generated assets and temporary dependencies do not pollute commits:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
HARNESS=/tmp/pi-visual-harness
|
|
40
|
+
rm -rf "$HARNESS"
|
|
41
|
+
mkdir -p "$HARNESS"
|
|
42
|
+
cd "$HARNESS"
|
|
43
|
+
npm init -y
|
|
44
|
+
npm install node-pty @xterm/xterm playwright
|
|
45
|
+
npm rebuild node-pty
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`npm rebuild node-pty` is useful after Node upgrades; without it, `node-pty` may fail with `posix_spawnp failed`.
|
|
49
|
+
|
|
50
|
+
## Runner contract
|
|
51
|
+
|
|
52
|
+
A runner script should:
|
|
53
|
+
|
|
54
|
+
- Spawn `pi -e <extension-dir> --model cursor/composer-2.5` with:
|
|
55
|
+
- `PI_CURSOR_NATIVE_TOOL_DISPLAY=1`
|
|
56
|
+
- `TERM=xterm-256color`
|
|
57
|
+
- fixed PTY size, for example `150x45`
|
|
58
|
+
- cwd set to the target audit repo.
|
|
59
|
+
- Wait for startup.
|
|
60
|
+
- Write the exact prompt and carriage return to the PTY.
|
|
61
|
+
- Wait a bounded amount of time.
|
|
62
|
+
- Save:
|
|
63
|
+
- `<label>.ansi` raw terminal bytes.
|
|
64
|
+
- `<label>.txt` stripped text for quick search.
|
|
65
|
+
- `<label>.png` rendered xterm screenshot.
|
|
66
|
+
- `<label>.jsonl.path` pointing to the latest pi session JSONL.
|
|
67
|
+
- Kill the PTY child after capture.
|
|
68
|
+
- Check for leftover commands when prompts can background work, especially shell timeout tests.
|
|
69
|
+
|
|
70
|
+
Example invocation shape:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
node /tmp/pi-visual-harness/run-pi-visual.mjs \
|
|
74
|
+
--label after-shell-nonzero \
|
|
75
|
+
--ext /path/to/pi-cursor-sdk \
|
|
76
|
+
--cwd /path/to/test-workspace \
|
|
77
|
+
--prompt "Run \`printf 'cursor-shell-stderr\\n' >&2; exit 7\` using only the shell/terminal tool. Do not use read, grep, glob, find, ls, edit, or write. Print the command result exactly, then stop." \
|
|
78
|
+
--wait-ms 30000 \
|
|
79
|
+
--out-dir /tmp/pi-visual-harness/review-current
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Keep the runner in `/tmp` unless the project explicitly decides to check in a maintained audit harness.
|
|
83
|
+
|
|
84
|
+
## Before/after comparison
|
|
85
|
+
|
|
86
|
+
Use a clean worktree for the baseline and the active worktree for the candidate change:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
BASE=/tmp/pi-cursor-visual-review
|
|
90
|
+
BEFORE_WT=$BASE/before-main
|
|
91
|
+
AFTER_WT=/path/to/pi-cursor-sdk
|
|
92
|
+
TARGET=/path/to/test-workspace
|
|
93
|
+
|
|
94
|
+
rm -rf "$BASE"
|
|
95
|
+
git fetch origin main
|
|
96
|
+
BASE_COMMIT=$(git merge-base origin/main HEAD)
|
|
97
|
+
git worktree add --detach "$BEFORE_WT" "$BASE_COMMIT"
|
|
98
|
+
|
|
99
|
+
# Optional speedup when the before worktree has no install of its own.
|
|
100
|
+
ln -s "$AFTER_WT/node_modules" "$BEFORE_WT/node_modules"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then run the same prompt against both extension dirs:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
node /tmp/pi-visual-harness/run-pi-visual.mjs \
|
|
107
|
+
--label before-glob-single \
|
|
108
|
+
--ext "$BEFORE_WT" \
|
|
109
|
+
--cwd "$TARGET" \
|
|
110
|
+
--prompt "Find files matching \`src/tools/reindex.ts\` using only the glob/file-search tool. Do not use shell, bash, grep, read, or ls. Print the matched files exactly as found, then stop." \
|
|
111
|
+
--wait-ms 16000 \
|
|
112
|
+
--out-dir /tmp/pi-visual-harness/review-current
|
|
113
|
+
|
|
114
|
+
node /tmp/pi-visual-harness/run-pi-visual.mjs \
|
|
115
|
+
--label after-glob-single \
|
|
116
|
+
--ext "$AFTER_WT" \
|
|
117
|
+
--cwd "$TARGET" \
|
|
118
|
+
--prompt "Find files matching \`src/tools/reindex.ts\` using only the glob/file-search tool. Do not use shell, bash, grep, read, or ls. Print the matched files exactly as found, then stop." \
|
|
119
|
+
--wait-ms 16000 \
|
|
120
|
+
--out-dir /tmp/pi-visual-harness/review-current
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
For review, create a simple HTML/PNG gallery that places `before-*.png` and `after-*.png` side by side. Keep the generated gallery in `/tmp` unless explicitly asked to commit visual artifacts.
|
|
124
|
+
|
|
125
|
+
## JSONL inspection
|
|
126
|
+
|
|
127
|
+
For each visual claim, inspect the JSONL path written by the runner. Confirm at least:
|
|
128
|
+
|
|
129
|
+
- `toolCall.name` is the expected pi-facing replay tool name.
|
|
130
|
+
- `toolCall.arguments` show the expected user-facing args.
|
|
131
|
+
- `toolResult.toolName` matches the call.
|
|
132
|
+
- `toolResult.content[0].text` contains the recorded body expected in the card.
|
|
133
|
+
- `toolResult.isError` matches the visual card state.
|
|
134
|
+
|
|
135
|
+
For local pi MCP bridge claims, also confirm:
|
|
136
|
+
|
|
137
|
+
- Bridged calls appear as the real pi tool name (for example `sem_reindex`), not the MCP bridge name (for example `pi__sem_reindex`; or `read`/`pi__read` when overlapping built-ins are explicitly exposed).
|
|
138
|
+
- The JSONL has no second Cursor MCP replay card for the same bridged call.
|
|
139
|
+
- Non-bridge Cursor MCP activity, if present, still renders as neutral Cursor activity instead of being suppressed.
|
|
140
|
+
|
|
141
|
+
Small helper pattern:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
python3 - <<'PY'
|
|
145
|
+
import json, pathlib
|
|
146
|
+
path = pathlib.Path('/tmp/pi-visual-harness/review-current/after-shell-nonzero.jsonl.path').read_text().strip()
|
|
147
|
+
for line in pathlib.Path(path).read_text().splitlines():
|
|
148
|
+
obj = json.loads(line)
|
|
149
|
+
msg = obj.get('message', {})
|
|
150
|
+
if msg.get('role') == 'assistant':
|
|
151
|
+
for part in msg.get('content', []):
|
|
152
|
+
if part.get('type') == 'toolCall':
|
|
153
|
+
print('CALL', part.get('name'), part.get('arguments'))
|
|
154
|
+
if msg.get('role') == 'toolResult':
|
|
155
|
+
text = msg.get('content', [{}])[0].get('text', '')
|
|
156
|
+
print('RESULT', msg.get('toolName'), 'isError=', msg.get('isError'), repr(text[:160]))
|
|
157
|
+
PY
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Safety rules
|
|
161
|
+
|
|
162
|
+
- Prefer the offscreen PTY renderer. Do not use `osascript`, visible Terminal windows, or `screencapture` unless a user explicitly asks for a real desktop screenshot.
|
|
163
|
+
- Keep generated screenshots, HTML galleries, ANSI logs, and temporary harness dependencies out of the repo by default.
|
|
164
|
+
- Use short, deterministic prompts with bounded wait times.
|
|
165
|
+
- For timeout/background prompts, always check for leftovers:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
ps -axo pid,etime,command | rg "sleep 2|should-not-print|<audit-session-label>" || true
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
- If the model uses a different tool than requested, record it as model/provider behavior unless JSONL shows replay lost or misrendered a completed Cursor tool event.
|
|
172
|
+
- Visual output can differ slightly from macOS Terminal fonts because xterm.js renders offscreen. Treat this workflow as evidence for card class, color state, labels, ordering, truncation, and content. Use a real terminal screenshot only for pixel-level terminal-specific bugs.
|
|
173
|
+
|
|
174
|
+
## Required evidence before commit or merge
|
|
175
|
+
|
|
176
|
+
Before accepting a replay-card change, provide:
|
|
177
|
+
|
|
178
|
+
- Before and after PNG paths.
|
|
179
|
+
- The prompt used for each pair.
|
|
180
|
+
- JSONL paths for each run.
|
|
181
|
+
- A short statement of what changed visually.
|
|
182
|
+
- The relevant JSONL `toolCall` / `toolResult` facts.
|
|
183
|
+
- `npm test` and `npm run typecheck` results, unless the change is documentation-only.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cursor-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "pi provider extension backed by @cursor/sdk local agents",
|
|
5
5
|
"author": "Mitch Fultz (https://github.com/fitchmultz)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"scripts/refresh-cursor-model-snapshots.mjs",
|
|
27
27
|
"README.md",
|
|
28
28
|
"docs/cursor-model-ux-spec.md",
|
|
29
|
+
"docs/cursor-live-smoke-checklist.md",
|
|
30
|
+
"docs/cursor-native-tool-replay.md",
|
|
31
|
+
"docs/cursor-native-tool-visual-audit.md",
|
|
29
32
|
"LICENSE",
|
|
30
33
|
"CHANGELOG.md"
|
|
31
34
|
],
|
|
@@ -40,7 +43,8 @@
|
|
|
40
43
|
"refresh:cursor-snapshots": "node scripts/refresh-cursor-model-snapshots.mjs"
|
|
41
44
|
},
|
|
42
45
|
"dependencies": {
|
|
43
|
-
"@cursor/sdk": "^1.0.13"
|
|
46
|
+
"@cursor/sdk": "^1.0.13",
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
44
48
|
},
|
|
45
49
|
"peerDependencies": {
|
|
46
50
|
"@earendil-works/pi-ai": "*",
|
package/src/context.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { Context, Message, ToolCall } from "@earendil-works/pi-ai";
|
|
3
|
+
import { convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
import type { SDKImage } from "@cursor/sdk";
|
|
5
|
+
import { getCursorPiBridgeContractText } from "./cursor-bridge-contract.js";
|
|
6
|
+
import { getCursorReplayPromptLabel } from "./cursor-tool-names.js";
|
|
3
7
|
|
|
4
8
|
export interface CursorPrompt {
|
|
5
9
|
text: string;
|
|
@@ -12,9 +16,14 @@ export interface CursorPromptOptions {
|
|
|
12
16
|
imageTokenEstimate?: number;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
const
|
|
19
|
+
export const CURSOR_APPROX_CHARS_PER_TOKEN = 4;
|
|
20
|
+
export const CURSOR_IMAGE_TOKEN_ESTIMATE = 1200;
|
|
16
21
|
const SECTION_SEPARATOR = "\n\n";
|
|
17
22
|
|
|
23
|
+
function normalizePiContextMessages(messages: Context["messages"]): Message[] {
|
|
24
|
+
return convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
function isTextBlock(block: { type: string }): block is { type: "text"; text: string } {
|
|
19
28
|
return block.type === "text";
|
|
20
29
|
}
|
|
@@ -58,8 +67,26 @@ function formatContentBlocks(content: string | { type: string; text?: string; da
|
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
function formatToolCall(toolCall: ToolCall): string {
|
|
61
|
-
const args = JSON.stringify(toolCall.arguments);
|
|
62
|
-
return `Tool call (${toolCall.name}, call ${toolCall.id}): ${args}`;
|
|
70
|
+
const args = JSON.stringify(toolCall.arguments) ?? "";
|
|
71
|
+
return `Tool call (${getCursorReplayPromptLabel(toolCall.name)}, call ${toolCall.id}): ${args}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sanitizeSystemPromptForCursor(systemPrompt: string): string {
|
|
75
|
+
let sanitized = systemPrompt;
|
|
76
|
+
sanitized = sanitized.replace(
|
|
77
|
+
/Available tools:\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n\n/g,
|
|
78
|
+
"Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.\n\n",
|
|
79
|
+
);
|
|
80
|
+
sanitized = sanitized.replace(
|
|
81
|
+
/Guidelines:\n[\s\S]*?\n\nPi documentation /g,
|
|
82
|
+
"Guidelines:\n- Be concise in your responses.\n- Show file paths clearly when working with files.\n\nPi documentation ",
|
|
83
|
+
);
|
|
84
|
+
sanitized = sanitized.replace(
|
|
85
|
+
/\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/g,
|
|
86
|
+
"",
|
|
87
|
+
);
|
|
88
|
+
sanitized = sanitized.replace(/\n+Semantic code intelligence priority:[\s\S]*$/g, "");
|
|
89
|
+
return sanitized.trim();
|
|
63
90
|
}
|
|
64
91
|
|
|
65
92
|
function formatMessage(msg: Message): string | undefined {
|
|
@@ -84,7 +111,7 @@ function formatMessage(msg: Message): string | undefined {
|
|
|
84
111
|
case "toolResult": {
|
|
85
112
|
const text = formatContentBlocks(msg.content);
|
|
86
113
|
const label = msg.isError ? "Tool error" : "Tool result";
|
|
87
|
-
return `${label} (${msg.toolName}, call ${msg.toolCallId}): ${text}`;
|
|
114
|
+
return `${label} (${getCursorReplayPromptLabel(msg.toolName)}, call ${msg.toolCallId}): ${text}`;
|
|
88
115
|
}
|
|
89
116
|
}
|
|
90
117
|
}
|
|
@@ -112,7 +139,7 @@ function applyPromptBudget(
|
|
|
112
139
|
return [...sectionsBeforeMessages, ...messageSections.map((section) => section.text), ...sectionsAfterMessages];
|
|
113
140
|
}
|
|
114
141
|
|
|
115
|
-
const charsPerToken = options.charsPerToken ??
|
|
142
|
+
const charsPerToken = options.charsPerToken ?? CURSOR_APPROX_CHARS_PER_TOKEN;
|
|
116
143
|
const maxChars = Math.max(1, Math.floor(maxInputTokens * charsPerToken));
|
|
117
144
|
const requiredMessageSections = messageSections.filter((section) => section.index === latestUserMessageIndex);
|
|
118
145
|
const requiredCost = [...sectionsBeforeMessages, ...requiredMessageSections.map((section) => section.text), ...sectionsAfterMessages].reduce(
|
|
@@ -148,22 +175,191 @@ function applyPromptBudget(
|
|
|
148
175
|
return [...sectionsBeforeMessages, ...budgetNotice, ...includedMessages, ...sectionsAfterMessages];
|
|
149
176
|
}
|
|
150
177
|
|
|
178
|
+
export function estimateCursorTextTokens(text: string, options: Pick<CursorPromptOptions, "charsPerToken"> = {}): number {
|
|
179
|
+
const charsPerToken = options.charsPerToken ?? CURSOR_APPROX_CHARS_PER_TOKEN;
|
|
180
|
+
return Math.ceil(text.length / charsPerToken);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function estimateCursorPromptTokens(prompt: CursorPrompt, options: Pick<CursorPromptOptions, "charsPerToken" | "imageTokenEstimate"> = {}): number {
|
|
184
|
+
return estimateCursorTextTokens(prompt.text, options) + prompt.images.length * (options.imageTokenEstimate ?? CURSOR_IMAGE_TOKEN_ESTIMATE);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function estimateCursorPromptMessageTokens(message: Message, options: Pick<CursorPromptOptions, "charsPerToken"> = {}): number {
|
|
188
|
+
const text = formatMessage(message);
|
|
189
|
+
return text ? estimateCursorTextTokens(text, options) : 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function estimateCursorContextTokens(context: Context, options: CursorPromptOptions = {}): number {
|
|
193
|
+
return estimateCursorPromptTokens(buildCursorPrompt(context, options), options);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface CursorContextFingerprintPayload {
|
|
197
|
+
systemHash: string;
|
|
198
|
+
messageHashes: string[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function hashCursorContextValue(value: string): string {
|
|
202
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function serializeMessageForFingerprint(message: Message, index: number): string {
|
|
206
|
+
switch (message.role) {
|
|
207
|
+
case "user": {
|
|
208
|
+
const text =
|
|
209
|
+
typeof message.content === "string"
|
|
210
|
+
? message.content
|
|
211
|
+
: JSON.stringify(message.content);
|
|
212
|
+
return hashCursorContextValue(`user:${message.timestamp ?? index}:${text}`);
|
|
213
|
+
}
|
|
214
|
+
case "assistant":
|
|
215
|
+
return hashCursorContextValue(`assistant:${message.timestamp ?? index}:${JSON.stringify(message.content)}`);
|
|
216
|
+
case "toolResult":
|
|
217
|
+
return hashCursorContextValue(
|
|
218
|
+
`toolResult:${message.timestamp ?? index}:${message.toolCallId}:${message.toolName}:${JSON.stringify(message.content)}:${message.isError === true}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function serializeRawPiMessageForFingerprint(message: Context["messages"][number], index: number): string {
|
|
224
|
+
const role = (message as { role?: string }).role;
|
|
225
|
+
switch (role) {
|
|
226
|
+
case "branchSummary": {
|
|
227
|
+
const entry = message as { summary?: string; fromId?: string; timestamp?: number };
|
|
228
|
+
return hashCursorContextValue(
|
|
229
|
+
`branchSummary:${entry.timestamp ?? index}:${entry.fromId ?? ""}:${entry.summary ?? ""}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
case "compactionSummary": {
|
|
233
|
+
const entry = message as { summary?: string; tokensBefore?: number; timestamp?: number };
|
|
234
|
+
return hashCursorContextValue(
|
|
235
|
+
`compactionSummary:${entry.timestamp ?? index}:${entry.tokensBefore ?? ""}:${entry.summary ?? ""}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
case "custom": {
|
|
239
|
+
const entry = message as { customType?: string; content?: unknown; timestamp?: number };
|
|
240
|
+
return hashCursorContextValue(
|
|
241
|
+
`custom:${entry.timestamp ?? index}:${entry.customType ?? ""}:${JSON.stringify(entry.content)}`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
case "bashExecution": {
|
|
245
|
+
const entry = message as {
|
|
246
|
+
command?: string;
|
|
247
|
+
output?: string;
|
|
248
|
+
exitCode?: number | null;
|
|
249
|
+
cancelled?: boolean;
|
|
250
|
+
excludeFromContext?: boolean;
|
|
251
|
+
timestamp?: number;
|
|
252
|
+
};
|
|
253
|
+
if (entry.excludeFromContext) {
|
|
254
|
+
return hashCursorContextValue(`bashExecution:excluded:${entry.timestamp ?? index}`);
|
|
255
|
+
}
|
|
256
|
+
return hashCursorContextValue(
|
|
257
|
+
`bashExecution:${entry.timestamp ?? index}:${entry.command ?? ""}:${entry.output ?? ""}:${entry.exitCode ?? ""}:${entry.cancelled === true}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
return serializeMessageForFingerprint(message as Message, index);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseCursorContextFingerprint(fingerprint: string): CursorContextFingerprintPayload | undefined {
|
|
266
|
+
try {
|
|
267
|
+
const parsed = JSON.parse(fingerprint) as CursorContextFingerprintPayload;
|
|
268
|
+
if (!parsed || typeof parsed.systemHash !== "string" || !Array.isArray(parsed.messageHashes)) return undefined;
|
|
269
|
+
if (!parsed.messageHashes.every((entry) => typeof entry === "string")) return undefined;
|
|
270
|
+
return parsed;
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function computeCursorContextFingerprint(context: Context): string {
|
|
277
|
+
const payload: CursorContextFingerprintPayload = {
|
|
278
|
+
systemHash: hashCursorContextValue(context.systemPrompt ?? ""),
|
|
279
|
+
messageHashes: context.messages.map((message, index) => serializeRawPiMessageForFingerprint(message, index)),
|
|
280
|
+
};
|
|
281
|
+
return JSON.stringify(payload);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function shouldBootstrapCursorSend(
|
|
285
|
+
sendState: { bootstrapped: boolean; contextFingerprint: string },
|
|
286
|
+
context: Context,
|
|
287
|
+
): boolean {
|
|
288
|
+
if (!sendState.bootstrapped) return true;
|
|
289
|
+
const previous = parseCursorContextFingerprint(sendState.contextFingerprint);
|
|
290
|
+
if (!previous) return true;
|
|
291
|
+
const current = parseCursorContextFingerprint(computeCursorContextFingerprint(context));
|
|
292
|
+
if (!current) return true;
|
|
293
|
+
if (current.systemHash !== previous.systemHash) return true;
|
|
294
|
+
if (current.messageHashes.length < previous.messageHashes.length) return true;
|
|
295
|
+
if (current.messageHashes.length > previous.messageHashes.length) {
|
|
296
|
+
for (let index = previous.messageHashes.length; index < context.messages.length; index += 1) {
|
|
297
|
+
const role = (context.messages[index] as { role?: string }).role;
|
|
298
|
+
if (role === "branchSummary" || role === "compactionSummary") return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for (let index = 0; index < previous.messageHashes.length; index += 1) {
|
|
302
|
+
if (current.messageHashes[index] !== previous.messageHashes[index]) return true;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function buildCursorIncrementalPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
|
|
308
|
+
// Incremental sends omit the full Cursor SDK tool boundary block; the session agent retains prior bootstrap context.
|
|
309
|
+
const messages = normalizePiContextMessages(context.messages);
|
|
310
|
+
const latestUserMessageIndex = getLatestUserMessageIndex(messages);
|
|
311
|
+
const latestUserMessage = latestUserMessageIndex >= 0 ? messages[latestUserMessageIndex] : undefined;
|
|
312
|
+
const latestUserText = latestUserMessage ? formatMessage(latestUserMessage) : undefined;
|
|
313
|
+
const sectionsBeforeMessages = [
|
|
314
|
+
"Continue the conversation using Cursor SDK capabilities only. Do not list, promise, or call pi-only tools from earlier context as if they were available.",
|
|
315
|
+
];
|
|
316
|
+
if (context.systemPrompt) {
|
|
317
|
+
sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
|
|
318
|
+
}
|
|
319
|
+
const latestUserMessageSections =
|
|
320
|
+
latestUserText && latestUserMessageIndex >= 0 ? [{ index: latestUserMessageIndex, text: latestUserText }] : [];
|
|
321
|
+
const images = extractLatestImages(messages);
|
|
322
|
+
const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
|
|
323
|
+
const budgetOptions =
|
|
324
|
+
options.maxInputTokens === undefined
|
|
325
|
+
? options
|
|
326
|
+
: { ...options, maxInputTokens: Math.max(1, options.maxInputTokens - imageTokenReserve) };
|
|
327
|
+
const parts = applyPromptBudget(sectionsBeforeMessages, latestUserMessageSections, [], latestUserMessageIndex, budgetOptions);
|
|
328
|
+
return { text: parts.join(SECTION_SEPARATOR), images };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function buildCursorSendPrompt(
|
|
332
|
+
context: Context,
|
|
333
|
+
options: CursorPromptOptions,
|
|
334
|
+
sendState: { bootstrapped: boolean; contextFingerprint: string },
|
|
335
|
+
): { prompt: CursorPrompt; bootstrap: boolean } {
|
|
336
|
+
const bootstrap = shouldBootstrapCursorSend(sendState, context);
|
|
337
|
+
if (bootstrap) {
|
|
338
|
+
return { prompt: buildCursorPrompt(context, options), bootstrap: true };
|
|
339
|
+
}
|
|
340
|
+
return { prompt: buildCursorIncrementalPrompt(context, options), bootstrap: false };
|
|
341
|
+
}
|
|
342
|
+
|
|
151
343
|
export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
|
|
152
344
|
const sectionsBeforeMessages: string[] = [
|
|
153
345
|
[
|
|
154
346
|
"Cursor SDK tool boundary:",
|
|
155
|
-
"
|
|
156
|
-
|
|
157
|
-
"
|
|
158
|
-
"
|
|
347
|
+
"You can call only tools actually exposed by Cursor SDK in this run. Pi tool names, replay tool names, and transcript tool names are context only, not callable capabilities.",
|
|
348
|
+
getCursorPiBridgeContractText(),
|
|
349
|
+
"If asked to list or exercise available tools, list and exercise Cursor SDK tools only; do not claim access to pi-side tools from the system prompt unless Cursor exposes an equivalent tool that runs.",
|
|
350
|
+
"Use pi__cursor_ask_question for material choices if exposed.",
|
|
351
|
+
"Web: use Cursor web/search/browser/MCP or say web search is not configured; do not claim WebSearch/WebFetch unless Cursor executes them.",
|
|
352
|
+
"Replay: pi may display recorded Cursor tool activity as pi-style cards, but replay is display-only and not a capability to invoke.",
|
|
353
|
+
"Images: only latest user images are sent; ask to reattach or describe prior images.",
|
|
159
354
|
].join("\n"),
|
|
160
355
|
];
|
|
161
356
|
|
|
162
357
|
if (context.systemPrompt) {
|
|
163
|
-
sectionsBeforeMessages.push(`System instructions from pi:\n${context.systemPrompt}`);
|
|
358
|
+
sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
|
|
164
359
|
}
|
|
165
360
|
|
|
166
|
-
const
|
|
361
|
+
const messages = normalizePiContextMessages(context.messages);
|
|
362
|
+
const messageSections = messages
|
|
167
363
|
.map((msg, index) => {
|
|
168
364
|
const text = formatMessage(msg);
|
|
169
365
|
return text ? { index, text } : undefined;
|
|
@@ -171,11 +367,11 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
|
|
|
171
367
|
.filter((section): section is { index: number; text: string } => section !== undefined);
|
|
172
368
|
const sectionsAfterMessages = [
|
|
173
369
|
[
|
|
174
|
-
"Answer the latest user request above using
|
|
175
|
-
"If
|
|
370
|
+
"Answer the latest user request above using Cursor SDK capabilities only. Do not list, promise, or call pi-only tools from the system prompt as if they were available.",
|
|
371
|
+
"If web research is requested, do not claim it unless a Cursor web/search/browser/MCP tool ran.",
|
|
176
372
|
].join("\n"),
|
|
177
373
|
];
|
|
178
|
-
const images = extractLatestImages(
|
|
374
|
+
const images = extractLatestImages(messages);
|
|
179
375
|
const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
|
|
180
376
|
const budgetOptions =
|
|
181
377
|
options.maxInputTokens === undefined
|
|
@@ -185,9 +381,11 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
|
|
|
185
381
|
sectionsBeforeMessages,
|
|
186
382
|
messageSections,
|
|
187
383
|
sectionsAfterMessages,
|
|
188
|
-
getLatestUserMessageIndex(
|
|
384
|
+
getLatestUserMessageIndex(messages),
|
|
189
385
|
budgetOptions,
|
|
190
386
|
);
|
|
387
|
+
const text = parts.join(SECTION_SEPARATOR);
|
|
191
388
|
|
|
192
|
-
|
|
389
|
+
|
|
390
|
+
return { text, images };
|
|
193
391
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX = "pi__";
|
|
2
|
+
|
|
3
|
+
const CURSOR_PI_BRIDGE_CONTRACT_LINES = [
|
|
4
|
+
"Pi bridge contract:",
|
|
5
|
+
`${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}* names are live Cursor MCP bridge tool names only when exposed in the current run.`,
|
|
6
|
+
`Call the ${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}* MCP tool name, not the real pi tool name shown in pi history or transcripts.`,
|
|
7
|
+
"Bridged calls execute through normal pi tool flow, so pi shows the real pi tool name and returns a normal pi tool result.",
|
|
8
|
+
"Replay IDs, replay labels, and transcript tool names are display-only/context-only, not callable tools.",
|
|
9
|
+
"Cursor-native host tools, settings, plugins, and configured MCP servers are separate from the pi bridge.",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export function getCursorPiBridgeContractText(): string {
|
|
13
|
+
return CURSOR_PI_BRIDGE_CONTRACT_LINES.join("\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildCursorPiBridgeMcpToolDescription(options: {
|
|
17
|
+
piToolName: string;
|
|
18
|
+
mcpToolName: string;
|
|
19
|
+
piToolDescription: string;
|
|
20
|
+
}): string {
|
|
21
|
+
return [
|
|
22
|
+
options.piToolDescription,
|
|
23
|
+
"",
|
|
24
|
+
getCursorPiBridgeContractText(),
|
|
25
|
+
`This run exposes real pi tool ${options.piToolName} as Cursor MCP tool ${options.mcpToolName}.`,
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|