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.
@@ -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.14",
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 DEFAULT_CHARS_PER_TOKEN = 4;
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 ?? DEFAULT_CHARS_PER_TOKEN;
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
- "Only tools exposed by the Cursor SDK in this run are callable. The pi system prompt and transcript are context only; they do not grant access to pi tools or tool names mentioned there.",
156
- "If the user asks you to search, fetch, browse, or research the web, use an actual Cursor SDK web/search/browser/MCP tool call. If no such Cursor SDK tool is available, say that web search is not configured for this Cursor SDK run.",
157
- "Do not plan to use or claim to have used pi-only tools such as WebSearch or WebFetch unless the Cursor SDK actually exposes and executes that tool in this run.",
158
- "Image payload boundary: only images attached to the latest user message are available as image bytes. Earlier images appear only as [image omitted from transcript] placeholders; ask the user to reattach or describe a prior image if the latest request depends on it.",
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 messageSections = context.messages
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 your capabilities. Do not assume access to pi tools.",
175
- "If the user asks for web research, do not claim to have searched the web unless a Cursor SDK web/search/browser/MCP tool was actually used.",
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(context.messages);
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(context.messages),
384
+ getLatestUserMessageIndex(messages),
189
385
  budgetOptions,
190
386
  );
387
+ const text = parts.join(SECTION_SEPARATOR);
191
388
 
192
- return { text: parts.join(SECTION_SEPARATOR), images };
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
+ }