pi-cursor-sdk 0.1.18 → 0.1.20
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 +58 -0
- package/README.md +59 -1
- package/docs/cursor-live-smoke-checklist.md +4 -1
- package/docs/cursor-model-ux-spec.md +7 -5
- package/docs/cursor-native-tool-replay.md +99 -3
- package/docs/cursor-testing-lessons.md +234 -5
- package/package.json +10 -2
- package/scripts/debug-provider-events.mjs +403 -0
- package/scripts/debug-sdk-events.mjs +413 -0
- package/scripts/lib/cursor-probe-utils.mjs +52 -0
- package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
- package/scripts/probe-mcp-coldstart.mjs +244 -0
- package/scripts/validate-smoke-jsonl.mjs +27 -3
- package/src/context.ts +45 -32
- package/src/cursor-agent-message-web-tools.ts +172 -0
- package/src/cursor-agents-context.ts +176 -0
- package/src/cursor-incomplete-tool-visibility.ts +124 -0
- package/src/cursor-live-run-coordinator.ts +18 -7
- package/src/cursor-mcp-timeout-override.ts +66 -11
- package/src/cursor-model.ts +12 -0
- package/src/cursor-native-tool-display-registration.ts +1 -4
- package/src/cursor-native-tool-display-replay.ts +65 -6
- package/src/cursor-native-tool-display-tools.ts +20 -0
- package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
- package/src/cursor-pi-tool-bridge-run.ts +16 -1
- package/src/cursor-pi-tool-bridge-types.ts +3 -0
- package/src/cursor-provider-errors.ts +96 -0
- package/src/cursor-provider-live-run-drain.ts +181 -62
- package/src/cursor-provider-turn-coordinator.ts +220 -33
- package/src/cursor-provider.ts +302 -93
- package/src/cursor-question-tool.ts +1 -4
- package/src/cursor-sdk-abort-error-guard.ts +109 -0
- package/src/cursor-sdk-event-debug-constants.ts +40 -0
- package/src/cursor-sdk-event-debug-session.ts +163 -0
- package/src/cursor-sdk-event-debug.ts +602 -0
- package/src/cursor-sensitive-text.ts +27 -7
- package/src/cursor-session-agent.ts +279 -82
- package/src/cursor-session-send-policy.ts +43 -0
- package/src/cursor-setting-sources.ts +29 -0
- package/src/cursor-state.ts +1 -5
- package/src/cursor-tool-lifecycle.ts +85 -0
- package/src/cursor-tool-names.ts +39 -0
- package/src/cursor-tool-transcript.ts +4 -2
- package/src/cursor-tool-visibility.ts +63 -0
- package/src/cursor-transcript-tool-formatters.ts +228 -5
- package/src/cursor-transcript-tool-specs.ts +135 -24
- package/src/cursor-transcript-utils.ts +12 -0
- package/src/cursor-web-tool-activity.ts +84 -0
- package/src/index.ts +4 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Maintainer probe: measure Cursor SDK cold-start timing with/without ambient MCP settings
|
|
4
|
+
* and with the pi-cursor-sdk MCP connect timeout override installed.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { performance } from "node:perf_hooks";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import {
|
|
10
|
+
installCursorMcpToolTimeoutOverride,
|
|
11
|
+
restoreCursorMcpToolTimeoutOverride,
|
|
12
|
+
} from "../src/cursor-mcp-timeout-override.ts";
|
|
13
|
+
import { scrubSensitiveText } from "./lib/cursor-probe-utils.mjs";
|
|
14
|
+
import { installCursorSdkOutputFilter, suppressCursorSdkOutput } from "./lib/cursor-sdk-output-filter.mjs";
|
|
15
|
+
|
|
16
|
+
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
17
|
+
const SCENARIOS = [
|
|
18
|
+
{ label: "with-all-settings", settingSources: ["all"] },
|
|
19
|
+
{ label: "with-all-settings+connect-override", settingSources: ["all"], installConnectOverride: true },
|
|
20
|
+
{ label: "no-setting-sources", settingSources: undefined },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function printHelp() {
|
|
24
|
+
console.log(`Measure Cursor SDK first-send MCP cold-start timing.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
CURSOR_API_KEY=... npm run debug:mcp-coldstart
|
|
28
|
+
node scripts/probe-mcp-coldstart.mjs [options]
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--api-key <key> Cursor API key. Prefer CURSOR_API_KEY to avoid shell history.
|
|
32
|
+
--scenario <label> Run one scenario in this process. Used by the orchestrator.
|
|
33
|
+
-h, --help Show this help without importing or calling the Cursor SDK.
|
|
34
|
+
|
|
35
|
+
Stdout:
|
|
36
|
+
Emits one JSON object per scenario. Human status lines go to stderr.
|
|
37
|
+
|
|
38
|
+
Scenarios:
|
|
39
|
+
with-all-settings Cursor settingSources=["all"]
|
|
40
|
+
with-all-settings+connect-override Same, with pi-cursor-sdk timeout override installed
|
|
41
|
+
no-setting-sources No explicit settingSources
|
|
42
|
+
|
|
43
|
+
Safety:
|
|
44
|
+
- --help never performs live Cursor calls.
|
|
45
|
+
- Each default scenario runs in a fresh child process before its first Cursor SDK import.
|
|
46
|
+
- SDK startup noise is suppressed.
|
|
47
|
+
- Error messages are scrubbed for API keys, bearer tokens, cookies, and bridge endpoints.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fail(message, apiKey) {
|
|
51
|
+
console.error(`probe-mcp-coldstart: ${scrubSensitiveText(message, apiKey)}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findScenario(label) {
|
|
56
|
+
return SCENARIOS.find((scenario) => scenario.label === label);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseArgs(argv, env = process.env) {
|
|
60
|
+
const args = {
|
|
61
|
+
apiKey: env.CURSOR_API_KEY?.trim() || undefined,
|
|
62
|
+
help: false,
|
|
63
|
+
scenario: undefined,
|
|
64
|
+
};
|
|
65
|
+
for (let index = 0; index < argv.length; index++) {
|
|
66
|
+
const arg = argv[index];
|
|
67
|
+
if (arg === "-h" || arg === "--help") {
|
|
68
|
+
args.help = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (arg === "--api-key") {
|
|
72
|
+
const value = argv[++index];
|
|
73
|
+
if (!value || value.startsWith("--")) fail("--api-key requires a value", args.apiKey);
|
|
74
|
+
args.apiKey = value.trim();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (arg.startsWith("--api-key=")) {
|
|
78
|
+
args.apiKey = arg.slice("--api-key=".length).trim();
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (arg === "--scenario") {
|
|
82
|
+
const value = argv[++index];
|
|
83
|
+
if (!value || value.startsWith("--")) fail("--scenario requires a value", args.apiKey);
|
|
84
|
+
args.scenario = value.trim();
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (arg.startsWith("--scenario=")) {
|
|
88
|
+
args.scenario = arg.slice("--scenario=".length).trim();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
fail(`unknown argument: ${arg}`, args.apiKey);
|
|
92
|
+
}
|
|
93
|
+
if (args.scenario && !findScenario(args.scenario)) {
|
|
94
|
+
fail(`unknown scenario: ${args.scenario}`, args.apiKey);
|
|
95
|
+
}
|
|
96
|
+
return args;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function probe(Agent, apiKey, label, { settingSources, installConnectOverride = false } = {}) {
|
|
100
|
+
let agent;
|
|
101
|
+
try {
|
|
102
|
+
const marks = [];
|
|
103
|
+
const t0 = performance.now();
|
|
104
|
+
const mark = (name) => marks.push({ name, ms: Math.round(performance.now() - t0) });
|
|
105
|
+
|
|
106
|
+
mark("start");
|
|
107
|
+
agent = await suppressCursorSdkOutput(() =>
|
|
108
|
+
Agent.create({
|
|
109
|
+
apiKey,
|
|
110
|
+
model: { id: "composer-2.5" },
|
|
111
|
+
local: settingSources
|
|
112
|
+
? { cwd: process.cwd(), settingSources }
|
|
113
|
+
: { cwd: process.cwd() },
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
mark("agent.create");
|
|
117
|
+
|
|
118
|
+
let firstDeltaMs;
|
|
119
|
+
const run = await suppressCursorSdkOutput(() =>
|
|
120
|
+
agent.send("Reply with exactly: pong", {
|
|
121
|
+
onDelta: ({ update }) => {
|
|
122
|
+
if (firstDeltaMs === undefined && update.type === "text-delta") {
|
|
123
|
+
firstDeltaMs = Math.round(performance.now() - t0);
|
|
124
|
+
mark("first-delta");
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
mark("agent.send-returned");
|
|
130
|
+
|
|
131
|
+
const result = await suppressCursorSdkOutput(() => run.wait());
|
|
132
|
+
mark("run.wait");
|
|
133
|
+
|
|
134
|
+
await suppressCursorSdkOutput(() => agent[Symbol.asyncDispose]());
|
|
135
|
+
agent = undefined;
|
|
136
|
+
mark("dispose");
|
|
137
|
+
|
|
138
|
+
const sendReturnedMs = marks.find((entry) => entry.name === "agent.send-returned")?.ms;
|
|
139
|
+
const mcpBlockingMs =
|
|
140
|
+
firstDeltaMs !== undefined && sendReturnedMs !== undefined ? firstDeltaMs - sendReturnedMs : undefined;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
label,
|
|
144
|
+
settingSources: settingSources ?? null,
|
|
145
|
+
installConnectOverride,
|
|
146
|
+
marks,
|
|
147
|
+
firstDeltaMs,
|
|
148
|
+
mcpBlockingMs,
|
|
149
|
+
status: result.status,
|
|
150
|
+
text: typeof result.result === "string" ? result.result.slice(0, 120) : null,
|
|
151
|
+
};
|
|
152
|
+
} finally {
|
|
153
|
+
if (agent) {
|
|
154
|
+
await suppressCursorSdkOutput(() => agent[Symbol.asyncDispose]()).catch(() => undefined);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function runScenarioInThisProcess(args, scenario) {
|
|
160
|
+
const restoreOutputFilter = installCursorSdkOutputFilter();
|
|
161
|
+
try {
|
|
162
|
+
if (scenario.installConnectOverride) {
|
|
163
|
+
const state = installCursorMcpToolTimeoutOverride();
|
|
164
|
+
console.error(
|
|
165
|
+
`probe-mcp-coldstart: installed connect override (${state.connectTimeoutMs}ms initialize/listTools, ${state.timeoutMs}ms callTool)`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const { Agent } = await suppressCursorSdkOutput(() => import("@cursor/sdk"));
|
|
169
|
+
console.log(JSON.stringify(await probe(Agent, args.apiKey, scenario.label, scenario)));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
+
console.log(
|
|
173
|
+
JSON.stringify({
|
|
174
|
+
label: scenario.label,
|
|
175
|
+
error: scrubSensitiveText(message, args.apiKey),
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
} finally {
|
|
179
|
+
restoreCursorMcpToolTimeoutOverride();
|
|
180
|
+
restoreOutputFilter();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function runScenarioChild(args, scenario) {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
const child = spawn(process.execPath, [SCRIPT_PATH, "--scenario", scenario.label], {
|
|
187
|
+
cwd: process.cwd(),
|
|
188
|
+
env: { ...process.env, CURSOR_API_KEY: args.apiKey },
|
|
189
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
190
|
+
});
|
|
191
|
+
let stdout = "";
|
|
192
|
+
let stderr = "";
|
|
193
|
+
|
|
194
|
+
child.stdout.on("data", (chunk) => {
|
|
195
|
+
stdout += chunk;
|
|
196
|
+
});
|
|
197
|
+
child.stderr.on("data", (chunk) => {
|
|
198
|
+
stderr += chunk;
|
|
199
|
+
});
|
|
200
|
+
child.on("error", (error) => {
|
|
201
|
+
stderr += error instanceof Error ? error.message : String(error);
|
|
202
|
+
});
|
|
203
|
+
child.on("close", (code) => {
|
|
204
|
+
const scrubbedStderr = scrubSensitiveText(stderr, args.apiKey);
|
|
205
|
+
if (scrubbedStderr) process.stderr.write(scrubbedStderr.endsWith("\n") ? scrubbedStderr : `${scrubbedStderr}\n`);
|
|
206
|
+
if (code === 0 && stdout.trim()) {
|
|
207
|
+
process.stdout.write(stdout.endsWith("\n") ? stdout : `${stdout}\n`);
|
|
208
|
+
resolve();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const error = scrubbedStderr.trim() || `child process exited with code ${code ?? "unknown"}`;
|
|
212
|
+
console.log(JSON.stringify({ label: scenario.label, error }));
|
|
213
|
+
resolve();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function main(argv = process.argv.slice(2), env = process.env) {
|
|
219
|
+
const args = parseArgs(argv, env);
|
|
220
|
+
if (args.help) {
|
|
221
|
+
printHelp();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!args.apiKey) {
|
|
225
|
+
fail("CURSOR_API_KEY is required. Set CURSOR_API_KEY or pass --api-key.");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const scenario = args.scenario ? findScenario(args.scenario) : undefined;
|
|
229
|
+
if (scenario) {
|
|
230
|
+
await runScenarioInThisProcess(args, scenario);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const scenarioToRun of SCENARIOS) {
|
|
235
|
+
await runScenarioChild(args, scenarioToRun);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (import.meta.url === new URL(process.argv[1], "file:").href) {
|
|
240
|
+
main().catch((error) => {
|
|
241
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
242
|
+
fail(message, process.env.CURSOR_API_KEY);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
@@ -41,7 +41,8 @@ Enforced invariants (default mode):
|
|
|
41
41
|
- assistant usage cacheRead/cacheWrite are exactly 0
|
|
42
42
|
|
|
43
43
|
Replay error scan (--replay-errors / --replay-errors-only):
|
|
44
|
-
- no
|
|
44
|
+
- no persisted error toolResult or error assistant message contains "Tool grep/cursor/find/ls not found"
|
|
45
|
+
- successful tool/file reads that mention those strings in docs are ignored
|
|
45
46
|
|
|
46
47
|
Notes:
|
|
47
48
|
- Prints one JSON summary line per scanned session file (usage mode) or one replay summary line (replay-only mode).
|
|
@@ -100,12 +101,35 @@ function parseJsonlFile(file) {
|
|
|
100
101
|
return { lineCount: lines.length, records, parseErrorCount };
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
function getMessageText(message) {
|
|
105
|
+
if (!message || typeof message !== "object") return "";
|
|
106
|
+
const parts = [];
|
|
107
|
+
if (typeof message.errorMessage === "string") parts.push(message.errorMessage);
|
|
108
|
+
if (Array.isArray(message.content)) {
|
|
109
|
+
for (const block of message.content) {
|
|
110
|
+
if (block?.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return parts.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isReplayErrorMessage(message, needle) {
|
|
117
|
+
const text = getMessageText(message);
|
|
118
|
+
if (!text.includes(needle)) return false;
|
|
119
|
+
if (message.role === "toolResult" && message.isError === true) return true;
|
|
120
|
+
if (message.role === "assistant" && (message.stopReason === "error" || typeof message.errorMessage === "string")) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
103
126
|
function scanReplayErrors(file, records) {
|
|
104
127
|
const hits = [];
|
|
105
128
|
for (const [index, record] of records.entries()) {
|
|
106
|
-
const
|
|
129
|
+
const message = record?.type === "message" ? record.message : undefined;
|
|
130
|
+
if (!message) continue;
|
|
107
131
|
for (const needle of REPLAY_TOOL_NOT_FOUND) {
|
|
108
|
-
if (
|
|
132
|
+
if (isReplayErrorMessage(message, needle)) {
|
|
109
133
|
hits.push({ line: index + 1, needle });
|
|
110
134
|
}
|
|
111
135
|
}
|
package/src/context.ts
CHANGED
|
@@ -20,6 +20,33 @@ export const CURSOR_APPROX_CHARS_PER_TOKEN = 4;
|
|
|
20
20
|
export const CURSOR_IMAGE_TOKEN_ESTIMATE = 1200;
|
|
21
21
|
const SECTION_SEPARATOR = "\n\n";
|
|
22
22
|
|
|
23
|
+
export function getCursorToolTailGuardText(): string {
|
|
24
|
+
return "Tool boundary reminder: If a tool is needed, call an available Cursor SDK/MCP tool. Never print a tool card (for example Tool call/Shell/command) as assistant text.";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getCursorToolBoundaryText(): string {
|
|
28
|
+
return [
|
|
29
|
+
"Cursor SDK tool boundary:",
|
|
30
|
+
"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.",
|
|
31
|
+
getCursorPiBridgeContractText(),
|
|
32
|
+
"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.",
|
|
33
|
+
"Use pi__cursor_ask_question for material choices if exposed.",
|
|
34
|
+
"Web: use Cursor web/search/browser/MCP or say web search is not configured; do not claim WebSearch/WebFetch unless Cursor executes them.",
|
|
35
|
+
"Replay: pi may display recorded Cursor tool activity as pi-style cards, but replay is display-only and not a capability to invoke.",
|
|
36
|
+
"Images: only latest user images are sent; ask to reattach or describe prior images.",
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getCursorBootstrapTailSections(): string[] {
|
|
41
|
+
return [
|
|
42
|
+
[
|
|
43
|
+
"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.",
|
|
44
|
+
"If web research is requested, do not claim it unless a Cursor web/search/browser/MCP tool ran.",
|
|
45
|
+
].join("\n"),
|
|
46
|
+
getCursorToolTailGuardText(),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
23
50
|
function normalizePiContextMessages(messages: Context["messages"]): Message[] {
|
|
24
51
|
return convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
|
|
25
52
|
}
|
|
@@ -281,7 +308,7 @@ export function computeCursorContextFingerprint(context: Context): string {
|
|
|
281
308
|
return JSON.stringify(payload);
|
|
282
309
|
}
|
|
283
310
|
|
|
284
|
-
export function
|
|
311
|
+
export function shouldBootstrapCursorContext(
|
|
285
312
|
sendState: { bootstrapped: boolean; contextFingerprint: string },
|
|
286
313
|
context: Context,
|
|
287
314
|
): boolean {
|
|
@@ -304,6 +331,14 @@ export function shouldBootstrapCursorSend(
|
|
|
304
331
|
return false;
|
|
305
332
|
}
|
|
306
333
|
|
|
334
|
+
/** @deprecated Use planCursorSessionSend() for send mode and shouldBootstrapCursorContext() for context-only checks. */
|
|
335
|
+
export function shouldBootstrapCursorSend(
|
|
336
|
+
sendState: { bootstrapped: boolean; contextFingerprint: string },
|
|
337
|
+
context: Context,
|
|
338
|
+
): boolean {
|
|
339
|
+
return shouldBootstrapCursorContext(sendState, context);
|
|
340
|
+
}
|
|
341
|
+
|
|
307
342
|
export function buildCursorIncrementalPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
|
|
308
343
|
// Incremental sends omit the full Cursor SDK tool boundary block; the session agent retains prior bootstrap context.
|
|
309
344
|
const messages = normalizePiContextMessages(context.messages);
|
|
@@ -324,35 +359,18 @@ export function buildCursorIncrementalPrompt(context: Context, options: CursorPr
|
|
|
324
359
|
options.maxInputTokens === undefined
|
|
325
360
|
? options
|
|
326
361
|
: { ...options, maxInputTokens: Math.max(1, options.maxInputTokens - imageTokenReserve) };
|
|
327
|
-
const parts = applyPromptBudget(
|
|
362
|
+
const parts = applyPromptBudget(
|
|
363
|
+
sectionsBeforeMessages,
|
|
364
|
+
latestUserMessageSections,
|
|
365
|
+
[getCursorToolTailGuardText()],
|
|
366
|
+
latestUserMessageIndex,
|
|
367
|
+
budgetOptions,
|
|
368
|
+
);
|
|
328
369
|
return { text: parts.join(SECTION_SEPARATOR), images };
|
|
329
370
|
}
|
|
330
371
|
|
|
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
|
-
|
|
343
372
|
export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
|
|
344
|
-
const sectionsBeforeMessages: string[] = [
|
|
345
|
-
[
|
|
346
|
-
"Cursor SDK tool boundary:",
|
|
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.",
|
|
354
|
-
].join("\n"),
|
|
355
|
-
];
|
|
373
|
+
const sectionsBeforeMessages: string[] = [getCursorToolBoundaryText()];
|
|
356
374
|
|
|
357
375
|
if (context.systemPrompt) {
|
|
358
376
|
sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
|
|
@@ -365,12 +383,7 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
|
|
|
365
383
|
return text ? { index, text } : undefined;
|
|
366
384
|
})
|
|
367
385
|
.filter((section): section is { index: number; text: string } => section !== undefined);
|
|
368
|
-
const sectionsAfterMessages =
|
|
369
|
-
[
|
|
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.",
|
|
372
|
-
].join("\n"),
|
|
373
|
-
];
|
|
386
|
+
const sectionsAfterMessages = getCursorBootstrapTailSections();
|
|
374
387
|
const images = extractLatestImages(messages);
|
|
375
388
|
const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
|
|
376
389
|
const budgetOptions =
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Agent, type AgentMessage } from "@cursor/sdk";
|
|
2
|
+
import { asRecord, getArray, getString, stringifyUnknown } from "./cursor-transcript-utils.js";
|
|
3
|
+
|
|
4
|
+
const CURSOR_AGENT_MESSAGE_PAGE_LIMIT = 8;
|
|
5
|
+
|
|
6
|
+
export interface CursorTranscriptCompletedToolCall {
|
|
7
|
+
identity: string;
|
|
8
|
+
toolCall: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface CursorTranscriptWebToolPayload {
|
|
12
|
+
kind: "webSearch" | "webFetch";
|
|
13
|
+
payload: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getOneofCaseValue(value: unknown, caseName: string): unknown {
|
|
17
|
+
const record = asRecord(value);
|
|
18
|
+
if (!record) return undefined;
|
|
19
|
+
if (record.case === caseName) return record.value;
|
|
20
|
+
return record[caseName];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function hasCursorAgentMessageAt(agentId: string, cwd: string, offset: number): Promise<boolean> {
|
|
24
|
+
const messages = await Agent.messages.list(agentId, { runtime: "local", cwd, limit: 1, offset });
|
|
25
|
+
return messages.length > 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function countCursorAgentMessages(agentId: string, cwd: string): Promise<number> {
|
|
29
|
+
let high = 1;
|
|
30
|
+
while (await hasCursorAgentMessageAt(agentId, cwd, high)) {
|
|
31
|
+
high *= 2;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let low = 0;
|
|
35
|
+
while (low < high) {
|
|
36
|
+
const mid = Math.floor((low + high) / 2);
|
|
37
|
+
if (await hasCursorAgentMessageAt(agentId, cwd, mid)) low = mid + 1;
|
|
38
|
+
else high = mid;
|
|
39
|
+
}
|
|
40
|
+
return low;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadCursorTranscriptWebToolCallsAfterOffset(options: {
|
|
44
|
+
agentId: string;
|
|
45
|
+
cwd: string;
|
|
46
|
+
offset: number | undefined;
|
|
47
|
+
}): Promise<CursorTranscriptCompletedToolCall[]> {
|
|
48
|
+
if (options.offset === undefined) return [];
|
|
49
|
+
const messages = await Agent.messages.list(options.agentId, {
|
|
50
|
+
runtime: "local",
|
|
51
|
+
cwd: options.cwd,
|
|
52
|
+
limit: CURSOR_AGENT_MESSAGE_PAGE_LIMIT,
|
|
53
|
+
offset: options.offset,
|
|
54
|
+
});
|
|
55
|
+
return collectCursorTranscriptWebToolCalls(messages);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function collectCursorTranscriptWebToolCalls(messages: readonly AgentMessage[]): CursorTranscriptCompletedToolCall[] {
|
|
59
|
+
const toolCalls: CursorTranscriptCompletedToolCall[] = [];
|
|
60
|
+
for (const [messageIndex, message] of messages.entries()) {
|
|
61
|
+
const messageId = message.uuid || `${message.agent_id || "cursor-agent"}:${messageIndex}`;
|
|
62
|
+
const steps = getAgentConversationSteps(message.message);
|
|
63
|
+
for (const [stepIndex, step] of steps.entries()) {
|
|
64
|
+
const webTool = getStepWebToolPayload(step);
|
|
65
|
+
if (!webTool) continue;
|
|
66
|
+
const converted = convertCursorTranscriptWebTool(webTool);
|
|
67
|
+
if (!converted) continue;
|
|
68
|
+
const args = asRecord(converted.args);
|
|
69
|
+
const toolCallId = getString(args, "toolCallId") ?? getString(args, "tool_call_id") ?? `${stepIndex}`;
|
|
70
|
+
toolCalls.push({
|
|
71
|
+
identity: `cursor-transcript:${messageId}:${webTool.kind}:${toolCallId}`,
|
|
72
|
+
toolCall: converted,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return toolCalls;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getAgentConversationSteps(message: unknown): unknown[] {
|
|
80
|
+
const record = asRecord(message);
|
|
81
|
+
const turn = getOneofCaseValue(record?.turn, "agentConversationTurn") ?? record?.agentConversationTurn;
|
|
82
|
+
return getArray(asRecord(turn), "steps") ?? [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getStepToolCall(step: unknown): unknown {
|
|
86
|
+
const stepRecord = asRecord(step);
|
|
87
|
+
const message = asRecord(stepRecord?.message);
|
|
88
|
+
return getOneofCaseValue(message, "toolCall") ?? stepRecord?.toolCall ?? message?.toolCall;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getStepWebToolPayload(step: unknown): CursorTranscriptWebToolPayload | undefined {
|
|
92
|
+
const toolCall = getStepToolCall(step);
|
|
93
|
+
const toolCallRecord = asRecord(toolCall);
|
|
94
|
+
const tool = toolCallRecord?.tool;
|
|
95
|
+
const webSearchPayload =
|
|
96
|
+
getOneofCaseValue(tool, "webSearchToolCall") ??
|
|
97
|
+
getOneofCaseValue(toolCall, "webSearchToolCall") ??
|
|
98
|
+
toolCallRecord?.webSearchToolCall;
|
|
99
|
+
if (webSearchPayload) return { kind: "webSearch", payload: webSearchPayload };
|
|
100
|
+
|
|
101
|
+
const webFetchPayload =
|
|
102
|
+
getOneofCaseValue(tool, "webFetchToolCall") ??
|
|
103
|
+
getOneofCaseValue(toolCall, "webFetchToolCall") ??
|
|
104
|
+
toolCallRecord?.webFetchToolCall;
|
|
105
|
+
if (webFetchPayload) return { kind: "webFetch", payload: webFetchPayload };
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function convertCursorTranscriptWebTool(webTool: CursorTranscriptWebToolPayload): { name: string; args: Record<string, unknown>; result: unknown } | undefined {
|
|
110
|
+
const payload = asRecord(webTool.payload);
|
|
111
|
+
if (!payload) return undefined;
|
|
112
|
+
const rawArgs = asRecord(payload.args) ?? {};
|
|
113
|
+
const args = normalizeWebToolArgs(webTool.kind, rawArgs);
|
|
114
|
+
const result = normalizeWebToolResult(payload.result);
|
|
115
|
+
if (!result) return undefined;
|
|
116
|
+
return {
|
|
117
|
+
name: webTool.kind,
|
|
118
|
+
args,
|
|
119
|
+
result,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeWebToolArgs(kind: "webSearch" | "webFetch", rawArgs: Record<string, unknown>): Record<string, unknown> {
|
|
124
|
+
const args = { ...rawArgs };
|
|
125
|
+
if (kind === "webSearch") {
|
|
126
|
+
const query = getString(args, "searchTerm") ?? getString(args, "search_term") ?? getString(args, "query") ?? getString(args, "q");
|
|
127
|
+
if (query && !args.searchTerm) args.searchTerm = query;
|
|
128
|
+
return args;
|
|
129
|
+
}
|
|
130
|
+
const url = getString(args, "url") ?? getString(args, "uri") ?? getString(args, "href");
|
|
131
|
+
if (url && !args.url) args.url = url;
|
|
132
|
+
return args;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeWebToolResult(result: unknown): unknown | undefined {
|
|
136
|
+
if (result === undefined) return undefined;
|
|
137
|
+
const success = getTranscriptResultCase(result, "success");
|
|
138
|
+
if (success !== undefined) {
|
|
139
|
+
return {
|
|
140
|
+
status: "success",
|
|
141
|
+
value: { content: [{ type: "text", text: transcriptWebSuccessText(success) }] },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const error = getTranscriptResultCase(result, "error");
|
|
146
|
+
if (error !== undefined) return { status: "error", error };
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
status: "success",
|
|
150
|
+
value: { content: [{ type: "text", text: transcriptWebSuccessText(result) }] },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getTranscriptResultCase(result: unknown, caseName: "success" | "error"): unknown {
|
|
155
|
+
const record = asRecord(result);
|
|
156
|
+
return getOneofCaseValue(record?.result, caseName) ?? record?.[caseName];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function transcriptWebSuccessText(success: unknown): string {
|
|
160
|
+
const successRecord = asRecord(success);
|
|
161
|
+
const references = getArray(successRecord, "references");
|
|
162
|
+
const chunks = references
|
|
163
|
+
?.map((reference) => getString(asRecord(reference), "chunk"))
|
|
164
|
+
.filter((chunk): chunk is string => Boolean(chunk?.trim()));
|
|
165
|
+
if (chunks && chunks.length > 0) return chunks.join("\n\n");
|
|
166
|
+
const content = getArray(successRecord, "content");
|
|
167
|
+
const text = content
|
|
168
|
+
?.map((entry) => getString(asRecord(entry), "text"))
|
|
169
|
+
.filter((entry): entry is string => Boolean(entry?.trim()));
|
|
170
|
+
if (text && text.length > 0) return text.join("\n");
|
|
171
|
+
return stringifyUnknown(success).trim() || "Cursor web activity completed.";
|
|
172
|
+
}
|