pi-cursor-sdk 0.1.15 → 0.1.17

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 (46) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/README.md +20 -8
  3. package/docs/cursor-live-smoke-checklist.md +267 -0
  4. package/docs/cursor-model-ux-spec.md +15 -5
  5. package/docs/cursor-native-tool-replay.md +16 -5
  6. package/package.json +12 -5
  7. package/scripts/steering-rpc-smoke.mjs +238 -0
  8. package/scripts/tmux-live-smoke.sh +418 -0
  9. package/scripts/validate-smoke-jsonl.mjs +152 -0
  10. package/src/context.ts +180 -5
  11. package/src/cursor-bridge-contract.ts +27 -0
  12. package/src/cursor-edit-diff.ts +11 -0
  13. package/src/cursor-env-boolean.ts +22 -0
  14. package/src/cursor-live-run-accounting.ts +65 -0
  15. package/src/cursor-live-run-coordinator.ts +483 -0
  16. package/src/cursor-native-tool-display-registration.ts +93 -0
  17. package/src/cursor-native-tool-display-replay.ts +465 -0
  18. package/src/cursor-native-tool-display-state.ts +78 -0
  19. package/src/cursor-native-tool-display-tools.ts +102 -0
  20. package/src/cursor-native-tool-display.ts +10 -639
  21. package/src/cursor-partial-content-emitter.ts +121 -0
  22. package/src/cursor-pi-tool-bridge-abort.ts +133 -0
  23. package/src/cursor-pi-tool-bridge-diagnostics.ts +179 -0
  24. package/src/cursor-pi-tool-bridge-mcp.ts +118 -0
  25. package/src/cursor-pi-tool-bridge-run.ts +384 -0
  26. package/src/cursor-pi-tool-bridge-server.ts +182 -0
  27. package/src/cursor-pi-tool-bridge-snapshot.ts +88 -0
  28. package/src/cursor-pi-tool-bridge-types.ts +80 -0
  29. package/src/cursor-pi-tool-bridge.ts +77 -602
  30. package/src/cursor-provider-live-run-drain.ts +379 -0
  31. package/src/cursor-provider-turn-coordinator.ts +456 -0
  32. package/src/cursor-provider.ts +133 -1092
  33. package/src/cursor-question-tool.ts +7 -2
  34. package/src/cursor-record-utils.ts +26 -0
  35. package/src/cursor-sdk-output-filter.ts +100 -0
  36. package/src/cursor-sensitive-text.ts +37 -0
  37. package/src/cursor-session-agent.ts +372 -0
  38. package/src/cursor-session-cwd.ts +14 -19
  39. package/src/cursor-session-scope.ts +65 -0
  40. package/src/cursor-state.ts +38 -10
  41. package/src/cursor-tool-transcript.ts +28 -1229
  42. package/src/cursor-transcript-tool-formatters.ts +641 -0
  43. package/src/cursor-transcript-tool-specs.ts +441 -0
  44. package/src/cursor-transcript-utils.ts +276 -0
  45. package/src/cursor-usage-accounting.ts +71 -0
  46. package/src/index.ts +20 -3
