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.
Files changed (49) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +59 -1
  3. package/docs/cursor-live-smoke-checklist.md +4 -1
  4. package/docs/cursor-model-ux-spec.md +7 -5
  5. package/docs/cursor-native-tool-replay.md +99 -3
  6. package/docs/cursor-testing-lessons.md +234 -5
  7. package/package.json +10 -2
  8. package/scripts/debug-provider-events.mjs +403 -0
  9. package/scripts/debug-sdk-events.mjs +413 -0
  10. package/scripts/lib/cursor-probe-utils.mjs +52 -0
  11. package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
  12. package/scripts/probe-mcp-coldstart.mjs +244 -0
  13. package/scripts/validate-smoke-jsonl.mjs +27 -3
  14. package/src/context.ts +45 -32
  15. package/src/cursor-agent-message-web-tools.ts +172 -0
  16. package/src/cursor-agents-context.ts +176 -0
  17. package/src/cursor-incomplete-tool-visibility.ts +124 -0
  18. package/src/cursor-live-run-coordinator.ts +18 -7
  19. package/src/cursor-mcp-timeout-override.ts +66 -11
  20. package/src/cursor-model.ts +12 -0
  21. package/src/cursor-native-tool-display-registration.ts +1 -4
  22. package/src/cursor-native-tool-display-replay.ts +65 -6
  23. package/src/cursor-native-tool-display-tools.ts +20 -0
  24. package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
  25. package/src/cursor-pi-tool-bridge-run.ts +16 -1
  26. package/src/cursor-pi-tool-bridge-types.ts +3 -0
  27. package/src/cursor-provider-errors.ts +96 -0
  28. package/src/cursor-provider-live-run-drain.ts +181 -62
  29. package/src/cursor-provider-turn-coordinator.ts +220 -33
  30. package/src/cursor-provider.ts +302 -93
  31. package/src/cursor-question-tool.ts +1 -4
  32. package/src/cursor-sdk-abort-error-guard.ts +109 -0
  33. package/src/cursor-sdk-event-debug-constants.ts +40 -0
  34. package/src/cursor-sdk-event-debug-session.ts +163 -0
  35. package/src/cursor-sdk-event-debug.ts +602 -0
  36. package/src/cursor-sensitive-text.ts +27 -7
  37. package/src/cursor-session-agent.ts +279 -82
  38. package/src/cursor-session-send-policy.ts +43 -0
  39. package/src/cursor-setting-sources.ts +29 -0
  40. package/src/cursor-state.ts +1 -5
  41. package/src/cursor-tool-lifecycle.ts +85 -0
  42. package/src/cursor-tool-names.ts +39 -0
  43. package/src/cursor-tool-transcript.ts +4 -2
  44. package/src/cursor-tool-visibility.ts +63 -0
  45. package/src/cursor-transcript-tool-formatters.ts +228 -5
  46. package/src/cursor-transcript-tool-specs.ts +135 -24
  47. package/src/cursor-transcript-utils.ts +12 -0
  48. package/src/cursor-web-tool-activity.ts +84 -0
  49. 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 JSONL record contains "Tool grep/cursor/find/ls not found"
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 blob = JSON.stringify(record);
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 (blob.includes(needle)) {
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 shouldBootstrapCursorSend(
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(sectionsBeforeMessages, latestUserMessageSections, [], latestUserMessageIndex, budgetOptions);
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
+ }