pi-cursor-sdk 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/context.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { Context, Message, ToolCall } from "@earendil-works/pi-ai";
3
+ import { convertToLlm } from "@earendil-works/pi-coding-agent";
2
4
  import type { SDKImage } from "@cursor/sdk";
5
+ import { getCursorPiBridgeContractText } from "./cursor-bridge-contract.js";
3
6
  import { getCursorReplayPromptLabel } from "./cursor-tool-names.js";
4
7
 
5
8
  export interface CursorPrompt {
@@ -13,9 +16,14 @@ export interface CursorPromptOptions {
13
16
  imageTokenEstimate?: number;
14
17
  }
15
18
 
16
- const DEFAULT_CHARS_PER_TOKEN = 4;
19
+ export const CURSOR_APPROX_CHARS_PER_TOKEN = 4;
20
+ export const CURSOR_IMAGE_TOKEN_ESTIMATE = 1200;
17
21
  const SECTION_SEPARATOR = "\n\n";
18
22
 
23
+ function normalizePiContextMessages(messages: Context["messages"]): Message[] {
24
+ return convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
25
+ }
26
+
19
27
  function isTextBlock(block: { type: string }): block is { type: "text"; text: string } {
20
28
  return block.type === "text";
21
29
  }
@@ -131,7 +139,7 @@ function applyPromptBudget(
131
139
  return [...sectionsBeforeMessages, ...messageSections.map((section) => section.text), ...sectionsAfterMessages];
132
140
  }
133
141
 
134
- const charsPerToken = options.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN;
142
+ const charsPerToken = options.charsPerToken ?? CURSOR_APPROX_CHARS_PER_TOKEN;
135
143
  const maxChars = Math.max(1, Math.floor(maxInputTokens * charsPerToken));
136
144
  const requiredMessageSections = messageSections.filter((section) => section.index === latestUserMessageIndex);
137
145
  const requiredCost = [...sectionsBeforeMessages, ...requiredMessageSections.map((section) => section.text), ...sectionsAfterMessages].reduce(
@@ -167,11 +175,177 @@ function applyPromptBudget(
167
175
  return [...sectionsBeforeMessages, ...budgetNotice, ...includedMessages, ...sectionsAfterMessages];
168
176
  }
169
177
 
178
+ export function estimateCursorTextTokens(text: string, options: Pick<CursorPromptOptions, "charsPerToken"> = {}): number {
179
+ const charsPerToken = options.charsPerToken ?? CURSOR_APPROX_CHARS_PER_TOKEN;
180
+ return Math.ceil(text.length / charsPerToken);
181
+ }
182
+
183
+ export function estimateCursorPromptTokens(prompt: CursorPrompt, options: Pick<CursorPromptOptions, "charsPerToken" | "imageTokenEstimate"> = {}): number {
184
+ return estimateCursorTextTokens(prompt.text, options) + prompt.images.length * (options.imageTokenEstimate ?? CURSOR_IMAGE_TOKEN_ESTIMATE);
185
+ }
186
+
187
+ export function estimateCursorPromptMessageTokens(message: Message, options: Pick<CursorPromptOptions, "charsPerToken"> = {}): number {
188
+ const text = formatMessage(message);
189
+ return text ? estimateCursorTextTokens(text, options) : 0;
190
+ }
191
+
192
+ export function estimateCursorContextTokens(context: Context, options: CursorPromptOptions = {}): number {
193
+ return estimateCursorPromptTokens(buildCursorPrompt(context, options), options);
194
+ }
195
+
196
+ interface CursorContextFingerprintPayload {
197
+ systemHash: string;
198
+ messageHashes: string[];
199
+ }
200
+
201
+ function hashCursorContextValue(value: string): string {
202
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
203
+ }
204
+
205
+ function serializeMessageForFingerprint(message: Message, index: number): string {
206
+ switch (message.role) {
207
+ case "user": {
208
+ const text =
209
+ typeof message.content === "string"
210
+ ? message.content
211
+ : JSON.stringify(message.content);
212
+ return hashCursorContextValue(`user:${message.timestamp ?? index}:${text}`);
213
+ }
214
+ case "assistant":
215
+ return hashCursorContextValue(`assistant:${message.timestamp ?? index}:${JSON.stringify(message.content)}`);
216
+ case "toolResult":
217
+ return hashCursorContextValue(
218
+ `toolResult:${message.timestamp ?? index}:${message.toolCallId}:${message.toolName}:${JSON.stringify(message.content)}:${message.isError === true}`,
219
+ );
220
+ }
221
+ }
222
+
223
+ function serializeRawPiMessageForFingerprint(message: Context["messages"][number], index: number): string {
224
+ const role = (message as { role?: string }).role;
225
+ switch (role) {
226
+ case "branchSummary": {
227
+ const entry = message as { summary?: string; fromId?: string; timestamp?: number };
228
+ return hashCursorContextValue(
229
+ `branchSummary:${entry.timestamp ?? index}:${entry.fromId ?? ""}:${entry.summary ?? ""}`,
230
+ );
231
+ }
232
+ case "compactionSummary": {
233
+ const entry = message as { summary?: string; tokensBefore?: number; timestamp?: number };
234
+ return hashCursorContextValue(
235
+ `compactionSummary:${entry.timestamp ?? index}:${entry.tokensBefore ?? ""}:${entry.summary ?? ""}`,
236
+ );
237
+ }
238
+ case "custom": {
239
+ const entry = message as { customType?: string; content?: unknown; timestamp?: number };
240
+ return hashCursorContextValue(
241
+ `custom:${entry.timestamp ?? index}:${entry.customType ?? ""}:${JSON.stringify(entry.content)}`,
242
+ );
243
+ }
244
+ case "bashExecution": {
245
+ const entry = message as {
246
+ command?: string;
247
+ output?: string;
248
+ exitCode?: number | null;
249
+ cancelled?: boolean;
250
+ excludeFromContext?: boolean;
251
+ timestamp?: number;
252
+ };
253
+ if (entry.excludeFromContext) {
254
+ return hashCursorContextValue(`bashExecution:excluded:${entry.timestamp ?? index}`);
255
+ }
256
+ return hashCursorContextValue(
257
+ `bashExecution:${entry.timestamp ?? index}:${entry.command ?? ""}:${entry.output ?? ""}:${entry.exitCode ?? ""}:${entry.cancelled === true}`,
258
+ );
259
+ }
260
+ default:
261
+ return serializeMessageForFingerprint(message as Message, index);
262
+ }
263
+ }
264
+
265
+ function parseCursorContextFingerprint(fingerprint: string): CursorContextFingerprintPayload | undefined {
266
+ try {
267
+ const parsed = JSON.parse(fingerprint) as CursorContextFingerprintPayload;
268
+ if (!parsed || typeof parsed.systemHash !== "string" || !Array.isArray(parsed.messageHashes)) return undefined;
269
+ if (!parsed.messageHashes.every((entry) => typeof entry === "string")) return undefined;
270
+ return parsed;
271
+ } catch {
272
+ return undefined;
273
+ }
274
+ }
275
+
276
+ export function computeCursorContextFingerprint(context: Context): string {
277
+ const payload: CursorContextFingerprintPayload = {
278
+ systemHash: hashCursorContextValue(context.systemPrompt ?? ""),
279
+ messageHashes: context.messages.map((message, index) => serializeRawPiMessageForFingerprint(message, index)),
280
+ };
281
+ return JSON.stringify(payload);
282
+ }
283
+
284
+ export function shouldBootstrapCursorSend(
285
+ sendState: { bootstrapped: boolean; contextFingerprint: string },
286
+ context: Context,
287
+ ): boolean {
288
+ if (!sendState.bootstrapped) return true;
289
+ const previous = parseCursorContextFingerprint(sendState.contextFingerprint);
290
+ if (!previous) return true;
291
+ const current = parseCursorContextFingerprint(computeCursorContextFingerprint(context));
292
+ if (!current) return true;
293
+ if (current.systemHash !== previous.systemHash) return true;
294
+ if (current.messageHashes.length < previous.messageHashes.length) return true;
295
+ if (current.messageHashes.length > previous.messageHashes.length) {
296
+ for (let index = previous.messageHashes.length; index < context.messages.length; index += 1) {
297
+ const role = (context.messages[index] as { role?: string }).role;
298
+ if (role === "branchSummary" || role === "compactionSummary") return true;
299
+ }
300
+ }
301
+ for (let index = 0; index < previous.messageHashes.length; index += 1) {
302
+ if (current.messageHashes[index] !== previous.messageHashes[index]) return true;
303
+ }
304
+ return false;
305
+ }
306
+
307
+ export function buildCursorIncrementalPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
308
+ // Incremental sends omit the full Cursor SDK tool boundary block; the session agent retains prior bootstrap context.
309
+ const messages = normalizePiContextMessages(context.messages);
310
+ const latestUserMessageIndex = getLatestUserMessageIndex(messages);
311
+ const latestUserMessage = latestUserMessageIndex >= 0 ? messages[latestUserMessageIndex] : undefined;
312
+ const latestUserText = latestUserMessage ? formatMessage(latestUserMessage) : undefined;
313
+ const sectionsBeforeMessages = [
314
+ "Continue the conversation using Cursor SDK capabilities only. Do not list, promise, or call pi-only tools from earlier context as if they were available.",
315
+ ];
316
+ if (context.systemPrompt) {
317
+ sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
318
+ }
319
+ const latestUserMessageSections =
320
+ latestUserText && latestUserMessageIndex >= 0 ? [{ index: latestUserMessageIndex, text: latestUserText }] : [];
321
+ const images = extractLatestImages(messages);
322
+ const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
323
+ const budgetOptions =
324
+ options.maxInputTokens === undefined
325
+ ? options
326
+ : { ...options, maxInputTokens: Math.max(1, options.maxInputTokens - imageTokenReserve) };
327
+ const parts = applyPromptBudget(sectionsBeforeMessages, latestUserMessageSections, [], latestUserMessageIndex, budgetOptions);
328
+ return { text: parts.join(SECTION_SEPARATOR), images };
329
+ }
330
+
331
+ export function buildCursorSendPrompt(
332
+ context: Context,
333
+ options: CursorPromptOptions,
334
+ sendState: { bootstrapped: boolean; contextFingerprint: string },
335
+ ): { prompt: CursorPrompt; bootstrap: boolean } {
336
+ const bootstrap = shouldBootstrapCursorSend(sendState, context);
337
+ if (bootstrap) {
338
+ return { prompt: buildCursorPrompt(context, options), bootstrap: true };
339
+ }
340
+ return { prompt: buildCursorIncrementalPrompt(context, options), bootstrap: false };
341
+ }
342
+
170
343
  export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
171
344
  const sectionsBeforeMessages: string[] = [
172
345
  [
173
346
  "Cursor SDK tool boundary:",
174
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(),
175
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.",
176
350
  "Use pi__cursor_ask_question for material choices if exposed.",
177
351
  "Web: use Cursor web/search/browser/MCP or say web search is not configured; do not claim WebSearch/WebFetch unless Cursor executes them.",
@@ -184,7 +358,8 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
184
358
  sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
185
359
  }
186
360
 
187
- const messageSections = context.messages
361
+ const messages = normalizePiContextMessages(context.messages);
362
+ const messageSections = messages
188
363
  .map((msg, index) => {
189
364
  const text = formatMessage(msg);
190
365
  return text ? { index, text } : undefined;
@@ -196,7 +371,7 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
196
371
  "If web research is requested, do not claim it unless a Cursor web/search/browser/MCP tool ran.",
197
372
  ].join("\n"),
198
373
  ];
199
- const images = extractLatestImages(context.messages);
374
+ const images = extractLatestImages(messages);
200
375
  const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
201
376
  const budgetOptions =
202
377
  options.maxInputTokens === undefined
@@ -206,7 +381,7 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
206
381
  sectionsBeforeMessages,
207
382
  messageSections,
208
383
  sectionsAfterMessages,
209
- getLatestUserMessageIndex(context.messages),
384
+ getLatestUserMessageIndex(messages),
210
385
  budgetOptions,
211
386
  );
212
387
  const text = parts.join(SECTION_SEPARATOR);
@@ -0,0 +1,27 @@
1
+ export const CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX = "pi__";
2
+
3
+ const CURSOR_PI_BRIDGE_CONTRACT_LINES = [
4
+ "Pi bridge contract:",
5
+ `${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}* names are live Cursor MCP bridge tool names only when exposed in the current run.`,
6
+ `Call the ${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}* MCP tool name, not the real pi tool name shown in pi history or transcripts.`,
7
+ "Bridged calls execute through normal pi tool flow, so pi shows the real pi tool name and returns a normal pi tool result.",
8
+ "Replay IDs, replay labels, and transcript tool names are display-only/context-only, not callable tools.",
9
+ "Cursor-native host tools, settings, plugins, and configured MCP servers are separate from the pi bridge.",
10
+ ] as const;
11
+
12
+ export function getCursorPiBridgeContractText(): string {
13
+ return CURSOR_PI_BRIDGE_CONTRACT_LINES.join("\n");
14
+ }
15
+
16
+ export function buildCursorPiBridgeMcpToolDescription(options: {
17
+ piToolName: string;
18
+ mcpToolName: string;
19
+ piToolDescription: string;
20
+ }): string {
21
+ return [
22
+ options.piToolDescription,
23
+ "",
24
+ getCursorPiBridgeContractText(),
25
+ `This run exposes real pi tool ${options.piToolName} as Cursor MCP tool ${options.mcpToolName}.`,
26
+ ].join("\n");
27
+ }
@@ -0,0 +1,65 @@
1
+ import type { Context, Message, ToolResultMessage } from "@earendil-works/pi-ai";
2
+ import { CURSOR_APPROX_CHARS_PER_TOKEN, estimateCursorPromptMessageTokens } from "./context.js";
3
+
4
+ export interface CursorLiveRunAccountingState {
5
+ promptInputTokens: number;
6
+ promptInputTokensReported: boolean;
7
+ consumedToolResultIds: ReadonlySet<string>;
8
+ }
9
+
10
+ export interface CursorLiveToolResultConsumption {
11
+ state: CursorLiveRunAccountingState;
12
+ toolResults: ToolResultMessage[];
13
+ toolResultInputTokens: number;
14
+ toolCallIds: string[];
15
+ }
16
+
17
+ export function createCursorLiveRunAccountingState(promptInputTokens: number): CursorLiveRunAccountingState {
18
+ return {
19
+ promptInputTokens,
20
+ promptInputTokensReported: false,
21
+ consumedToolResultIds: new Set(),
22
+ };
23
+ }
24
+
25
+ function asToolResultMessage(message: Message): ToolResultMessage | undefined {
26
+ return message.role === "toolResult" ? message : undefined;
27
+ }
28
+
29
+ export function consumeCursorLiveToolResults(
30
+ state: CursorLiveRunAccountingState,
31
+ context: Context,
32
+ isMatchingToolResult: (toolResult: ToolResultMessage) => boolean,
33
+ ): CursorLiveToolResultConsumption {
34
+ const consumedToolResultIds = new Set(state.consumedToolResultIds);
35
+ const toolResults: ToolResultMessage[] = [];
36
+ let toolResultInputTokens = 0;
37
+
38
+ for (const message of context.messages) {
39
+ const toolResult = asToolResultMessage(message);
40
+ if (!toolResult) continue;
41
+ if (consumedToolResultIds.has(toolResult.toolCallId)) continue;
42
+ if (!isMatchingToolResult(toolResult)) continue;
43
+ consumedToolResultIds.add(toolResult.toolCallId);
44
+ toolResults.push(toolResult);
45
+ toolResultInputTokens += estimateCursorPromptMessageTokens(toolResult, { charsPerToken: CURSOR_APPROX_CHARS_PER_TOKEN });
46
+ }
47
+
48
+ return {
49
+ state: { ...state, consumedToolResultIds },
50
+ toolResults,
51
+ toolResultInputTokens,
52
+ toolCallIds: toolResults.map((toolResult) => toolResult.toolCallId),
53
+ };
54
+ }
55
+
56
+ export function takeCursorLiveTurnInputTokens(
57
+ state: CursorLiveRunAccountingState,
58
+ toolResultInputTokens: number,
59
+ ): { state: CursorLiveRunAccountingState; sessionInputTokens: number } {
60
+ const promptInputTokens = state.promptInputTokensReported ? 0 : state.promptInputTokens;
61
+ return {
62
+ state: state.promptInputTokensReported ? state : { ...state, promptInputTokensReported: true },
63
+ sessionInputTokens: promptInputTokens + toolResultInputTokens,
64
+ };
65
+ }
@@ -12,6 +12,8 @@ import {
12
12
  highlightCode,
13
13
  type ExtensionAPI,
14
14
  type ExtensionContext,
15
+ type ExtensionHandler,
16
+ type SessionStartEvent,
15
17
  type ToolDefinition,
16
18
  } from "@earendil-works/pi-coding-agent";
17
19
  import { Image, Text, type Component } from "@earendil-works/pi-tui";
@@ -45,6 +47,13 @@ export interface CursorNativeToolDisplayItem extends CursorPiToolDisplay {
45
47
  const registeredNativeToolNames = new Set<NativeCursorToolName>();
46
48
  const nativeToolResults = new Map<string, CursorNativeToolDisplayItem>();
47
49
 
50
+ type CursorNativeToolRegistryApi = Pick<ExtensionAPI, "getActiveTools" | "getAllTools" | "registerTool" | "setActiveTools">;
51
+
52
+ interface CursorNativeToolDisplayExtensionApi extends CursorNativeToolRegistryApi {
53
+ on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
54
+ on(event: "model_select", handler: (event: { model: ExtensionContext["model"] }, ctx: ExtensionContext) => Promise<void> | void): void;
55
+ }
56
+
48
57
  function readBooleanEnv(name: string): boolean | undefined {
49
58
  const value = process.env[name]?.trim().toLowerCase();
50
59
  if (value === "1" || value === "true" || value === "yes" || value === "on") return true;
@@ -566,12 +575,12 @@ function createNativeCursorToolDefinition(toolName: NativeCursorToolName, cwd: s
566
575
  throw new Error(`Unsupported Cursor native replay tool: ${toolName}`);
567
576
  }
568
577
 
569
- function registerNativeCursorTool(pi: ExtensionAPI, toolName: NativeCursorToolName): void {
578
+ function registerNativeCursorTool(pi: Pick<ExtensionAPI, "registerTool">, toolName: NativeCursorToolName): void {
570
579
  const definition = createNativeCursorToolDefinition(toolName, getCursorSessionCwd());
571
580
  pi.registerTool(wrapNativeCursorTool(definition, () => createNativeCursorToolDefinition(toolName, getCursorSessionCwd())));
572
581
  }
573
582
 
574
- function hasNonBuiltinTool(pi: ExtensionAPI, toolName: NativeCursorToolName): boolean {
583
+ function hasNonBuiltinTool(pi: Pick<ExtensionAPI, "getAllTools">, toolName: NativeCursorToolName): boolean {
575
584
  const existingTool = pi.getAllTools().find((tool) => tool.name === toolName);
576
585
  return existingTool !== undefined && existingTool.sourceInfo.source !== "builtin";
577
586
  }
@@ -582,7 +591,7 @@ function isCursorModel(model: ExtensionContext["model"]): boolean {
582
591
  return model?.provider === "cursor" || model?.api === "cursor-sdk";
583
592
  }
584
593
 
585
- function syncRegisteredNativeCursorToolsForModel(pi: ExtensionAPI, model: ExtensionContext["model"]): void {
594
+ function syncRegisteredNativeCursorToolsForModel(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">, model: ExtensionContext["model"]): void {
586
595
  if (registeredNativeToolNames.size === 0) return;
587
596
  const activeToolNames = new Set(pi.getActiveTools());
588
597
  let changed = false;
@@ -602,7 +611,7 @@ function syncRegisteredNativeCursorToolsForModel(pi: ExtensionAPI, model: Extens
602
611
  if (changed) pi.setActiveTools([...activeToolNames]);
603
612
  }
604
613
 
605
- function registerAvailableNativeCursorTools(pi: ExtensionAPI, ctx: NativeRegistrationContext): void {
614
+ function registerAvailableNativeCursorTools(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): void {
606
615
  if (!isCursorNativeToolRegistrationRequested()) {
607
616
  registeredNativeToolNames.clear();
608
617
  return;
@@ -629,7 +638,7 @@ function registerAvailableNativeCursorTools(pi: ExtensionAPI, ctx: NativeRegistr
629
638
  }
630
639
  }
631
640
 
632
- export function registerCursorNativeToolDisplay(pi: ExtensionAPI): void {
641
+ export function registerCursorNativeToolDisplay(pi: CursorNativeToolDisplayExtensionApi): void {
633
642
  pi.on("session_start", (_event, ctx) => {
634
643
  registerAvailableNativeCursorTools(pi, ctx);
635
644
  });