@@ -0,0 +1,276 @@
1
+ import { closeSync, openSync, readSync, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import { asRecord, getFirstStringByKeys } from "./cursor-record-utils.js";
4
+
5
+ export { asRecord, getFirstStringByKeys } from "./cursor-record-utils.js";
6
+
7
+ export interface TranscriptOptions {
8
+ maxChars?: number;
9
+ maxLines?: number;
10
+ maxListItems?: number;
11
+ cwd?: string;
12
+ }
13
+
14
+ export interface NormalizedResult {
15
+ status: string | undefined;
16
+ value: unknown;
17
+ error: unknown;
18
+ }
19
+
20
+ interface PiToolDisplayContent {
21
+ type: "text";
22
+ text: string;
23
+ }
24
+
25
+ export interface PiToolDisplayResult {
26
+ content: PiToolDisplayContent[];
27
+ details?: unknown;
28
+ }
29
+
30
+ export interface CursorPiToolDisplay {
31
+ toolName: string;
32
+ args: Record<string, unknown>;
33
+ result: PiToolDisplayResult;
34
+ isError: boolean;
35
+ }
36
+
37
+ export const DEFAULT_MAX_TRANSCRIPT_CHARS = 24000;
38
+ export const DEFAULT_MAX_TRANSCRIPT_LINES = 800;
39
+ export const DEFAULT_MAX_LIST_ITEMS = 200;
40
+ export const DEFAULT_READ_TRANSCRIPT_CHARS = 4000;
41
+ export const DEFAULT_READ_TRANSCRIPT_LINES = 12;
42
+ export const DEFAULT_NATIVE_READ_DISPLAY_LINES = 20;
43
+ export const LOCAL_READ_PREVIEW_NOTICE =
44
+ "[local file preview at transcript time; Cursor read result content was unavailable]";
45
+
46
+ export function getString(record: Record<string, unknown> | undefined, key: string): string | undefined {
47
+ const value = record?.[key];
48
+ return typeof value === "string" ? value : undefined;
49
+ }
50
+
51
+ export function getNumber(record: Record<string, unknown> | undefined, key: string): number | undefined {
52
+ const value = record?.[key];
53
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
54
+ }
55
+
56
+ export function getBoolean(record: Record<string, unknown> | undefined, key: string): boolean | undefined {
57
+ const value = record?.[key];
58
+ return typeof value === "boolean" ? value : undefined;
59
+ }
60
+
61
+ export function getRecord(record: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
62
+ return asRecord(record?.[key]);
63
+ }
64
+
65
+ export function getArray(record: Record<string, unknown> | undefined, key: string): unknown[] | undefined {
66
+ const value = record?.[key];
67
+ return Array.isArray(value) ? value : undefined;
68
+ }
69
+
70
+ export function getToolName(toolCall: unknown): string {
71
+ const record = asRecord(toolCall);
72
+ return getString(record, "name") ?? getString(record, "type") ?? getString(record, "toolName") ?? "unknown";
73
+ }
74
+
75
+ export function getToolArgs(toolCall: unknown): Record<string, unknown> {
76
+ const record = asRecord(toolCall);
77
+ return getRecord(record, "args") ?? getRecord(record, "input") ?? {};
78
+ }
79
+
80
+ export function getToolResult(toolCall: unknown): unknown {
81
+ const record = asRecord(toolCall);
82
+ return record?.result;
83
+ }
84
+
85
+ export function normalizeToolName(name: string): string {
86
+ const normalized = name.replace(/\s+/g, " ").trim();
87
+ const normalizedKey = normalized.toLowerCase();
88
+ switch (normalizedKey) {
89
+ case "read_file":
90
+ return "read";
91
+ case "list_dir":
92
+ return "ls";
93
+ case "run_terminal_cmd":
94
+ case "terminal":
95
+ case "bash":
96
+ case "shell":
97
+ return "shell";
98
+ case "grep_search":
99
+ case "search":
100
+ return "grep";
101
+ case "file_search":
102
+ return "glob";
103
+ case "write_file":
104
+ case "writefile":
105
+ return "write";
106
+ case "strreplace":
107
+ case "str_replace":
108
+ case "str-replace":
109
+ case "edit_file":
110
+ case "editfile":
111
+ case "edit_notebook":
112
+ case "editnotebook":
113
+ case "notebook_edit":
114
+ case "notebookedit":
115
+ return "edit";
116
+ default:
117
+ return normalized || "unknown";
118
+ }
119
+ }
120
+
121
+ export function normalizeResult(result: unknown): NormalizedResult {
122
+ const record = asRecord(result);
123
+ const status = getString(record, "status");
124
+ if (status === "success" || status === "error") {
125
+ return { status, value: record?.value, error: record?.error };
126
+ }
127
+ return { status, value: result, error: undefined };
128
+ }
129
+
130
+ export function stringifyUnknown(value: unknown): string {
131
+ if (value === undefined) return "";
132
+ if (typeof value === "string") return value;
133
+ try {
134
+ return JSON.stringify(value, null, 2) ?? String(value);
135
+ } catch {
136
+ return String(value);
137
+ }
138
+ }
139
+
140
+ export function limitText(text: string, options: TranscriptOptions = {}, knownTotalLines?: number): string {
141
+ const maxChars = options.maxChars ?? DEFAULT_MAX_TRANSCRIPT_CHARS;
142
+ const maxLines = options.maxLines ?? DEFAULT_MAX_TRANSCRIPT_LINES;
143
+ const lines = text.split("\n");
144
+ let limitedLines = lines.slice(0, maxLines);
145
+ let limited = limitedLines.join("\n");
146
+ let truncatedLines = Math.max((knownTotalLines ?? lines.length) - limitedLines.length, 0);
147
+ let truncatedChars = 0;
148
+
149
+ if (limited.length > maxChars) {
150
+ truncatedChars += limited.length - maxChars;
151
+ limited = limited.slice(0, maxChars);
152
+ limitedLines = limited.split("\n");
153
+ truncatedLines = Math.max(truncatedLines, Math.max((knownTotalLines ?? lines.length) - limitedLines.length, 0));
154
+ }
155
+ if (text.length > limited.length) {
156
+ truncatedChars += Math.max(text.length - limited.length - truncatedChars, 0);
157
+ }
158
+
159
+ const suffixParts: string[] = [];
160
+ if (truncatedLines > 0) suffixParts.push(`${truncatedLines} more lines`);
161
+ if (truncatedChars > 0 && truncatedLines === 0) suffixParts.push(`${truncatedChars} more chars`);
162
+ return suffixParts.length > 0 ? `${limited}\n... (${suffixParts.join(", ")} truncated)` : limited;
163
+ }
164
+
165
+ export function limitItems<T>(items: T[], options: TranscriptOptions = {}): { items: T[]; omitted: number } {
166
+ const maxListItems = options.maxListItems ?? DEFAULT_MAX_LIST_ITEMS;
167
+ return { items: items.slice(0, maxListItems), omitted: Math.max(items.length - maxListItems, 0) };
168
+ }
169
+
170
+ export function joinSections(header: string, body?: string): string {
171
+ const trimmedBody = body?.trimEnd();
172
+ return trimmedBody ? `${header}\n\n${trimmedBody}\n` : `${header}\n`;
173
+ }
174
+
175
+ export function formatError(error: unknown): string {
176
+ const text = stringifyUnknown(error).trim();
177
+ return text ? `Error: ${text}` : "Error";
178
+ }
179
+
180
+ export function formatDisplayPath(path: string, cwd = process.cwd()): string {
181
+ const trimmed = path.trim();
182
+ if (!trimmed) return trimmed;
183
+ if (!isAbsolute(trimmed)) return trimmed;
184
+ const relativePath = relative(cwd, trimmed);
185
+ if (!relativePath || relativePath === "") return ".";
186
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) return trimmed;
187
+ return relativePath;
188
+ }
189
+
190
+ export function formatDiffPath(path: string, cwd = process.cwd()): string {
191
+ if (path === "/dev/null") return path;
192
+ return formatDisplayPath(path, cwd);
193
+ }
194
+
195
+ export function formatDiffHeaderLine(line: string, options: TranscriptOptions): string {
196
+ const match = /^(---|\+\+\+)\s+((?:[ab]\/)?)(.+)$/.exec(line);
197
+ if (!match) return line;
198
+ const [, marker, prefix, rawPath] = match;
199
+ if (!prefix && rawPath !== "/dev/null") return line;
200
+ const displayPath = formatDiffPath(rawPath, options.cwd);
201
+ return `${marker} ${prefix}${displayPath}`;
202
+ }
203
+
204
+ export function formatDiffString(diff: string | undefined, options: TranscriptOptions): string | undefined {
205
+ return diff
206
+ ?.split("\n")
207
+ .map((line) => formatDiffHeaderLine(line, options))
208
+ .join("\n");
209
+ }
210
+
211
+ export function resolveFilePath(path: string, cwd = process.cwd()): string {
212
+ return isAbsolute(path) ? path : resolve(cwd, path);
213
+ }
214
+
215
+ export function isPathWithinCwd(filePath: string, cwd = process.cwd()): boolean {
216
+ const relativePath = relative(cwd, filePath);
217
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
218
+ }
219
+
220
+ export function isSensitivePreviewPath(filePath: string): boolean {
221
+ const segments = filePath.split(/[\\/]+/).map((segment) => segment.toLowerCase());
222
+ const basename = segments.at(-1) ?? "";
223
+ return (
224
+ segments.includes(".ssh") ||
225
+ segments.includes("secrets") ||
226
+ basename === ".env" ||
227
+ basename.startsWith(".env.") ||
228
+ basename === ".npmrc" ||
229
+ basename === ".netrc" ||
230
+ basename === "credentials" ||
231
+ basename === "id_rsa" ||
232
+ basename === "id_ed25519" ||
233
+ /\.(?:pem|key|p12|pfx)$/i.test(basename)
234
+ );
235
+ }
236
+
237
+ export function readFilePreview(path: string, options: TranscriptOptions): string | undefined {
238
+ const cwd = options.cwd ?? process.cwd();
239
+ const filePath = resolveFilePath(path, cwd);
240
+
241
+ const maxChars = options.maxChars ?? DEFAULT_READ_TRANSCRIPT_CHARS;
242
+ const maxBytes = Math.max(8192, maxChars * 4);
243
+ let fd: number | undefined;
244
+ try {
245
+ const realCwd = realpathSync(cwd);
246
+ const realFilePath = realpathSync(filePath);
247
+ if (!isPathWithinCwd(realFilePath, realCwd) || isSensitivePreviewPath(filePath) || isSensitivePreviewPath(realFilePath)) return undefined;
248
+
249
+ const stat = statSync(realFilePath);
250
+ if (!stat.isFile()) return undefined;
251
+ fd = openSync(realFilePath, "r");
252
+ const buffer = Buffer.alloc(Math.min(stat.size, maxBytes));
253
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
254
+ const text = buffer.toString("utf8", 0, bytesRead);
255
+ if (text.includes("\0")) return undefined;
256
+ return text;
257
+ } catch {
258
+ return undefined;
259
+ } finally {
260
+ if (fd !== undefined) closeSync(fd);
261
+ }
262
+ }
263
+
264
+ export function formatPathArg(args: Record<string, unknown>, options: TranscriptOptions, key = "path"): string | undefined {
265
+ const path = args[key];
266
+ return typeof path === "string" && path.trim() ? formatDisplayPath(path, options.cwd) : undefined;
267
+ }
268
+
269
+
270
+ export function firstNonEmptyLine(text: string): string | undefined {
271
+ return text.split("\n").find((line) => line.trim())?.trim();
272
+ }
273
+
274
+ export function truncateArg(value: string, maxLength = 120): string {
275
+ return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
276
+ }
@@ -0,0 +1,71 @@
1
+ import type { Api, AssistantMessage, Context, Model } from "@earendil-works/pi-ai";
2
+ import {
3
+ CURSOR_APPROX_CHARS_PER_TOKEN,
4
+ CURSOR_IMAGE_TOKEN_ESTIMATE,
5
+ estimateCursorContextTokens,
6
+ estimateCursorPromptTokens,
7
+ estimateCursorTextTokens,
8
+ type CursorPrompt,
9
+ type CursorPromptOptions,
10
+ } from "./context.js";
11
+
12
+ export interface CursorUsagePromptOptions extends CursorPromptOptions {
13
+ maxInputTokens: number;
14
+ charsPerToken: number;
15
+ imageTokenEstimate: number;
16
+ }
17
+
18
+ function getPromptInputTokenBudget(model: Model<Api>): number {
19
+ const outputReserveTokens = Math.min(model.maxTokens, Math.max(1, Math.floor(model.contextWindow * 0.2)));
20
+ return Math.max(1, model.contextWindow - outputReserveTokens);
21
+ }
22
+
23
+ export function getCursorPromptOptions(model: Model<Api>): CursorUsagePromptOptions {
24
+ return {
25
+ maxInputTokens: getPromptInputTokenBudget(model),
26
+ charsPerToken: CURSOR_APPROX_CHARS_PER_TOKEN,
27
+ imageTokenEstimate: CURSOR_IMAGE_TOKEN_ESTIMATE,
28
+ };
29
+ }
30
+
31
+ export function estimateCursorPromptInputTokens(prompt: CursorPrompt, options: Pick<CursorPromptOptions, "charsPerToken" | "imageTokenEstimate">): number {
32
+ return estimateCursorPromptTokens(prompt, options);
33
+ }
34
+
35
+ function stringifyUsageValue(value: unknown): string {
36
+ try {
37
+ return JSON.stringify(value) ?? "";
38
+ } catch {
39
+ return String(value);
40
+ }
41
+ }
42
+
43
+ export function estimateCursorAssistantSessionOutputTokens(message: AssistantMessage): number {
44
+ const parts = message.content
45
+ .map((block) => {
46
+ if (block.type === "text") return block.text;
47
+ if (block.type === "thinking") return block.thinking;
48
+ if (block.type === "toolCall") {
49
+ return `Tool call (${block.name}, call ${block.id}): ${stringifyUsageValue(block.arguments)}`;
50
+ }
51
+ return "";
52
+ })
53
+ .filter(Boolean);
54
+ return estimateCursorTextTokens(parts.join("\n"), { charsPerToken: CURSOR_APPROX_CHARS_PER_TOKEN });
55
+ }
56
+
57
+ function withAssistantMessage(context: Context, partial: AssistantMessage): Context {
58
+ return { ...context, messages: [...context.messages, partial] };
59
+ }
60
+
61
+ export function estimateCursorContextTotalTokens(partial: AssistantMessage, model: Model<Api>, context: Context): number {
62
+ return estimateCursorContextTokens(withAssistantMessage(context, partial), getCursorPromptOptions(model));
63
+ }
64
+
65
+ export function applyCursorApproximateUsage(partial: AssistantMessage, model: Model<Api>, context: Context, sessionInputTokens: number): void {
66
+ partial.usage.input = sessionInputTokens;
67
+ partial.usage.output = estimateCursorAssistantSessionOutputTokens(partial);
68
+ partial.usage.cacheRead = 0;
69
+ partial.usage.cacheWrite = 0;
70
+ partial.usage.totalTokens = estimateCursorContextTotalTokens(partial, model, context);
71
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,28 @@
1
- import type { ExtensionAPI, ProviderConfig, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext, ProviderConfig, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
2
  import { discoverModels, type CursorModelFallbackIssue } from "./model-discovery.js";
3
3
  import { registerCursorFastControls } from "./cursor-state.js";
4
4
  import { registerCursorNativeToolDisplay } from "./cursor-native-tool-display.js";
5
5
  import { registerCursorPiToolBridge } from "./cursor-pi-tool-bridge.js";
6
6
  import { registerCursorQuestionTool } from "./cursor-question-tool.js";
7
7
  import { registerCursorSessionCwd } from "./cursor-session-cwd.js";
8
+ import { registerCursorSessionAgent } from "./cursor-session-agent.js";
8
9
  import { streamCursor } from "./cursor-provider.js";
9
10
 
11
+ type CursorExtensionApi =
12
+ & Pick<ExtensionAPI, "registerProvider">
13
+ & {
14
+ registerCommand(name: string, options: {
15
+ description?: string;
16
+ handler: (args: string, ctx: Pick<ExtensionContext, "hasUI"> & { ui: Pick<ExtensionContext["ui"], "notify"> }) => Promise<void> | void;
17
+ }): void;
18
+ }
19
+ & Parameters<typeof registerCursorSessionCwd>[0]
20
+ & Parameters<typeof registerCursorSessionAgent>[0]
21
+ & Parameters<typeof registerCursorFastControls>[0]
22
+ & Parameters<typeof registerCursorNativeToolDisplay>[0]
23
+ & Parameters<typeof registerCursorQuestionTool>[0]
24
+ & Parameters<typeof registerCursorPiToolBridge>[0];
25
+
10
26
  function createCursorProviderConfig(models: ProviderModelConfig[]): ProviderConfig {
11
27
  return {
12
28
  name: "Cursor",
@@ -18,13 +34,14 @@ function createCursorProviderConfig(models: ProviderModelConfig[]): ProviderConf
18
34
  };
19
35
  }
20
36
 
21
- function registerCursorProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): void {
37
+ function registerCursorProvider(pi: Pick<ExtensionAPI, "registerProvider">, models: ProviderModelConfig[]): void {
22
38
  pi.registerProvider("cursor", createCursorProviderConfig(models));
23
39
  }
24
40
 
25
- export default async function (pi: ExtensionAPI) {
41
+ export default async function (pi: CursorExtensionApi) {
26
42
  // Session cwd must register before other session_start listeners that depend on it.
27
43
  registerCursorSessionCwd(pi);
44
+ registerCursorSessionAgent(pi);
28
45
  registerCursorFastControls(pi);
29
46
  registerCursorNativeToolDisplay(pi);
30
47
  registerCursorQuestionTool(pi);