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,641 @@
1
+ import { resolveCursorEditDiff } from "./cursor-edit-diff.js";
2
+ import { getFirstStringByKeys } from "./cursor-record-utils.js";
3
+ import {
4
+ asRecord,
5
+ formatDisplayPath,
6
+ formatDiffHeaderLine,
7
+ formatDiffString,
8
+ formatError,
9
+ formatPathArg,
10
+ getArray,
11
+ getBoolean,
12
+ getNumber,
13
+ getRecord,
14
+ getString,
15
+ joinSections,
16
+ limitItems,
17
+ limitText,
18
+ LOCAL_READ_PREVIEW_NOTICE,
19
+ DEFAULT_READ_TRANSCRIPT_CHARS,
20
+ DEFAULT_READ_TRANSCRIPT_LINES,
21
+ DEFAULT_NATIVE_READ_DISPLAY_LINES,
22
+ readFilePreview,
23
+ stringifyUnknown,
24
+ firstNonEmptyLine,
25
+ truncateArg,
26
+ type NormalizedResult,
27
+ type TranscriptOptions,
28
+ } from "./cursor-transcript-utils.js";
29
+
30
+ function getReadContent(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
31
+ const rawPath = typeof args.path === "string" ? args.path : undefined;
32
+ const readOptions = {
33
+ ...options,
34
+ maxChars: options.maxChars ?? DEFAULT_READ_TRANSCRIPT_CHARS,
35
+ maxLines: options.maxLines ?? DEFAULT_READ_TRANSCRIPT_LINES,
36
+ };
37
+ const value = asRecord(result.value);
38
+ const resultContent = getString(value, "content");
39
+ if (resultContent && resultContent.length > 0) return resultContent;
40
+ if (!rawPath) return stringifyUnknown(result.value);
41
+ const localPreview = readFilePreview(rawPath, readOptions);
42
+ return localPreview ? `${LOCAL_READ_PREVIEW_NOTICE}\n${localPreview}` : stringifyUnknown(result.value);
43
+ }
44
+
45
+ export function formatRead(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
46
+ const rawPath = typeof args.path === "string" ? args.path : undefined;
47
+ const path = rawPath ? formatDisplayPath(rawPath, options.cwd) : "unknown";
48
+ if (result.status === "error") return joinSections(`read ${path}`, formatError(result.error));
49
+
50
+ const value = asRecord(result.value);
51
+ const totalLines = getNumber(value, "totalLines");
52
+ const readOptions = {
53
+ ...options,
54
+ maxChars: options.maxChars ?? DEFAULT_READ_TRANSCRIPT_CHARS,
55
+ maxLines: options.maxLines ?? DEFAULT_READ_TRANSCRIPT_LINES,
56
+ };
57
+ return joinSections(`read ${path}`, limitText(getReadContent(args, result, options), readOptions, totalLines));
58
+ }
59
+
60
+ export function buildReadDisplayArgs(args: Record<string, unknown>, options: TranscriptOptions): Record<string, unknown> {
61
+ const rawPath = typeof args.path === "string" ? args.path : undefined;
62
+ return rawPath ? { ...args, path: formatDisplayPath(rawPath, options.cwd) } : args;
63
+ }
64
+
65
+ function buildPathDisplayArgs(args: Record<string, unknown>, options: TranscriptOptions): Record<string, unknown> {
66
+ const rawPath = typeof args.path === "string" ? args.path : undefined;
67
+ return rawPath ? { ...args, path: formatDisplayPath(rawPath, options.cwd) } : args;
68
+ }
69
+
70
+ export function buildWriteDisplayArgs(args: Record<string, unknown>, options: TranscriptOptions): Record<string, unknown> {
71
+ const displayArgs = buildPathDisplayArgs(args, options);
72
+ const content = getCursorWriteArgContent(args);
73
+ return content === undefined ? displayArgs : { ...displayArgs, content };
74
+ }
75
+
76
+ type NativeEditReplacement = { oldText: string; newText: string };
77
+ type NativeEditDisplayArgs = { path: string; edits: NativeEditReplacement[] };
78
+
79
+ const CURSOR_EDIT_PATH_KEYS = ["path", "filePath", "file_path"] as const;
80
+ const CURSOR_EDIT_OLD_TEXT_KEYS = ["oldText", "old_text", "oldString", "old_string", "oldStr", "old_str"] as const;
81
+ const CURSOR_EDIT_NEW_TEXT_KEYS = ["newText", "new_text", "newString", "new_string", "newStr", "new_str"] as const;
82
+ const CURSOR_NOTEBOOK_EDIT_ARG_KEYS = ["cellId", "cell_id", "cellIndex", "cell_index", "cellType", "cell_type", "notebookPath", "notebook_path"] as const;
83
+
84
+ function getCursorEditPathArg(args: Record<string, unknown>): string | undefined {
85
+ const path = getFirstStringByKeys(args, CURSOR_EDIT_PATH_KEYS);
86
+ return path?.trim() ? path : undefined;
87
+ }
88
+
89
+ function isCursorNotebookEditToolName(toolName: string): boolean {
90
+ const normalized = toolName.replace(/[\s_-]+/g, "").toLowerCase();
91
+ return normalized === "editnotebook" || normalized === "notebookedit";
92
+ }
93
+
94
+ function isCursorStrReplaceToolName(toolName: string): boolean {
95
+ const normalized = toolName.replace(/[\s_-]+/g, "").toLowerCase();
96
+ return normalized === "strreplace";
97
+ }
98
+
99
+ function hasAnyKey(record: Record<string, unknown>, keys: readonly string[]): boolean {
100
+ return keys.some((key) => record[key] !== undefined);
101
+ }
102
+
103
+ function isNotebookPath(path: string | undefined): boolean {
104
+ return path?.toLowerCase().endsWith(".ipynb") === true;
105
+ }
106
+
107
+ function isCursorNotebookEditActivity(rawToolName: string, args: Record<string, unknown>): boolean {
108
+ if (isCursorNotebookEditToolName(rawToolName)) return true;
109
+ if (hasAnyKey(args, CURSOR_NOTEBOOK_EDIT_ARG_KEYS)) return true;
110
+ return !isCursorStrReplaceToolName(rawToolName) && isNotebookPath(getCursorEditPathArg(args));
111
+ }
112
+
113
+ function asNativeEditReplacement(value: unknown): NativeEditReplacement | undefined {
114
+ const record = asRecord(value);
115
+ const oldText = record ? getFirstStringByKeys(record, CURSOR_EDIT_OLD_TEXT_KEYS) : undefined;
116
+ const newText = record ? getFirstStringByKeys(record, CURSOR_EDIT_NEW_TEXT_KEYS) : undefined;
117
+ if (typeof oldText !== "string" || oldText.length === 0 || typeof newText !== "string") return undefined;
118
+ return { oldText, newText };
119
+ }
120
+
121
+ function getNativeEditReplacementsFromArgs(args: Record<string, unknown>): NativeEditReplacement[] | undefined {
122
+ const edits = getArray(args, "edits")?.map(asNativeEditReplacement);
123
+ if (edits && edits.length > 0 && edits.every((edit): edit is NativeEditReplacement => edit !== undefined)) return edits;
124
+
125
+ const singleEdit = asNativeEditReplacement(args);
126
+ return singleEdit ? [singleEdit] : undefined;
127
+ }
128
+
129
+ export function buildNativeEditDisplayArgs(rawToolName: string, args: Record<string, unknown>, options: TranscriptOptions): NativeEditDisplayArgs | undefined {
130
+ if (isCursorNotebookEditActivity(rawToolName, args)) return undefined;
131
+ const rawPath = getCursorEditPathArg(args);
132
+ const edits = getNativeEditReplacementsFromArgs(args);
133
+ if (!rawPath || !edits) return undefined;
134
+ return { path: formatDisplayPath(rawPath, options.cwd), edits };
135
+ }
136
+
137
+ export function buildCursorEditActivityDisplayArgs(args: Record<string, unknown>, options: TranscriptOptions): Record<string, unknown> {
138
+ const rawPath = getCursorEditPathArg(args);
139
+ return rawPath ? { ...args, path: formatDisplayPath(rawPath, options.cwd) } : args;
140
+ }
141
+
142
+ export function formatNativeReadDisplayContent(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
143
+ const value = asRecord(result.value);
144
+ const totalLines = getNumber(value, "totalLines");
145
+ const readOptions = {
146
+ ...options,
147
+ maxChars: options.maxChars ?? DEFAULT_READ_TRANSCRIPT_CHARS,
148
+ maxLines: options.maxLines ?? DEFAULT_NATIVE_READ_DISPLAY_LINES,
149
+ };
150
+ const content = getReadContent(args, result, readOptions);
151
+ if (totalLines === undefined) return limitText(content, readOptions);
152
+
153
+ const maxLines = readOptions.maxLines ?? DEFAULT_NATIVE_READ_DISPLAY_LINES;
154
+ const lines = content.split("\n");
155
+ const visible = lines.slice(0, maxLines).join("\n");
156
+ if (totalLines <= maxLines && lines.length <= maxLines) return visible;
157
+ if (visible.length > (readOptions.maxChars ?? DEFAULT_READ_TRANSCRIPT_CHARS)) return limitText(content, readOptions, totalLines);
158
+ return `${visible}\n\n[${Math.max(totalLines - maxLines, 0)} more lines in file. Use offset=${maxLines + 1} to continue.]`;
159
+ }
160
+
161
+ export function getShellOutput(result: NormalizedResult, args: Record<string, unknown> = {}): { text: string; exitCode: number | undefined; timedOut: boolean } {
162
+ const value = asRecord(result.value);
163
+ const stdout = getString(value, "stdout") ?? "";
164
+ const stderr = getString(value, "stderr") ?? "";
165
+ const exitCode = getNumber(value, "exitCode");
166
+ const timeoutMs = getNumber(args, "timeout");
167
+ const executionTimeMs = getNumber(value, "executionTime");
168
+ const timedOut = timeoutMs !== undefined && executionTimeMs !== undefined && executionTimeMs >= timeoutMs;
169
+ const outputParts: string[] = [];
170
+ if (stdout) outputParts.push(stdout.trimEnd());
171
+ if (stderr) outputParts.push(stderr.trimEnd());
172
+ if (exitCode !== undefined && exitCode !== 0) outputParts.push(`Command exited with code ${exitCode}`);
173
+ if (timedOut) outputParts.push(`Command backgrounded after ${(timeoutMs / 1000).toFixed(0)} second timeout`);
174
+ return { text: outputParts.filter(Boolean).join("\n\n") || "(no output)", exitCode, timedOut };
175
+ }
176
+
177
+ export function buildShellDisplayArgs(args: Record<string, unknown>): Record<string, unknown> {
178
+ const command = typeof args.command === "string" ? args.command : undefined;
179
+ const timeoutMs = getNumber(args, "timeout");
180
+ const displayArgs: Record<string, unknown> = command ? { command } : { ...args };
181
+ if (timeoutMs !== undefined) {
182
+ displayArgs.timeout = timeoutMs / 1000;
183
+ }
184
+ return displayArgs;
185
+ }
186
+
187
+ export function formatShell(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
188
+ const command = typeof args.command === "string" ? args.command : stringifyUnknown(args).trim();
189
+ if (result.status === "error") return joinSections(`$ ${command || "shell"}`, formatError(result.error));
190
+
191
+ const value = asRecord(result.value);
192
+ const executionTime = getNumber(value, "executionTime");
193
+ const outputParts = [getShellOutput(result, args).text];
194
+ if (executionTime !== undefined) outputParts.push(`Took ${(executionTime / 1000).toFixed(1)}s`);
195
+ return joinSections(`$ ${command || "shell"}`, limitText(outputParts.filter(Boolean).join("\n\n"), options));
196
+ }
197
+
198
+ function renderTreeNode(node: unknown, depth = 0, lines: string[] = []): string[] {
199
+ const record = asRecord(node);
200
+ if (!record) return lines;
201
+ const name = getString(record, "name") ?? getString(record, "path") ?? getString(record, "relativePath") ?? "";
202
+ const indent = " ".repeat(depth);
203
+ if (name) lines.push(`${indent}${name}`);
204
+ const children = getArray(record, "children") ?? getArray(record, "entries") ?? getArray(record, "files") ?? [];
205
+ for (const child of children) renderTreeNode(child, depth + 1, lines);
206
+ return lines;
207
+ }
208
+
209
+ export function getLsBody(result: NormalizedResult, options: TranscriptOptions): string {
210
+ const value = asRecord(result.value);
211
+ const root = value?.directoryTreeRoot ?? result.value;
212
+ const treeLines = renderTreeNode(root);
213
+ const body = treeLines.length > 0 ? treeLines.join("\n") : stringifyUnknown(result.value);
214
+ return limitText(body, options);
215
+ }
216
+
217
+ export function formatLs(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
218
+ const path = formatPathArg(args, options) ?? ".";
219
+ if (result.status === "error") return joinSections(`ls ${path}`, formatError(result.error));
220
+ return joinSections(`ls ${path}`, getLsBody(result, options));
221
+ }
222
+
223
+ export function formatGlob(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
224
+ const header = `$ ${synthesizeGlobBashCommand(args, options)}`;
225
+ if (result.status === "error") return joinSections(header, formatError(result.error));
226
+ return joinSections(header, getGlobBody(result, options));
227
+ }
228
+
229
+ function formatSearchCount(totalMatches: number): string {
230
+ return totalMatches === 1 ? "1 match" : `${totalMatches} matches`;
231
+ }
232
+
233
+ function formatSearchFile(file: string): string {
234
+ return file.endsWith(":") ? file.slice(0, -1) : file;
235
+ }
236
+
237
+ function collectSearchResults(value: unknown): string[] {
238
+ const record = asRecord(value);
239
+ const outputs: unknown[] = [];
240
+ const activeEditorResult = record?.activeEditorResult;
241
+ if (activeEditorResult) outputs.push(activeEditorResult);
242
+ const workspaceResults = asRecord(record?.workspaceResults);
243
+ if (workspaceResults) outputs.push(...Object.values(workspaceResults));
244
+ if (outputs.length === 0) outputs.push(value);
245
+
246
+ const lines: string[] = [];
247
+ let sawExplicitNoMatches = false;
248
+ for (const outputValue of outputs) {
249
+ const outputRecord = asRecord(outputValue);
250
+ const type = getString(outputRecord, "type");
251
+ const output = getRecord(outputRecord, "output");
252
+ if (type === "content") {
253
+ const matches = getArray(output, "matches") ?? [];
254
+ if (matches.length === 0 && getNumber(output, "totalMatches") === 0) sawExplicitNoMatches = true;
255
+ for (const match of matches) {
256
+ const matchRecord = asRecord(match);
257
+ const file = formatSearchFile(getString(matchRecord, "file") ?? "");
258
+ const lineNumber = getNumber(matchRecord, "lineNumber");
259
+ const line = getString(matchRecord, "line") ?? "";
260
+ if (lineNumber === undefined && !line.trim()) {
261
+ if (file) lines.push(file);
262
+ continue;
263
+ }
264
+ const location = `${file}${lineNumber !== undefined ? `:${lineNumber}` : ""}`;
265
+ lines.push(line ? `${location}: ${line}` : location);
266
+ }
267
+ } else if (type === "files") {
268
+ const files = getArray(output, "files") ?? [];
269
+ if (files.length === 0 && getNumber(output, "totalMatches") === 0) sawExplicitNoMatches = true;
270
+ lines.push(...files.filter((entry): entry is string => typeof entry === "string").map(formatSearchFile));
271
+ } else if (type === "count") {
272
+ const counts = getArray(output, "counts") ?? [];
273
+ if (counts.length === 0 && getNumber(output, "totalMatches") === 0) sawExplicitNoMatches = true;
274
+ for (const count of counts) {
275
+ const countRecord = asRecord(count);
276
+ lines.push(`${getString(countRecord, "file") ?? ""}: ${getNumber(countRecord, "count") ?? 0}`.trim());
277
+ }
278
+ } else {
279
+ const totalMatches = getNumber(outputRecord, "totalMatches");
280
+ if (totalMatches !== undefined) {
281
+ if (totalMatches === 0) {
282
+ sawExplicitNoMatches = true;
283
+ continue;
284
+ }
285
+ lines.push(formatSearchCount(totalMatches));
286
+ continue;
287
+ }
288
+ lines.push(stringifyUnknown(outputValue));
289
+ }
290
+ }
291
+
292
+ const topLevelTotalMatches = getNumber(record, "totalMatches");
293
+ if (lines.length === 0 && topLevelTotalMatches !== undefined) {
294
+ return topLevelTotalMatches === 0 ? ["(no matches)"] : [formatSearchCount(topLevelTotalMatches)];
295
+ }
296
+ if (lines.length === 0 && sawExplicitNoMatches) return ["(no matches)"];
297
+ return lines.filter(Boolean);
298
+ }
299
+
300
+ function synthesizeGrepBashCommand(args: Record<string, unknown>, options: TranscriptOptions): string {
301
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
302
+ const path = typeof args.path === "string" ? formatDisplayPath(args.path, options.cwd) : undefined;
303
+ const glob = typeof args.glob === "string" ? args.glob : undefined;
304
+ return ["grep", pattern && JSON.stringify(pattern), path ?? glob].filter(Boolean).join(" ");
305
+ }
306
+
307
+ export function buildGrepDisplayArgs(args: Record<string, unknown>, options: TranscriptOptions): Record<string, unknown> {
308
+ const displayArgs: Record<string, unknown> = {};
309
+ const pattern = typeof args.pattern === "string" ? args.pattern : undefined;
310
+ const path = typeof args.path === "string" ? formatDisplayPath(args.path, options.cwd) : undefined;
311
+ const glob = typeof args.glob === "string" ? args.glob : undefined;
312
+ const ignoreCase = getBoolean(args, "caseInsensitive");
313
+ const context = getNumber(args, "context") ?? getNumber(args, "contextBefore") ?? getNumber(args, "contextAfter");
314
+ const limit = getNumber(args, "headLimit");
315
+ if (pattern !== undefined) displayArgs.pattern = pattern;
316
+ if (path !== undefined) displayArgs.path = path;
317
+ if (glob !== undefined) displayArgs.glob = glob;
318
+ if (ignoreCase !== undefined) displayArgs.ignoreCase = ignoreCase;
319
+ if (context !== undefined) displayArgs.context = context;
320
+ if (limit !== undefined) displayArgs.limit = limit;
321
+ return Object.keys(displayArgs).length > 0 ? displayArgs : args;
322
+ }
323
+
324
+ function getGlobPattern(args: Record<string, unknown>): string {
325
+ return typeof args.globPattern === "string" ? args.globPattern : typeof args.pattern === "string" ? args.pattern : "*";
326
+ }
327
+
328
+ function getGlobTargetDirectory(args: Record<string, unknown>, options: TranscriptOptions): string | undefined {
329
+ const rawPath = typeof args.targetDirectory === "string" ? args.targetDirectory : typeof args.path === "string" ? args.path : undefined;
330
+ return rawPath ? formatDisplayPath(rawPath, options.cwd) : undefined;
331
+ }
332
+
333
+ function synthesizeGlobBashCommand(args: Record<string, unknown>, options: TranscriptOptions): string {
334
+ const pattern = getGlobPattern(args);
335
+ const targetDirectory = getGlobTargetDirectory(args, options);
336
+ return targetDirectory ? `glob ${pattern} in ${targetDirectory}` : `glob ${pattern}`;
337
+ }
338
+
339
+ export function buildFindDisplayArgs(args: Record<string, unknown>, options: TranscriptOptions): Record<string, unknown> {
340
+ const displayArgs: Record<string, unknown> = { pattern: getGlobPattern(args) };
341
+ const targetDirectory = getGlobTargetDirectory(args, options);
342
+ const limit = getNumber(args, "limit") ?? getNumber(args, "headLimit");
343
+ if (targetDirectory !== undefined) displayArgs.path = targetDirectory;
344
+ if (limit !== undefined) displayArgs.limit = limit;
345
+ return displayArgs;
346
+ }
347
+
348
+ export function getGrepBody(result: NormalizedResult, options: TranscriptOptions): string {
349
+ const lines = collectSearchResults(result.value);
350
+ const limited = limitItems(lines, options);
351
+ const body = limited.omitted > 0 ? `${limited.items.join("\n")}\n... (${limited.omitted} more matches truncated)` : limited.items.join("\n");
352
+ return limitText(body || stringifyUnknown(result.value), options);
353
+ }
354
+
355
+ export function getGlobBody(result: NormalizedResult, options: TranscriptOptions): string {
356
+ const value = asRecord(result.value);
357
+ const files = getArray(value, "files")?.filter((entry): entry is string => typeof entry === "string") ?? [];
358
+ if (files.length === 0) {
359
+ const totalMatches = getNumber(value, "totalMatches");
360
+ const totalFiles = getNumber(value, "totalFiles");
361
+ if (totalMatches === 0 || totalFiles === 0) return "No files found matching pattern";
362
+ return stringifyUnknown(result.value);
363
+ }
364
+ const limited = limitItems(files, options);
365
+ const body = limited.omitted > 0 ? `${limited.items.join("\n")}\n... (${limited.omitted} more files truncated)` : limited.items.join("\n");
366
+ return limitText(body, options);
367
+ }
368
+
369
+ export function formatGrep(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
370
+ const header = `$ ${synthesizeGrepBashCommand(args, options)}`;
371
+ if (result.status === "error") return joinSections(header, formatError(result.error));
372
+ return joinSections(header, getGrepBody(result, options));
373
+ }
374
+
375
+ export function getCursorWriteArgContent(args: Record<string, unknown>): string | undefined {
376
+ return getString(args, "content") ?? getString(args, "fileContent") ?? getString(args, "contents");
377
+ }
378
+
379
+ function getCursorWriteRecordedContent(args: Record<string, unknown>, resultValue: Record<string, unknown> | undefined): string | undefined {
380
+ return getCursorWriteArgContent(args) ?? getString(resultValue, "fileContentAfterWrite");
381
+ }
382
+
383
+ export function formatWrite(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
384
+ const path = formatPathArg(args, options) ?? "unknown";
385
+ if (result.status === "error") return joinSections(`write ${path}`, formatError(result.error));
386
+
387
+ const value = asRecord(result.value);
388
+ const linesCreated = getNumber(value, "linesCreated");
389
+ const fileSize = getNumber(value, "fileSize");
390
+ const fileContentAfterWrite = getCursorWriteRecordedContent(args, value);
391
+ const parts = [
392
+ linesCreated !== undefined ? `Created ${linesCreated} lines` : undefined,
393
+ fileSize !== undefined ? `File size: ${fileSize} bytes` : undefined,
394
+ fileContentAfterWrite ? limitText(fileContentAfterWrite, options) : undefined,
395
+ ].filter((part): part is string => Boolean(part));
396
+ return joinSections(`write ${path}`, parts.join("\n\n") || stringifyUnknown(result.value));
397
+ }
398
+
399
+ export function formatEdit(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
400
+ const path = formatPathArg(args, options) ?? "unknown";
401
+ if (result.status === "error") return joinSections(`edit ${path}`, formatError(result.error));
402
+
403
+ const value = asRecord(result.value);
404
+ const diff = formatDiffString(resolveCursorEditDiff(value), options);
405
+ const linesAdded = getNumber(value, "linesAdded");
406
+ const linesRemoved = getNumber(value, "linesRemoved");
407
+ const stats = [
408
+ linesAdded !== undefined ? `+${linesAdded}` : undefined,
409
+ linesRemoved !== undefined ? `-${linesRemoved}` : undefined,
410
+ ].filter(Boolean).join(" ");
411
+ const body = [stats, diff ? limitText(diff, options) : undefined].filter((part): part is string => Boolean(part)).join("\n\n");
412
+ return joinSections(`edit ${path}`, body || stringifyUnknown(result.value));
413
+ }
414
+
415
+ export function formatDelete(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
416
+ const path = formatPathArg(args, options) ?? "unknown";
417
+ if (result.status === "error") return joinSections(`delete ${path}`, formatError(result.error));
418
+ const value = asRecord(result.value);
419
+ const fileSize = getNumber(value, "fileSize");
420
+ return joinSections(`delete ${path}`, fileSize !== undefined ? `Deleted ${fileSize} bytes` : stringifyUnknown(result.value));
421
+ }
422
+
423
+ export function getReadLintPaths(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string[] {
424
+ const explicitPaths = Array.isArray(args.paths)
425
+ ? args.paths.filter((entry): entry is string => typeof entry === "string")
426
+ : typeof args.path === "string"
427
+ ? [args.path]
428
+ : [];
429
+ const resultPaths = (getArray(asRecord(result.value), "fileDiagnostics") ?? [])
430
+ .map((file) => getString(asRecord(file), "path"))
431
+ .filter((entry): entry is string => Boolean(entry));
432
+ return [...new Set([...explicitPaths, ...resultPaths].map((entry) => formatDisplayPath(entry, options.cwd)))];
433
+ }
434
+
435
+ export function getReadLintDiagnostics(result: NormalizedResult, options: TranscriptOptions): string[] {
436
+ const value = asRecord(result.value);
437
+ const files = getArray(value, "fileDiagnostics") ?? [];
438
+ const lines: string[] = [];
439
+ for (const file of files) {
440
+ const fileRecord = asRecord(file);
441
+ const pathValue = getString(fileRecord, "path");
442
+ const path = pathValue ? formatDisplayPath(pathValue, options.cwd) : "unknown";
443
+ const diagnostics = getArray(fileRecord, "diagnostics") ?? [];
444
+ for (const diagnostic of diagnostics) {
445
+ const diagnosticRecord = asRecord(diagnostic);
446
+ const severity = getString(diagnosticRecord, "severity") ?? "diagnostic";
447
+ const message = getString(diagnosticRecord, "message") ?? "";
448
+ const source = getString(diagnosticRecord, "source");
449
+ lines.push(`${path}: ${severity}${source ? ` ${source}` : ""}: ${message}`);
450
+ }
451
+ }
452
+ return lines;
453
+ }
454
+
455
+ export function formatReadLints(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
456
+ const paths = getReadLintPaths(args, result, options);
457
+ const header = `readLints${paths.length > 0 ? ` ${paths.join(" ")}` : ""}`;
458
+ if (result.status === "error") return joinSections(header, formatError(result.error));
459
+
460
+ const lines = getReadLintDiagnostics(result, options);
461
+ if (lines.length === 0 && paths.length > 0) return joinSections(header, `No diagnostics in ${paths.join(", ")}`);
462
+ return joinSections(header, limitText(lines.join("\n") || stringifyUnknown(result.value), options));
463
+ }
464
+
465
+ export function getTodoItems(args: Record<string, unknown>, result: NormalizedResult): Array<{ content: string; status?: string }> {
466
+ const value = asRecord(result.value);
467
+ const rawTodos = getArray(value, "todos") ?? getArray(args, "todos") ?? [];
468
+ const todos: Array<{ content: string; status?: string }> = [];
469
+ for (const todo of rawTodos) {
470
+ const record = asRecord(todo);
471
+ const content = getString(record, "content");
472
+ if (!content) continue;
473
+ const status = getString(record, "status");
474
+ todos.push(status ? { content, status } : { content });
475
+ }
476
+ return todos;
477
+ }
478
+
479
+ export function getTodoTotalCount(args: Record<string, unknown>, result: NormalizedResult, todos: Array<{ content: string; status?: string }>): number {
480
+ return getNumber(asRecord(result.value), "totalCount") ?? getNumber(args, "totalCount") ?? todos.length;
481
+ }
482
+
483
+ export function summarizeTodos(args: Record<string, unknown>, result: NormalizedResult): string {
484
+ const todos = getTodoItems(args, result);
485
+ const total = getTodoTotalCount(args, result, todos);
486
+ const completed = todos.filter((todo) => todo.status === "completed").length;
487
+ const inProgress = todos.filter((todo) => todo.status === "inProgress").length;
488
+ const pending = todos.filter((todo) => todo.status === "pending").length;
489
+ const parts = [`${completed}/${total} completed`];
490
+ if (inProgress > 0) parts.push(`${inProgress} in progress`);
491
+ if (pending > 0) parts.push(`${pending} pending`);
492
+ return parts.join(", ");
493
+ }
494
+
495
+ function formatTodoStatus(status: string | undefined): string {
496
+ if (status === "completed") return "✓";
497
+ if (status === "inProgress") return "…";
498
+ if (status === "pending") return "○";
499
+ return "•";
500
+ }
501
+
502
+ export function formatTodos(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions, header: string): string {
503
+ if (result.status === "error") return joinSections(header, formatError(result.error));
504
+ const todos = getTodoItems(args, result);
505
+ if (todos.length === 0) return joinSections(header, limitText(stringifyUnknown(result.value), options));
506
+ const lines = todos.map((todo) => `${formatTodoStatus(todo.status)} ${todo.content}${todo.status ? ` (${todo.status})` : ""}`);
507
+ return joinSections(header, limitText(lines.join("\n"), options));
508
+ }
509
+
510
+ export function summarizePlan(args: Record<string, unknown>, result: NormalizedResult): string {
511
+ const planText = getString(args, "plan") ?? getString(asRecord(result.value), "plan");
512
+ const firstLine = planText ? firstNonEmptyLine(planText) : undefined;
513
+ return firstLine ? truncateArg(firstLine, 160) : summarizeTodos(args, result);
514
+ }
515
+
516
+ export function formatPlan(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
517
+ if (result.status === "error") return joinSections("createPlan", formatError(result.error));
518
+ const planText = getString(args, "plan") ?? getString(asRecord(result.value), "plan");
519
+ if (planText?.trim()) return joinSections("createPlan", limitText(planText, options));
520
+ return formatTodos(args, result, options, "createPlan");
521
+ }
522
+
523
+ export function getTaskDescription(args: Record<string, unknown>, result: NormalizedResult): string {
524
+ return getString(args, "description") ?? getString(asRecord(result.value), "description") ?? "task";
525
+ }
526
+
527
+ function getNestedRecord(record: Record<string, unknown> | undefined, ...keys: string[]): Record<string, unknown> | undefined {
528
+ let current = record;
529
+ for (const key of keys) {
530
+ current = getRecord(current, key);
531
+ if (!current) return undefined;
532
+ }
533
+ return current;
534
+ }
535
+
536
+ export function collectTaskText(result: NormalizedResult): string {
537
+ const value = asRecord(result.value);
538
+ const success = getNestedRecord(value, "result", "success");
539
+ const command = getString(success, "command");
540
+ const stdout = getString(success, "stdout");
541
+ const interleavedOutput = getString(success, "interleavedOutput");
542
+ const assistantMessages = (getArray(value, "conversationSteps") ?? [])
543
+ .map((step) => getString(getRecord(asRecord(step), "assistantMessage"), "text"))
544
+ .filter((entry): entry is string => Boolean(entry));
545
+ const parts = [command ? `$ ${command}` : undefined, stdout || interleavedOutput, ...assistantMessages].filter((part): part is string => Boolean(part));
546
+ return parts.join("\n");
547
+ }
548
+
549
+ export function formatTask(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
550
+ const description = getTaskDescription(args, result);
551
+ if (result.status === "error") return joinSections(`task ${description}`, formatError(result.error));
552
+ const taskText = collectTaskText(result);
553
+ return joinSections(`task ${description}`, limitText(taskText || stringifyUnknown(result.value), options));
554
+ }
555
+
556
+ export function summarizeTask(description: string, taskText: string): string {
557
+ const firstLine = firstNonEmptyLine(taskText);
558
+ if (!firstLine) return truncateArg(description);
559
+ if (description === "task" || description === firstLine) return truncateArg(firstLine);
560
+ return truncateArg(`${description}: ${firstLine}`, 160);
561
+ }
562
+
563
+ function getGenerateImageValue(result: NormalizedResult): Record<string, unknown> | undefined {
564
+ return asRecord(result.value);
565
+ }
566
+
567
+ export function getGenerateImagePath(args: Record<string, unknown>, result: NormalizedResult): string | undefined {
568
+ const value = getGenerateImageValue(result);
569
+ return getString(value, "filePath") ?? getString(args, "filePath") ?? getString(args, "path");
570
+ }
571
+
572
+ export function getGenerateImageDisplayPath(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string | undefined {
573
+ const path = getGenerateImagePath(args, result);
574
+ return path ? formatDisplayPath(path, options.cwd) : undefined;
575
+ }
576
+
577
+ export function inferImageMimeType(path: string | undefined): string | undefined {
578
+ const lower = path?.toLowerCase();
579
+ if (!lower) return undefined;
580
+ if (lower.endsWith(".png")) return "image/png";
581
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
582
+ if (lower.endsWith(".gif")) return "image/gif";
583
+ if (lower.endsWith(".webp")) return "image/webp";
584
+ return undefined;
585
+ }
586
+
587
+ export function formatGenerateImage(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
588
+ const prompt = getString(args, "prompt") ?? getString(args, "description") ?? "image";
589
+ if (result.status === "error") return joinSections(`generateImage ${prompt}`, formatError(result.error));
590
+ const value = getGenerateImageValue(result);
591
+ const displayPath = getGenerateImageDisplayPath(args, result, options);
592
+ const hasImageData = typeof value?.imageData === "string" && value.imageData.length > 0;
593
+ const lines = [displayPath ? `Saved image: ${displayPath}` : undefined, hasImageData ? "Image data returned by Cursor SDK." : undefined].filter(
594
+ (line): line is string => Boolean(line),
595
+ );
596
+ if (lines.length > 0) return joinSections(`generateImage ${prompt}`, lines.join("\n"));
597
+ return joinSections(`generateImage ${prompt}`, limitText(stringifyUnknown(result.value), options));
598
+ }
599
+
600
+ function getMcpContentText(entry: unknown): string | undefined {
601
+ const record = asRecord(entry);
602
+ const directText = getString(record, "text");
603
+ if (directText) return directText;
604
+ const nestedText = getRecord(record, "text");
605
+ return getString(nestedText, "text");
606
+ }
607
+
608
+ function describeNonTextMcpContent(entry: unknown): string {
609
+ const record = asRecord(entry);
610
+ const type = getString(record, "type") ?? "content";
611
+ if (type === "image") {
612
+ const mimeType = getString(record, "mimeType") ?? getString(record, "mime") ?? getString(record, "mediaType");
613
+ return `[image${mimeType ? ` ${mimeType}` : ""} omitted]`;
614
+ }
615
+ if (type === "audio") return "[audio omitted]";
616
+ if (type === "resource") return "[resource omitted]";
617
+ return `[${type} omitted]`;
618
+ }
619
+
620
+ export function formatMcp(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
621
+ const toolName = typeof args.toolName === "string" ? args.toolName : "mcp";
622
+ if (result.status === "error") return joinSections(toolName, formatError(result.error));
623
+
624
+ const value = asRecord(result.value);
625
+ const isError = getBoolean(value, "isError");
626
+ const content = getArray(value, "content") ?? [];
627
+ const text = content
628
+ .map((entry) => getMcpContentText(entry))
629
+ .filter((entry): entry is string => Boolean(entry))
630
+ .join("\n");
631
+ const contentSummary = content.length > 0 ? content.map(describeNonTextMcpContent).join("\n") : stringifyUnknown(result.value);
632
+ const body = `${isError ? "[tool error]\n" : ""}${text || contentSummary}`;
633
+ return joinSections(toolName, limitText(body, options));
634
+ }
635
+
636
+ export function formatFallback(name: string, args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
637
+ const header = name === "unknown" ? "Cursor tool" : name;
638
+ if (result.status === "error") return joinSections(header, formatError(result.error));
639
+ const argsText = Object.keys(args).length > 0 ? `${stringifyUnknown(args)}\n\n` : "";
640
+ return joinSections(header, limitText(`${argsText}${stringifyUnknown(result.value)}`.trim(), options));
641
+ }