pi-cursor-sdk 0.1.9 → 0.1.10
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 +21 -0
- package/README.md +29 -10
- package/docs/cursor-model-ux-spec.md +16 -19
- package/package.json +4 -2
- package/scripts/refresh-cursor-model-snapshots.mjs +234 -0
- package/src/context.ts +128 -35
- package/src/cursor-fallback-models.generated.ts +145 -0
- package/src/cursor-native-tool-display.ts +148 -13
- package/src/cursor-provider.ts +32 -5
- package/src/cursor-tool-transcript.ts +44 -5
- package/src/index.ts +35 -8
- package/src/model-discovery.ts +2 -142
package/src/context.ts
CHANGED
|
@@ -6,6 +6,15 @@ export interface CursorPrompt {
|
|
|
6
6
|
images: SDKImage[];
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export interface CursorPromptOptions {
|
|
10
|
+
maxInputTokens?: number;
|
|
11
|
+
charsPerToken?: number;
|
|
12
|
+
imageTokenEstimate?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CHARS_PER_TOKEN = 4;
|
|
16
|
+
const SECTION_SEPARATOR = "\n\n";
|
|
17
|
+
|
|
9
18
|
function isTextBlock(block: { type: string }): block is { type: "text"; text: string } {
|
|
10
19
|
return block.type === "text";
|
|
11
20
|
}
|
|
@@ -53,48 +62,132 @@ function formatToolCall(toolCall: ToolCall): string {
|
|
|
53
62
|
return `Tool call (${toolCall.name}, call ${toolCall.id}): ${args}`;
|
|
54
63
|
}
|
|
55
64
|
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
function formatMessage(msg: Message): string | undefined {
|
|
66
|
+
switch (msg.role) {
|
|
67
|
+
case "user": {
|
|
68
|
+
const text = formatContentBlocks(msg.content);
|
|
69
|
+
return text ? `User: ${text}` : undefined;
|
|
70
|
+
}
|
|
71
|
+
case "assistant": {
|
|
72
|
+
const blocks = Array.isArray(msg.content) ? msg.content : [{ type: "text" as const, text: String(msg.content) }];
|
|
73
|
+
const textParts: string[] = [];
|
|
74
|
+
for (const block of blocks) {
|
|
75
|
+
if (isTextBlock(block)) {
|
|
76
|
+
textParts.push(block.text);
|
|
77
|
+
} else if (isToolCallBlock(block)) {
|
|
78
|
+
textParts.push(formatToolCall(block));
|
|
79
|
+
}
|
|
80
|
+
// Omit thinking content from transcript
|
|
81
|
+
}
|
|
82
|
+
return textParts.length > 0 ? `Assistant: ${textParts.join("\n")}` : undefined;
|
|
83
|
+
}
|
|
84
|
+
case "toolResult": {
|
|
85
|
+
const text = formatContentBlocks(msg.content);
|
|
86
|
+
const label = msg.isError ? "Tool error" : "Tool result";
|
|
87
|
+
return `${label} (${msg.toolName}, call ${msg.toolCallId}): ${text}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
58
91
|
|
|
59
|
-
|
|
60
|
-
|
|
92
|
+
function getLatestUserMessageIndex(messages: Message[]): number {
|
|
93
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
94
|
+
if (messages[index].role === "user") return index;
|
|
61
95
|
}
|
|
96
|
+
return -1;
|
|
97
|
+
}
|
|
62
98
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
function getSectionCost(section: string): number {
|
|
100
|
+
return section.length + SECTION_SEPARATOR.length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function applyPromptBudget(
|
|
104
|
+
sectionsBeforeMessages: string[],
|
|
105
|
+
messageSections: Array<{ index: number; text: string }>,
|
|
106
|
+
sectionsAfterMessages: string[],
|
|
107
|
+
latestUserMessageIndex: number,
|
|
108
|
+
options: CursorPromptOptions,
|
|
109
|
+
): string[] {
|
|
110
|
+
const maxInputTokens = options.maxInputTokens;
|
|
111
|
+
if (maxInputTokens === undefined || !Number.isFinite(maxInputTokens) || maxInputTokens <= 0) {
|
|
112
|
+
return [...sectionsBeforeMessages, ...messageSections.map((section) => section.text), ...sectionsAfterMessages];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const charsPerToken = options.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN;
|
|
116
|
+
const maxChars = Math.max(1, Math.floor(maxInputTokens * charsPerToken));
|
|
117
|
+
const requiredMessageSections = messageSections.filter((section) => section.index === latestUserMessageIndex);
|
|
118
|
+
const requiredCost = [...sectionsBeforeMessages, ...requiredMessageSections.map((section) => section.text), ...sectionsAfterMessages].reduce(
|
|
119
|
+
(total, section) => total + getSectionCost(section),
|
|
120
|
+
0,
|
|
121
|
+
);
|
|
122
|
+
let remainingChars = maxChars - requiredCost;
|
|
123
|
+
const includedMessageIndexes = new Set(requiredMessageSections.map((section) => section.index));
|
|
124
|
+
let omittedMessageCount = 0;
|
|
125
|
+
|
|
126
|
+
for (let index = messageSections.length - 1; index >= 0; index -= 1) {
|
|
127
|
+
const section = messageSections[index];
|
|
128
|
+
if (includedMessageIndexes.has(section.index)) continue;
|
|
129
|
+
const cost = getSectionCost(section.text);
|
|
130
|
+
if (cost <= remainingChars) {
|
|
131
|
+
includedMessageIndexes.add(section.index);
|
|
132
|
+
remainingChars -= cost;
|
|
133
|
+
continue;
|
|
92
134
|
}
|
|
135
|
+
omittedMessageCount += messageSections
|
|
136
|
+
.slice(0, index + 1)
|
|
137
|
+
.filter((candidate) => !includedMessageIndexes.has(candidate.index)).length;
|
|
138
|
+
break;
|
|
93
139
|
}
|
|
94
140
|
|
|
95
|
-
|
|
141
|
+
const budgetNotice =
|
|
142
|
+
omittedMessageCount > 0
|
|
143
|
+
? [`[Earlier transcript omitted: ${omittedMessageCount} message${omittedMessageCount === 1 ? "" : "s"} to fit Cursor context budget]`]
|
|
144
|
+
: [];
|
|
145
|
+
const includedMessages = messageSections
|
|
146
|
+
.filter((section) => includedMessageIndexes.has(section.index))
|
|
147
|
+
.map((section) => section.text);
|
|
148
|
+
return [...sectionsBeforeMessages, ...budgetNotice, ...includedMessages, ...sectionsAfterMessages];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
|
|
152
|
+
const sectionsBeforeMessages: string[] = [
|
|
153
|
+
[
|
|
154
|
+
"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.",
|
|
159
|
+
].join("\n"),
|
|
160
|
+
];
|
|
96
161
|
|
|
162
|
+
if (context.systemPrompt) {
|
|
163
|
+
sectionsBeforeMessages.push(`System instructions from pi:\n${context.systemPrompt}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const messageSections = context.messages
|
|
167
|
+
.map((msg, index) => {
|
|
168
|
+
const text = formatMessage(msg);
|
|
169
|
+
return text ? { index, text } : undefined;
|
|
170
|
+
})
|
|
171
|
+
.filter((section): section is { index: number; text: string } => section !== undefined);
|
|
172
|
+
const sectionsAfterMessages = [
|
|
173
|
+
[
|
|
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.",
|
|
176
|
+
].join("\n"),
|
|
177
|
+
];
|
|
97
178
|
const images = extractLatestImages(context.messages);
|
|
179
|
+
const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
|
|
180
|
+
const budgetOptions =
|
|
181
|
+
options.maxInputTokens === undefined
|
|
182
|
+
? options
|
|
183
|
+
: { ...options, maxInputTokens: Math.max(1, options.maxInputTokens - imageTokenReserve) };
|
|
184
|
+
const parts = applyPromptBudget(
|
|
185
|
+
sectionsBeforeMessages,
|
|
186
|
+
messageSections,
|
|
187
|
+
sectionsAfterMessages,
|
|
188
|
+
getLatestUserMessageIndex(context.messages),
|
|
189
|
+
budgetOptions,
|
|
190
|
+
);
|
|
98
191
|
|
|
99
|
-
return { text: parts.join(
|
|
192
|
+
return { text: parts.join(SECTION_SEPARATOR), images };
|
|
100
193
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ModelListItem } from "@cursor/sdk";
|
|
2
|
+
|
|
3
|
+
// Generated/maintained fallback Cursor catalog snapshot.
|
|
4
|
+
// Refresh with: npm run refresh:cursor-snapshots -- --write
|
|
5
|
+
// Do not add secrets; this file stores public model metadata only.
|
|
6
|
+
export const FALLBACK_MODEL_ITEMS = [
|
|
7
|
+
{
|
|
8
|
+
id: "composer-2",
|
|
9
|
+
displayName: "Cursor Composer 2",
|
|
10
|
+
parameters: [
|
|
11
|
+
{
|
|
12
|
+
id: "fast",
|
|
13
|
+
displayName: "Fast",
|
|
14
|
+
values: [{ value: "false" }, { value: "true" }],
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
variants: [
|
|
18
|
+
{
|
|
19
|
+
params: [{ id: "fast", value: "true" }],
|
|
20
|
+
displayName: "Cursor Composer 2",
|
|
21
|
+
isDefault: true,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "gpt-5.5",
|
|
27
|
+
displayName: "GPT-5.5",
|
|
28
|
+
parameters: [
|
|
29
|
+
{
|
|
30
|
+
id: "context",
|
|
31
|
+
displayName: "Context",
|
|
32
|
+
values: [{ value: "1m" }, { value: "272k" }],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "reasoning",
|
|
36
|
+
displayName: "Reasoning",
|
|
37
|
+
values: [
|
|
38
|
+
{ value: "none" },
|
|
39
|
+
{ value: "low" },
|
|
40
|
+
{ value: "medium" },
|
|
41
|
+
{ value: "high" },
|
|
42
|
+
{ value: "extra-high" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "fast",
|
|
47
|
+
displayName: "Fast",
|
|
48
|
+
values: [{ value: "false" }, { value: "true" }],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
variants: [
|
|
52
|
+
{
|
|
53
|
+
params: [
|
|
54
|
+
{ id: "context", value: "1m" },
|
|
55
|
+
{ id: "reasoning", value: "medium" },
|
|
56
|
+
{ id: "fast", value: "false" },
|
|
57
|
+
],
|
|
58
|
+
displayName: "GPT-5.5",
|
|
59
|
+
isDefault: true,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "claude-sonnet-4-6",
|
|
65
|
+
displayName: "Sonnet 4.6",
|
|
66
|
+
parameters: [
|
|
67
|
+
{
|
|
68
|
+
id: "thinking",
|
|
69
|
+
displayName: "Thinking",
|
|
70
|
+
values: [{ value: "false" }, { value: "true" }],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "context",
|
|
74
|
+
displayName: "Context",
|
|
75
|
+
values: [{ value: "1m" }, { value: "200k" }],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "effort",
|
|
79
|
+
displayName: "Effort",
|
|
80
|
+
values: [
|
|
81
|
+
{ value: "low" },
|
|
82
|
+
{ value: "medium" },
|
|
83
|
+
{ value: "high" },
|
|
84
|
+
{ value: "xhigh" },
|
|
85
|
+
{ value: "max" },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "fast",
|
|
90
|
+
displayName: "Fast",
|
|
91
|
+
values: [{ value: "false" }, { value: "true" }],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
variants: [
|
|
95
|
+
{
|
|
96
|
+
params: [
|
|
97
|
+
{ id: "thinking", value: "true" },
|
|
98
|
+
{ id: "context", value: "1m" },
|
|
99
|
+
{ id: "effort", value: "medium" },
|
|
100
|
+
{ id: "fast", value: "false" },
|
|
101
|
+
],
|
|
102
|
+
displayName: "Sonnet 4.6",
|
|
103
|
+
isDefault: true,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "claude-opus-4-7",
|
|
109
|
+
displayName: "Opus 4.7",
|
|
110
|
+
parameters: [
|
|
111
|
+
{
|
|
112
|
+
id: "thinking",
|
|
113
|
+
displayName: "Thinking",
|
|
114
|
+
values: [{ value: "false" }, { value: "true" }],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "context",
|
|
118
|
+
displayName: "Context",
|
|
119
|
+
values: [{ value: "1m" }, { value: "300k" }],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "effort",
|
|
123
|
+
displayName: "Effort",
|
|
124
|
+
values: [
|
|
125
|
+
{ value: "low" },
|
|
126
|
+
{ value: "medium" },
|
|
127
|
+
{ value: "high" },
|
|
128
|
+
{ value: "xhigh" },
|
|
129
|
+
{ value: "max" },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
variants: [
|
|
134
|
+
{
|
|
135
|
+
params: [
|
|
136
|
+
{ id: "thinking", value: "true" },
|
|
137
|
+
{ id: "context", value: "1m" },
|
|
138
|
+
{ id: "effort", value: "xhigh" },
|
|
139
|
+
],
|
|
140
|
+
displayName: "Opus 4.7",
|
|
141
|
+
isDefault: true,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
] satisfies ModelListItem[];
|
|
@@ -6,14 +6,16 @@ import {
|
|
|
6
6
|
type ExtensionContext,
|
|
7
7
|
type ToolDefinition,
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import
|
|
9
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
10
|
+
import { Type, type TSchema } from "typebox";
|
|
10
11
|
import type { CursorPiToolDisplay } from "./cursor-tool-transcript.js";
|
|
11
12
|
|
|
12
|
-
const NATIVE_CURSOR_TOOL_NAMES = ["read", "bash", "ls"] as const;
|
|
13
|
+
const NATIVE_CURSOR_TOOL_NAMES = ["read", "bash", "ls", "cursor_edit", "cursor_write"] as const;
|
|
13
14
|
type NativeCursorToolName = (typeof NATIVE_CURSOR_TOOL_NAMES)[number];
|
|
14
15
|
const NATIVE_CURSOR_TOOL_DISPLAY_ENV = "PI_CURSOR_NATIVE_TOOL_DISPLAY";
|
|
15
16
|
// Registration-only kill switch for users who want transcript fallback without shadowing read/bash/ls.
|
|
16
17
|
const NATIVE_CURSOR_TOOL_REGISTRATION_ENV = "PI_CURSOR_REGISTER_NATIVE_TOOLS";
|
|
18
|
+
const cursorReplayToolSchema = Type.Object({}, { additionalProperties: true });
|
|
17
19
|
|
|
18
20
|
export interface CursorNativeToolDisplayItem extends CursorPiToolDisplay {
|
|
19
21
|
id: string;
|
|
@@ -22,6 +24,7 @@ export interface CursorNativeToolDisplayItem extends CursorPiToolDisplay {
|
|
|
22
24
|
|
|
23
25
|
const registeredNativeToolNames = new Set<NativeCursorToolName>();
|
|
24
26
|
const nativeToolResults = new Map<string, CursorNativeToolDisplayItem>();
|
|
27
|
+
let currentNativeToolCwd = process.cwd();
|
|
25
28
|
|
|
26
29
|
function readBooleanEnv(name: string): boolean | undefined {
|
|
27
30
|
const value = process.env[name]?.trim().toLowerCase();
|
|
@@ -77,11 +80,13 @@ export const __testUtils = {
|
|
|
77
80
|
reset(): void {
|
|
78
81
|
registeredNativeToolNames.clear();
|
|
79
82
|
nativeToolResults.clear();
|
|
83
|
+
currentNativeToolCwd = process.cwd();
|
|
80
84
|
},
|
|
81
85
|
};
|
|
82
86
|
|
|
83
87
|
function wrapNativeCursorTool<TParams extends TSchema, TDetails, TState>(
|
|
84
88
|
definition: ToolDefinition<TParams, TDetails, TState>,
|
|
89
|
+
getCurrentDefinition: () => ToolDefinition<TParams, TDetails, TState>,
|
|
85
90
|
): ToolDefinition<TParams, TDetails, TState> {
|
|
86
91
|
return {
|
|
87
92
|
...definition,
|
|
@@ -94,21 +99,151 @@ function wrapNativeCursorTool<TParams extends TSchema, TDetails, TState>(
|
|
|
94
99
|
terminate: cursorDisplay.terminate ?? true,
|
|
95
100
|
};
|
|
96
101
|
}
|
|
97
|
-
return
|
|
102
|
+
return getCurrentDefinition().execute(toolCallId, params, signal, onUpdate, ctx);
|
|
98
103
|
},
|
|
99
104
|
};
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
interface CursorReplayToolDetails {
|
|
108
|
+
cursorToolName?: "edit" | "write";
|
|
109
|
+
path?: string;
|
|
110
|
+
linesAdded?: number;
|
|
111
|
+
linesRemoved?: number;
|
|
112
|
+
linesCreated?: number;
|
|
113
|
+
fileSize?: number;
|
|
114
|
+
diffString?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function asCursorReplayToolDetails(value: unknown): CursorReplayToolDetails | undefined {
|
|
118
|
+
return value && typeof value === "object" ? (value as CursorReplayToolDetails) : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getCursorReplayPath(args: Record<string, unknown> | undefined, details: CursorReplayToolDetails | undefined): string {
|
|
122
|
+
const argPath = args?.path;
|
|
123
|
+
return details?.path ?? (typeof argPath === "string" && argPath.trim() ? argPath : "unknown");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type CursorReplayRenderCall = NonNullable<ToolDefinition<typeof cursorReplayToolSchema, unknown>["renderCall"]>;
|
|
127
|
+
type CursorReplayRenderResult = NonNullable<ToolDefinition<typeof cursorReplayToolSchema, unknown>["renderResult"]>;
|
|
128
|
+
type CursorReplayRenderTheme = Parameters<CursorReplayRenderCall>[1];
|
|
129
|
+
|
|
130
|
+
function formatCursorReplayDiff(diff: string, theme: CursorReplayRenderTheme, maxLines: number): string {
|
|
131
|
+
const lines = diff.split("\n");
|
|
132
|
+
const visible = lines.slice(0, maxLines);
|
|
133
|
+
const rendered = visible.map((line) => {
|
|
134
|
+
if (line.startsWith("+") && !line.startsWith("+++")) return theme.fg("success", line);
|
|
135
|
+
if (line.startsWith("-") && !line.startsWith("---")) return theme.fg("error", line);
|
|
136
|
+
return theme.fg("muted", line);
|
|
137
|
+
});
|
|
138
|
+
if (lines.length > maxLines) rendered.push(theme.fg("muted", `... (${lines.length - maxLines} more diff lines)`));
|
|
139
|
+
return rendered.join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderCursorReplayCall(
|
|
143
|
+
toolName: "cursor_edit" | "cursor_write",
|
|
144
|
+
args: Record<string, unknown> | undefined,
|
|
145
|
+
theme: CursorReplayRenderTheme,
|
|
146
|
+
isPartial: boolean,
|
|
147
|
+
): Text {
|
|
148
|
+
if (!isPartial) return new Text("", 0, 0);
|
|
149
|
+
const cursorToolName = toolName === "cursor_edit" ? "edit" : "write";
|
|
150
|
+
let text = theme.fg("toolTitle", theme.bold(`Cursor ${cursorToolName} `));
|
|
151
|
+
text += theme.fg("accent", getCursorReplayPath(args, undefined));
|
|
152
|
+
return new Text(text, 0, 0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pluralize(count: number, noun: string): string {
|
|
156
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hasCursorEditChanges(details: CursorReplayToolDetails): boolean {
|
|
160
|
+
return Boolean(details.diffString) || Boolean(details.linesAdded) || Boolean(details.linesRemoved);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function classifyCursorEditOperation(details: CursorReplayToolDetails): "created" | "deleted" | "updated" | "unchanged" {
|
|
164
|
+
if (!hasCursorEditChanges(details)) return "unchanged";
|
|
165
|
+
if (details.diffString?.startsWith("--- /dev/null")) return "created";
|
|
166
|
+
if (details.diffString?.includes("\n+++ /dev/null")) return "deleted";
|
|
167
|
+
return "updated";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function formatCursorEditSummary(details: CursorReplayToolDetails): string {
|
|
171
|
+
const operation = classifyCursorEditOperation(details);
|
|
172
|
+
if (operation === "unchanged") return "no changes needed";
|
|
173
|
+
if (operation === "created" && details.linesAdded !== undefined) return `created ${pluralize(details.linesAdded, "line")}`;
|
|
174
|
+
if (operation === "deleted" && details.linesRemoved !== undefined) return `deleted ${pluralize(details.linesRemoved, "line")}`;
|
|
175
|
+
const parts = [
|
|
176
|
+
details.linesAdded ? `added ${pluralize(details.linesAdded, "line")}` : undefined,
|
|
177
|
+
details.linesRemoved ? `removed ${pluralize(details.linesRemoved, "line")}` : undefined,
|
|
178
|
+
].filter((part): part is string => Boolean(part));
|
|
179
|
+
return parts.length > 0 ? parts.join(", ") : "updated file";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderCursorReplayResult(
|
|
183
|
+
result: Parameters<CursorReplayRenderResult>[0],
|
|
184
|
+
options: Parameters<CursorReplayRenderResult>[1],
|
|
185
|
+
theme: Parameters<CursorReplayRenderResult>[2],
|
|
186
|
+
isError: boolean,
|
|
187
|
+
): Text {
|
|
188
|
+
if (options.isPartial) return new Text(theme.fg("warning", "Replaying Cursor tool result..."), 0, 0);
|
|
189
|
+
const details = asCursorReplayToolDetails(result.details);
|
|
190
|
+
const content = result.content[0];
|
|
191
|
+
const text = content?.type === "text" ? content.text : "";
|
|
192
|
+
if (isError) return new Text(theme.fg("error", text.split("\n")[0] || "Cursor replay failed"), 0, 0);
|
|
193
|
+
|
|
194
|
+
if (details?.cursorToolName === "edit") {
|
|
195
|
+
const summary = formatCursorEditSummary(details);
|
|
196
|
+
let rendered = `${theme.fg("toolTitle", theme.bold(`Cursor ${classifyCursorEditOperation(details)}`))} ${theme.fg("accent", getCursorReplayPath(undefined, details))} ${theme.fg("success", summary)}`;
|
|
197
|
+
if (details.diffString) rendered += options.expanded ? `\n${formatCursorReplayDiff(details.diffString, theme, 40)}` : theme.fg("muted", " (expand for diff)");
|
|
198
|
+
return new Text(rendered, 0, 0);
|
|
106
199
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
200
|
+
|
|
201
|
+
if (details?.cursorToolName === "write") {
|
|
202
|
+
const parts = [
|
|
203
|
+
details.linesCreated !== undefined ? `${details.linesCreated} line${details.linesCreated === 1 ? "" : "s"}` : undefined,
|
|
204
|
+
details.fileSize !== undefined ? `${details.fileSize} bytes` : undefined,
|
|
205
|
+
].filter(Boolean);
|
|
206
|
+
const summary = parts.length > 0 ? parts.join(", ") : "written";
|
|
207
|
+
return new Text(
|
|
208
|
+
`${theme.fg("toolTitle", theme.bold("Cursor write"))} ${theme.fg("accent", getCursorReplayPath(undefined, details))} ${theme.fg("success", summary)}`,
|
|
209
|
+
0,
|
|
210
|
+
0,
|
|
211
|
+
);
|
|
110
212
|
}
|
|
111
|
-
|
|
213
|
+
|
|
214
|
+
return new Text(text || theme.fg("success", "Cursor tool result replayed"), 0, 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function createCursorReplayOnlyToolDefinition(toolName: "cursor_edit" | "cursor_write"): ToolDefinition<typeof cursorReplayToolSchema, unknown> {
|
|
218
|
+
const cursorToolName = toolName === "cursor_edit" ? "edit" : "write";
|
|
219
|
+
return {
|
|
220
|
+
name: toolName,
|
|
221
|
+
label: `Cursor ${cursorToolName}`,
|
|
222
|
+
description: `Replay display for a Cursor SDK ${cursorToolName} operation. This tool only returns recorded Cursor results and never mutates files directly.`,
|
|
223
|
+
parameters: cursorReplayToolSchema,
|
|
224
|
+
renderShell: "self",
|
|
225
|
+
async execute() {
|
|
226
|
+
throw new Error(`No recorded Cursor ${cursorToolName} result was available. This replay-only tool does not execute file mutations.`);
|
|
227
|
+
},
|
|
228
|
+
renderCall(args, theme, context) {
|
|
229
|
+
return renderCursorReplayCall(toolName, args as Record<string, unknown>, theme, context.isPartial);
|
|
230
|
+
},
|
|
231
|
+
renderResult(result, options, theme, context) {
|
|
232
|
+
return renderCursorReplayResult(result, options, theme, context.isError);
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function createNativeCursorToolDefinition(toolName: NativeCursorToolName, cwd: string): ToolDefinition<TSchema, unknown, unknown> {
|
|
238
|
+
if (toolName === "read") return createReadToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
239
|
+
if (toolName === "bash") return createBashToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
240
|
+
if (toolName === "ls") return createLsToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
|
|
241
|
+
return createCursorReplayOnlyToolDefinition(toolName) as ToolDefinition<TSchema, unknown, unknown>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function registerNativeCursorTool(pi: ExtensionAPI, toolName: NativeCursorToolName): void {
|
|
245
|
+
const definition = createNativeCursorToolDefinition(toolName, currentNativeToolCwd);
|
|
246
|
+
pi.registerTool(wrapNativeCursorTool(definition, () => createNativeCursorToolDefinition(toolName, currentNativeToolCwd)));
|
|
112
247
|
}
|
|
113
248
|
|
|
114
249
|
function hasNonBuiltinTool(pi: ExtensionAPI, toolName: NativeCursorToolName): boolean {
|
|
@@ -122,7 +257,7 @@ function registerAvailableNativeCursorTools(pi: ExtensionAPI, ctx: ExtensionCont
|
|
|
122
257
|
return;
|
|
123
258
|
}
|
|
124
259
|
|
|
125
|
-
|
|
260
|
+
currentNativeToolCwd = ctx.cwd;
|
|
126
261
|
const skippedToolNames: string[] = [];
|
|
127
262
|
for (const toolName of NATIVE_CURSOR_TOOL_NAMES) {
|
|
128
263
|
if (registeredNativeToolNames.has(toolName)) continue;
|
|
@@ -130,7 +265,7 @@ function registerAvailableNativeCursorTools(pi: ExtensionAPI, ctx: ExtensionCont
|
|
|
130
265
|
skippedToolNames.push(toolName);
|
|
131
266
|
continue;
|
|
132
267
|
}
|
|
133
|
-
registerNativeCursorTool(pi, toolName
|
|
268
|
+
registerNativeCursorTool(pi, toolName);
|
|
134
269
|
registeredNativeToolNames.add(toolName);
|
|
135
270
|
}
|
|
136
271
|
|
package/src/cursor-provider.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type AssistantMessage,
|
|
9
9
|
} from "@earendil-works/pi-ai";
|
|
10
10
|
import { Agent, createAgentPlatform } from "@cursor/sdk";
|
|
11
|
-
import type { InteractionUpdate, SDKAgent } from "@cursor/sdk";
|
|
11
|
+
import type { InteractionUpdate, SDKAgent, SettingSource } from "@cursor/sdk";
|
|
12
12
|
import { buildCursorPrompt, type CursorPrompt } from "./context.js";
|
|
13
13
|
import { getEffectiveFastForModelId } from "./cursor-state.js";
|
|
14
14
|
import { buildCursorModelSelection } from "./model-discovery.js";
|
|
@@ -61,6 +61,7 @@ const IMAGE_TOKEN_ESTIMATE = 1200;
|
|
|
61
61
|
const CURSOR_ACTIVITY_TRACE_MAX_CHARS = 50000;
|
|
62
62
|
const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
|
|
63
63
|
const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
|
|
64
|
+
const CURSOR_SETTING_SOURCES_ENV = "PI_CURSOR_SETTING_SOURCES";
|
|
64
65
|
|
|
65
66
|
type CursorNativeQueuedEvent =
|
|
66
67
|
| { type: "thinking-delta"; text: string }
|
|
@@ -131,6 +132,18 @@ function resolveCursorApiKey(apiKey?: string): string | undefined {
|
|
|
131
132
|
return trimmed;
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
function resolveCursorSettingSources(): SettingSource[] | undefined {
|
|
136
|
+
const raw = process.env[CURSOR_SETTING_SOURCES_ENV]?.trim();
|
|
137
|
+
if (!raw) return undefined;
|
|
138
|
+
const normalized = raw.toLowerCase();
|
|
139
|
+
if (["0", "false", "off", "none", "omit", "disabled"].includes(normalized)) return undefined;
|
|
140
|
+
if (["1", "true", "on", "all"].includes(normalized)) return ["all"];
|
|
141
|
+
return raw
|
|
142
|
+
.split(",")
|
|
143
|
+
.map((entry) => entry.trim())
|
|
144
|
+
.filter((entry): entry is SettingSource => Boolean(entry));
|
|
145
|
+
}
|
|
146
|
+
|
|
134
147
|
function sanitizeError(error: unknown, apiKey?: string): string {
|
|
135
148
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
136
149
|
if (message === MISSING_API_KEY_MESSAGE) return MISSING_API_KEY_MESSAGE;
|
|
@@ -172,6 +185,11 @@ function estimatePromptInputTokens(prompt: CursorPrompt): number {
|
|
|
172
185
|
return estimateTextTokens(prompt.text) + prompt.images.length * IMAGE_TOKEN_ESTIMATE;
|
|
173
186
|
}
|
|
174
187
|
|
|
188
|
+
function getPromptInputTokenBudget(model: Model<Api>): number {
|
|
189
|
+
const outputReserveTokens = Math.min(model.maxTokens, Math.max(1, Math.floor(model.contextWindow * 0.2)));
|
|
190
|
+
return Math.max(1, model.contextWindow - outputReserveTokens);
|
|
191
|
+
}
|
|
192
|
+
|
|
175
193
|
function setApproximateUsage(partial: AssistantMessage, promptInputTokens: number, outputText: string): void {
|
|
176
194
|
partial.usage.input = promptInputTokens;
|
|
177
195
|
partial.usage.output = estimateTextTokens(outputText);
|
|
@@ -557,20 +575,25 @@ export function streamCursor(
|
|
|
557
575
|
if (!apiKey) throw new Error(MISSING_API_KEY_MESSAGE);
|
|
558
576
|
resolvedApiKey = apiKey;
|
|
559
577
|
|
|
578
|
+
// pi-ai Context/SimpleStreamOptions do not currently expose ExtensionContext.cwd;
|
|
579
|
+
// provider calls use the process cwd until pi exposes a session cwd to streamSimple.
|
|
560
580
|
const cwd = process.cwd();
|
|
561
581
|
const fastEnabled = getEffectiveFastForModelId(model.id);
|
|
562
582
|
const selection = buildCursorModelSelection(model.id, options?.reasoning ?? "off", fastEnabled);
|
|
583
|
+
const settingSources = resolveCursorSettingSources();
|
|
563
584
|
|
|
564
585
|
agent = await Agent.create({
|
|
565
586
|
apiKey,
|
|
566
587
|
model: selection,
|
|
567
|
-
|
|
568
|
-
// setting/rule loading INFO logs directly to process output, which corrupts pi's TUI.
|
|
569
|
-
local: { cwd },
|
|
588
|
+
local: settingSources ? { cwd, settingSources } : { cwd },
|
|
570
589
|
});
|
|
571
590
|
throwIfAborted();
|
|
572
591
|
|
|
573
|
-
const prompt = buildCursorPrompt(context
|
|
592
|
+
const prompt = buildCursorPrompt(context, {
|
|
593
|
+
maxInputTokens: getPromptInputTokenBudget(model),
|
|
594
|
+
charsPerToken: APPROX_CHARS_PER_TOKEN,
|
|
595
|
+
imageTokenEstimate: IMAGE_TOKEN_ESTIMATE,
|
|
596
|
+
});
|
|
574
597
|
const promptInputTokens = estimatePromptInputTokens(prompt);
|
|
575
598
|
let thinkingContentIndex = -1;
|
|
576
599
|
let activityTraceChars = 0;
|
|
@@ -718,6 +741,10 @@ export function streamCursor(
|
|
|
718
741
|
}
|
|
719
742
|
|
|
720
743
|
if (useNativeToolReplay && canRenderCursorToolNatively(display.toolName) && liveRun) {
|
|
744
|
+
if (!nativeToolReplayStarted && textDeltas.length > 0) {
|
|
745
|
+
for (const text of textDeltas) queueCursorNativeEvent(liveRun, { type: "text-delta", text });
|
|
746
|
+
textDeltas.length = 0;
|
|
747
|
+
}
|
|
721
748
|
nativeToolReplayStarted = true;
|
|
722
749
|
const id = `${nativeReplayId}-tool-${++nativeToolDisplayCounter}`;
|
|
723
750
|
queueCursorNativeEvent(liveRun, {
|