pi-cursor-sdk 0.1.17 → 0.1.19
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 +62 -0
- package/README.md +38 -1
- package/docs/cursor-live-smoke-checklist.md +22 -2
- package/docs/cursor-model-ux-spec.md +5 -4
- package/docs/cursor-native-tool-replay.md +96 -2
- package/docs/cursor-testing-lessons.md +428 -0
- package/package.json +11 -2
- package/scripts/debug-provider-events.mjs +403 -0
- package/scripts/debug-sdk-events.mjs +413 -0
- package/scripts/isolated-cursor-smoke.sh +226 -0
- package/scripts/lib/cursor-probe-utils.mjs +52 -0
- package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
- package/scripts/validate-smoke-jsonl.mjs +86 -7
- 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-context-tools.ts +6 -0
- package/src/cursor-display-text.ts +10 -0
- package/src/cursor-incomplete-tool-visibility.ts +118 -0
- package/src/cursor-live-run-coordinator.ts +18 -7
- package/src/cursor-model.ts +12 -0
- package/src/cursor-native-replay-routing.ts +48 -0
- package/src/cursor-native-replay-trace.ts +29 -0
- package/src/cursor-native-tool-display-registration.ts +14 -7
- package/src/cursor-native-tool-display-replay.ts +63 -5
- 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 +208 -63
- package/src/cursor-provider-turn-coordinator.ts +217 -47
- package/src/cursor-provider.ts +275 -83
- package/src/cursor-question-tool.ts +10 -5
- 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 +597 -0
- package/src/cursor-sensitive-text.ts +27 -7
- package/src/cursor-session-agent.ts +25 -3
- 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 +111 -0
- package/src/cursor-tool-names.ts +12 -0
- package/src/cursor-tool-transcript.ts +4 -2
- package/src/cursor-transcript-tool-formatters.ts +228 -5
- package/src/cursor-transcript-tool-specs.ts +113 -14
- 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,86 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
export const CURSOR_SDK_STARTUP_NOISE_PATTERNS = [
|
|
4
|
+
"[hooks]",
|
|
5
|
+
"managed_skills.",
|
|
6
|
+
"CursorPluginsAgentSkillsService load completed",
|
|
7
|
+
"LocalCursorRulesService load completed",
|
|
8
|
+
"AgentSkillsCursorRulesService load completed",
|
|
9
|
+
"Error initializing ignore mapping for",
|
|
10
|
+
"Ripgrep path not configured. Call configureRipgrepPath() at startup.",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const cursorSdkOutputSuppression = new AsyncLocalStorage();
|
|
14
|
+
|
|
15
|
+
export function isCursorSdkOutputSuppressed() {
|
|
16
|
+
return cursorSdkOutputSuppression.getStore() === true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function suppressCursorSdkOutput(operation) {
|
|
20
|
+
return cursorSdkOutputSuppression.run(true, operation);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isCursorSdkStartupNoise(text) {
|
|
24
|
+
return CURSOR_SDK_STARTUP_NOISE_PATTERNS.some((pattern) => text.includes(pattern));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createFilteredProcessWrite(write, stream) {
|
|
28
|
+
return (chunk, encodingOrCallback, callback) => {
|
|
29
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
30
|
+
if (isCursorSdkOutputSuppressed() || isCursorSdkStartupNoise(text)) {
|
|
31
|
+
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
|
|
32
|
+
done?.();
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return write.call(stream, chunk, encodingOrCallback, callback);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createFilteredConsoleMethod(method) {
|
|
40
|
+
return (...args) => {
|
|
41
|
+
const text = args.map((arg) => (typeof arg === "string" ? arg : String(arg))).join(" ");
|
|
42
|
+
if (isCursorSdkOutputSuppressed() || isCursorSdkStartupNoise(text)) return;
|
|
43
|
+
method(...args);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let activeOutputFilterInstalls = 0;
|
|
48
|
+
let outputFilterOriginals;
|
|
49
|
+
|
|
50
|
+
export function installCursorSdkOutputFilter() {
|
|
51
|
+
if (activeOutputFilterInstalls === 0) {
|
|
52
|
+
outputFilterOriginals = {
|
|
53
|
+
stdoutWrite: process.stdout.write,
|
|
54
|
+
stderrWrite: process.stderr.write,
|
|
55
|
+
consoleLog: console.log,
|
|
56
|
+
consoleInfo: console.info,
|
|
57
|
+
consoleWarn: console.warn,
|
|
58
|
+
consoleError: console.error,
|
|
59
|
+
consoleDebug: console.debug,
|
|
60
|
+
};
|
|
61
|
+
process.stdout.write = createFilteredProcessWrite(outputFilterOriginals.stdoutWrite, process.stdout);
|
|
62
|
+
process.stderr.write = createFilteredProcessWrite(outputFilterOriginals.stderrWrite, process.stderr);
|
|
63
|
+
console.log = createFilteredConsoleMethod(outputFilterOriginals.consoleLog);
|
|
64
|
+
console.info = createFilteredConsoleMethod(outputFilterOriginals.consoleInfo);
|
|
65
|
+
console.warn = createFilteredConsoleMethod(outputFilterOriginals.consoleWarn);
|
|
66
|
+
console.error = createFilteredConsoleMethod(outputFilterOriginals.consoleError);
|
|
67
|
+
console.debug = createFilteredConsoleMethod(outputFilterOriginals.consoleDebug);
|
|
68
|
+
}
|
|
69
|
+
activeOutputFilterInstalls += 1;
|
|
70
|
+
|
|
71
|
+
let restored = false;
|
|
72
|
+
return () => {
|
|
73
|
+
if (restored) return;
|
|
74
|
+
restored = true;
|
|
75
|
+
activeOutputFilterInstalls = Math.max(activeOutputFilterInstalls - 1, 0);
|
|
76
|
+
if (activeOutputFilterInstalls > 0 || !outputFilterOriginals) return;
|
|
77
|
+
process.stdout.write = outputFilterOriginals.stdoutWrite;
|
|
78
|
+
process.stderr.write = outputFilterOriginals.stderrWrite;
|
|
79
|
+
console.log = outputFilterOriginals.consoleLog;
|
|
80
|
+
console.info = outputFilterOriginals.consoleInfo;
|
|
81
|
+
console.warn = outputFilterOriginals.consoleWarn;
|
|
82
|
+
console.error = outputFilterOriginals.consoleError;
|
|
83
|
+
console.debug = outputFilterOriginals.consoleDebug;
|
|
84
|
+
outputFilterOriginals = undefined;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
6
|
import { join, relative } from "node:path";
|
|
7
7
|
|
|
8
|
+
const REPLAY_TOOL_NOT_FOUND = [
|
|
9
|
+
"Tool grep not found",
|
|
10
|
+
"Tool cursor not found",
|
|
11
|
+
"Tool find not found",
|
|
12
|
+
"Tool ls not found",
|
|
13
|
+
];
|
|
14
|
+
|
|
8
15
|
function printHelp() {
|
|
9
16
|
console.log(`Validate assistant presence and usage metadata in pi smoke session JSONL files.
|
|
10
17
|
|
|
@@ -18,21 +25,27 @@ Arguments:
|
|
|
18
25
|
|
|
19
26
|
Options:
|
|
20
27
|
-h, --help Show this help.
|
|
28
|
+
--replay-errors Also fail when JSONL contains native replay "Tool * not found" errors.
|
|
29
|
+
--replay-errors-only Scan only for native replay "Tool * not found" errors (skip usage checks).
|
|
21
30
|
|
|
22
31
|
Exit codes:
|
|
23
|
-
0 every
|
|
24
|
-
1 invalid arguments, unreadable directory, invalid JSONL, empty/no-assistant files,
|
|
32
|
+
0 every enforced invariant passed for the selected mode(s)
|
|
33
|
+
1 invalid arguments, unreadable directory, invalid JSONL, empty/no-assistant files, usage validation failures, or replay tool errors
|
|
25
34
|
2 no JSONL files found under the smoke directory
|
|
26
35
|
|
|
27
|
-
Enforced invariants:
|
|
36
|
+
Enforced invariants (default mode):
|
|
28
37
|
- each scanned JSONL file contains parseable JSONL records
|
|
29
38
|
- each scanned JSONL file contains at least one persisted assistant message
|
|
30
39
|
- every persisted assistant message has usage metadata
|
|
31
40
|
- assistant usage input/output/totalTokens are non-negative numbers
|
|
32
41
|
- assistant usage cacheRead/cacheWrite are exactly 0
|
|
33
42
|
|
|
43
|
+
Replay error scan (--replay-errors / --replay-errors-only):
|
|
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
|
|
46
|
+
|
|
34
47
|
Notes:
|
|
35
|
-
- Prints one JSON summary line per scanned session file.
|
|
48
|
+
- Prints one JSON summary line per scanned session file (usage mode) or one replay summary line (replay-only mode).
|
|
36
49
|
- Does not print session message contents or secrets.`);
|
|
37
50
|
}
|
|
38
51
|
|
|
@@ -88,6 +101,42 @@ function parseJsonlFile(file) {
|
|
|
88
101
|
return { lineCount: lines.length, records, parseErrorCount };
|
|
89
102
|
}
|
|
90
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
|
+
|
|
126
|
+
function scanReplayErrors(file, records) {
|
|
127
|
+
const hits = [];
|
|
128
|
+
for (const [index, record] of records.entries()) {
|
|
129
|
+
const message = record?.type === "message" ? record.message : undefined;
|
|
130
|
+
if (!message) continue;
|
|
131
|
+
for (const needle of REPLAY_TOOL_NOT_FOUND) {
|
|
132
|
+
if (isReplayErrorMessage(message, needle)) {
|
|
133
|
+
hits.push({ line: index + 1, needle });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return hits;
|
|
138
|
+
}
|
|
139
|
+
|
|
91
140
|
function main() {
|
|
92
141
|
const args = process.argv.slice(2);
|
|
93
142
|
if (args.includes("-h") || args.includes("--help")) {
|
|
@@ -95,11 +144,15 @@ function main() {
|
|
|
95
144
|
return;
|
|
96
145
|
}
|
|
97
146
|
|
|
98
|
-
|
|
147
|
+
const replayErrorsOnly = args.includes("--replay-errors-only");
|
|
148
|
+
const replayErrors = replayErrorsOnly || args.includes("--replay-errors");
|
|
149
|
+
const positional = args.filter((arg) => !arg.startsWith("-"));
|
|
150
|
+
|
|
151
|
+
if (positional.length > 1) {
|
|
99
152
|
fail("too many arguments; pass only the smoke directory");
|
|
100
153
|
}
|
|
101
154
|
|
|
102
|
-
const smokeDir =
|
|
155
|
+
const smokeDir = positional[0] ?? process.env.SMOKE_DIR;
|
|
103
156
|
if (!smokeDir) {
|
|
104
157
|
fail("missing smoke directory; pass a path or set SMOKE_DIR");
|
|
105
158
|
}
|
|
@@ -117,6 +170,24 @@ function main() {
|
|
|
117
170
|
}
|
|
118
171
|
|
|
119
172
|
let failures = 0;
|
|
173
|
+
if (replayErrorsOnly) {
|
|
174
|
+
let replayHitCount = 0;
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
const { records } = parseJsonlFile(file);
|
|
177
|
+
const hits = scanReplayErrors(file, records);
|
|
178
|
+
replayHitCount += hits.length;
|
|
179
|
+
if (hits.length > 0) failures += 1;
|
|
180
|
+
console.log(
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
file: relative(smokeDir, file),
|
|
183
|
+
replayErrorCount: hits.length,
|
|
184
|
+
replayErrors: hits.slice(0, 5),
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
189
|
+
}
|
|
190
|
+
|
|
120
191
|
for (const file of files) {
|
|
121
192
|
let summary;
|
|
122
193
|
try {
|
|
@@ -125,7 +196,14 @@ function main() {
|
|
|
125
196
|
const assistants = messages.filter((message) => message?.role === "assistant");
|
|
126
197
|
const usage = assistants.map((message) => message.usage).filter(Boolean);
|
|
127
198
|
const badUsage = assistants.map((message) => message.usage).filter(isBadUsage);
|
|
128
|
-
const
|
|
199
|
+
const replayHits = replayErrors ? scanReplayErrors(file, records) : [];
|
|
200
|
+
const fileFailure =
|
|
201
|
+
lineCount === 0 ||
|
|
202
|
+
parseErrorCount > 0 ||
|
|
203
|
+
assistants.length === 0 ||
|
|
204
|
+
usage.length !== assistants.length ||
|
|
205
|
+
badUsage.length > 0 ||
|
|
206
|
+
replayHits.length > 0;
|
|
129
207
|
if (fileFailure) failures += 1;
|
|
130
208
|
summary = {
|
|
131
209
|
file: relative(smokeDir, file),
|
|
@@ -135,6 +213,7 @@ function main() {
|
|
|
135
213
|
assistantCount: assistants.length,
|
|
136
214
|
usageCount: usage.length,
|
|
137
215
|
badUsageCount: badUsage.length,
|
|
216
|
+
replayErrorCount: replayHits.length,
|
|
138
217
|
};
|
|
139
218
|
} catch (error) {
|
|
140
219
|
failures += 1;
|
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
|
+
}
|