pi-cursor-sdk 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +37 -0
  3. package/docs/cursor-live-smoke-checklist.md +3 -0
  4. package/docs/cursor-model-ux-spec.md +4 -3
  5. package/docs/cursor-native-tool-replay.md +96 -2
  6. package/docs/cursor-testing-lessons.md +234 -5
  7. package/package.json +8 -2
  8. package/scripts/debug-provider-events.mjs +403 -0
  9. package/scripts/debug-sdk-events.mjs +413 -0
  10. package/scripts/lib/cursor-probe-utils.mjs +52 -0
  11. package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
  12. package/scripts/validate-smoke-jsonl.mjs +27 -3
  13. package/src/context.ts +45 -32
  14. package/src/cursor-agent-message-web-tools.ts +172 -0
  15. package/src/cursor-agents-context.ts +176 -0
  16. package/src/cursor-incomplete-tool-visibility.ts +118 -0
  17. package/src/cursor-live-run-coordinator.ts +18 -7
  18. package/src/cursor-model.ts +12 -0
  19. package/src/cursor-native-tool-display-registration.ts +1 -4
  20. package/src/cursor-native-tool-display-replay.ts +63 -5
  21. package/src/cursor-native-tool-display-tools.ts +20 -0
  22. package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
  23. package/src/cursor-pi-tool-bridge-run.ts +16 -1
  24. package/src/cursor-pi-tool-bridge-types.ts +3 -0
  25. package/src/cursor-provider-errors.ts +96 -0
  26. package/src/cursor-provider-live-run-drain.ts +181 -62
  27. package/src/cursor-provider-turn-coordinator.ts +198 -32
  28. package/src/cursor-provider.ts +270 -83
  29. package/src/cursor-question-tool.ts +1 -4
  30. package/src/cursor-sdk-abort-error-guard.ts +109 -0
  31. package/src/cursor-sdk-event-debug-constants.ts +40 -0
  32. package/src/cursor-sdk-event-debug-session.ts +163 -0
  33. package/src/cursor-sdk-event-debug.ts +597 -0
  34. package/src/cursor-sensitive-text.ts +27 -7
  35. package/src/cursor-session-agent.ts +25 -3
  36. package/src/cursor-session-send-policy.ts +43 -0
  37. package/src/cursor-setting-sources.ts +29 -0
  38. package/src/cursor-state.ts +1 -5
  39. package/src/cursor-tool-lifecycle.ts +111 -0
  40. package/src/cursor-tool-names.ts +12 -0
  41. package/src/cursor-tool-transcript.ts +4 -2
  42. package/src/cursor-transcript-tool-formatters.ts +228 -5
  43. package/src/cursor-transcript-tool-specs.ts +113 -14
  44. package/src/cursor-transcript-utils.ts +12 -0
  45. package/src/cursor-web-tool-activity.ts +84 -0
  46. package/src/index.ts +4 -1
@@ -16,10 +16,12 @@ import {
16
16
  } from "./cursor-pi-tool-bridge.js";
17
17
  import { computeCursorContextFingerprint } from "./context.js";
18
18
  import { getCursorSessionScopeKey, onCursorSessionScopeKeyChange } from "./cursor-session-scope.js";
19
+ import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js";
19
20
 
20
21
  export interface SessionCursorAgentSendState {
21
22
  bootstrapped: boolean;
22
23
  contextFingerprint: string;
24
+ incrementalSendCount: number;
23
25
  }
24
26
 
25
27
  export interface SessionCursorAgentLease {
@@ -74,6 +76,7 @@ interface SessionCursorAgentCreateParams {
74
76
  modelSelection: ModelSelection;
75
77
  settingSources?: SettingSource[];
76
78
  onBridgeToolRequest?: (request: CursorPiBridgeToolRequest) => void;
79
+ debugRecorder?: CursorSdkEventDebugRecorder;
77
80
  createAgent?: typeof Agent.create;
78
81
  }
79
82
 
@@ -150,13 +153,19 @@ async function disposePoolEntryForScope(scopeKey: string, options?: { terminal?:
150
153
  const entry = sessionAgentsByScope.get(scopeKey);
151
154
  invalidatedScopeKeys.delete(scopeKey);
152
155
  if (!entry) return;
156
+ const orphanedCreating = entry.creating;
153
157
  sessionAgentsByScope.delete(scopeKey);
154
- if (entry.creating || !entry.agent) return;
158
+ if (entry.creating || !entry.agent) {
159
+ orphanedCreating?.catch(() => {
160
+ // In-flight Agent.create was orphaned by scope disposal; active waiters surface errors elsewhere.
161
+ });
162
+ return;
163
+ }
155
164
  await disposePoolEntry(entry);
156
165
  }
157
166
 
158
167
  function createInitialSendState(): SessionCursorAgentSendState {
159
- return { bootstrapped: false, contextFingerprint: "" };
168
+ return { bootstrapped: false, contextFingerprint: "", incrementalSendCount: 0 };
160
169
  }
161
170
 
162
171
  function bindBridgeToolRequest(
@@ -173,6 +182,7 @@ function leaseFromEntry(
173
182
  created: boolean,
174
183
  ): SessionCursorAgentLease {
175
184
  bindBridgeToolRequest(entry, params.onBridgeToolRequest);
185
+ entry.bridgeRun?.setDebugRecorder(params.debugRecorder);
176
186
  return {
177
187
  scopeKey,
178
188
  agent: entry.agent!,
@@ -192,6 +202,7 @@ async function createSessionAgentEntry(
192
202
  if (registeredBridge) {
193
203
  bridgeRun = await registeredBridge.createRun({
194
204
  onToolRequest: params.onBridgeToolRequest,
205
+ debugRecorder: params.debugRecorder,
195
206
  });
196
207
  if (!bridgeRun.enabled || !bridgeRun.mcpServers) {
197
208
  await bridgeRun.dispose();
@@ -230,13 +241,24 @@ async function createSessionAgentEntry(
230
241
  };
231
242
  }
232
243
 
233
- export { shouldBootstrapCursorSend } from "./context.js";
244
+ export {
245
+ buildCursorSessionSendPrompt,
246
+ MAX_COMPLETED_INCREMENTAL_SENDS_BEFORE_REBOOTSTRAP,
247
+ planCursorSessionSend,
248
+ type CursorSessionSendPlan,
249
+ } from "./cursor-session-send-policy.js";
250
+ export { shouldBootstrapCursorContext, shouldBootstrapCursorSend } from "./context.js";
234
251
 
235
252
  export function commitSessionAgentSend(scopeKey: string, context: Context, bootstrapped: boolean): void {
236
253
  const entry = sessionAgentsByScope.get(scopeKey);
237
254
  if (!entry) return;
238
255
  entry.sendState.bootstrapped = bootstrapped || entry.sendState.bootstrapped;
239
256
  entry.sendState.contextFingerprint = computeCursorContextFingerprint(context);
257
+ if (bootstrapped) {
258
+ entry.sendState.incrementalSendCount = 0;
259
+ return;
260
+ }
261
+ entry.sendState.incrementalSendCount += 1;
240
262
  }
241
263
 
242
264
  export function invalidateSessionAgent(scopeKey: string = getCursorSessionScopeKey()): void {
@@ -0,0 +1,43 @@
1
+ import type { Context } from "@earendil-works/pi-ai";
2
+ import {
3
+ buildCursorIncrementalPrompt,
4
+ buildCursorPrompt,
5
+ shouldBootstrapCursorContext,
6
+ type CursorPrompt,
7
+ type CursorPromptOptions,
8
+ } from "./context.js";
9
+ import type { SessionCursorAgentSendState } from "./cursor-session-agent.js";
10
+
11
+ // Long-lived SDK session agents can drift tool-call behavior; recreate the agent after this many successful incremental sends.
12
+ export const MAX_COMPLETED_INCREMENTAL_SENDS_BEFORE_REBOOTSTRAP = 20;
13
+
14
+ export type CursorSessionSendMode = "bootstrap" | "incremental";
15
+
16
+ export type CursorSessionSendReason = "initial" | "context_divergence" | "incremental_threshold" | "incremental";
17
+
18
+ export interface CursorSessionSendPlan {
19
+ mode: CursorSessionSendMode;
20
+ resetAgent: boolean;
21
+ reason: CursorSessionSendReason;
22
+ }
23
+
24
+ export function planCursorSessionSend(sendState: SessionCursorAgentSendState, context: Context): CursorSessionSendPlan {
25
+ if (!sendState.bootstrapped) {
26
+ return { mode: "bootstrap", resetAgent: false, reason: "initial" };
27
+ }
28
+ if (sendState.incrementalSendCount >= MAX_COMPLETED_INCREMENTAL_SENDS_BEFORE_REBOOTSTRAP) {
29
+ return { mode: "bootstrap", resetAgent: true, reason: "incremental_threshold" };
30
+ }
31
+ if (shouldBootstrapCursorContext(sendState, context)) {
32
+ return { mode: "bootstrap", resetAgent: true, reason: "context_divergence" };
33
+ }
34
+ return { mode: "incremental", resetAgent: false, reason: "incremental" };
35
+ }
36
+
37
+ export function buildCursorSessionSendPrompt(
38
+ context: Context,
39
+ options: CursorPromptOptions,
40
+ plan: CursorSessionSendPlan,
41
+ ): CursorPrompt {
42
+ return plan.mode === "bootstrap" ? buildCursorPrompt(context, options) : buildCursorIncrementalPrompt(context, options);
43
+ }
@@ -0,0 +1,29 @@
1
+ import type { SettingSource } from "@cursor/sdk";
2
+
3
+ export const CURSOR_SETTING_SOURCES_ENV = "PI_CURSOR_SETTING_SOURCES";
4
+
5
+ export function resolveCursorSettingSources(raw?: string): SettingSource[] | undefined {
6
+ const trimmed = raw?.trim();
7
+ if (!trimmed) return ["all"];
8
+ const normalized = trimmed.toLowerCase();
9
+ if (["0", "false", "off", "none", "omit", "disabled"].includes(normalized)) return undefined;
10
+ if (["1", "true", "on", "all"].includes(normalized)) return ["all"];
11
+ return trimmed
12
+ .split(",")
13
+ .map((entry) => entry.trim())
14
+ .filter((entry): entry is SettingSource => Boolean(entry));
15
+ }
16
+
17
+ export function getEffectiveCursorSettingSources(raw: string | undefined = process.env[CURSOR_SETTING_SOURCES_ENV]): SettingSource[] | undefined {
18
+ return resolveCursorSettingSources(raw);
19
+ }
20
+
21
+ export function cursorSettingSourcesLoadUserAgentsRules(settingSources: SettingSource[] | undefined): boolean {
22
+ if (!settingSources?.length) return false;
23
+ return settingSources.includes("all") || settingSources.includes("user");
24
+ }
25
+
26
+ export function cursorSettingSourcesLoadProjectAgentsRules(settingSources: SettingSource[] | undefined): boolean {
27
+ if (!settingSources?.length) return false;
28
+ return settingSources.includes("all") || settingSources.includes("project");
29
+ }
@@ -2,9 +2,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionContext, SessionStartEvent } from "@earendil-works/pi-coding-agent";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { isCursorModel } from "./cursor-model.js";
5
6
  import { getCursorModelMetadata } from "./model-discovery.js";
6
7
 
7
- const CURSOR_PROVIDER = "cursor";
8
8
  const FAST_ENTRY_TYPE = "cursor-fast-state";
9
9
  const GLOBAL_CONFIG_FILE = "cursor-sdk.json";
10
10
 
@@ -94,10 +94,6 @@ function getEffectiveFast(baseModelId: string, modelId: string): boolean | undef
94
94
  return sessionFastPreferences.get(baseModelId) ?? globalFastPreferences.get(baseModelId) ?? metadata.defaultFast;
95
95
  }
96
96
 
97
- function isCursorModel(model: CursorFastControlsModel): boolean {
98
- return model?.provider === CURSOR_PROVIDER || model?.api === "cursor-sdk";
99
- }
100
-
101
97
  function updateCursorStatus(ctx: { model: CursorFastControlsModel; ui: Pick<ExtensionContext["ui"], "setStatus"> }, model = ctx.model): void {
102
98
  if (!model || !isCursorModel(model)) {
103
99
  ctx.ui.setStatus("cursor", undefined);
@@ -0,0 +1,111 @@
1
+ import { truncateCursorDisplayLine } from "./cursor-display-text.js";
2
+ import { getCursorReplayDisplayLabel, type CursorReplayLegacyToolName } from "./cursor-tool-names.js";
3
+ import { scrubSensitiveText } from "./cursor-sensitive-text.js";
4
+ import { extractWebSearchQuery, resolveTranscriptToolName } from "./cursor-web-tool-activity.js";
5
+ import { firstNonEmptyLine, getArray, getString, getToolArgs, getToolName, normalizeToolName, truncateArg } from "./cursor-transcript-utils.js";
6
+
7
+ /** Defer pending lifecycle lines so fast start+complete pairs coalesce into the completed replay card only. */
8
+ export const CURSOR_TOOL_LIFECYCLE_DEFER_MS = 75;
9
+
10
+ const LIFECYCLE_ELIGIBLE_TOOLS = new Set(
11
+ ["task", "shell", "mcp", "generateImage", "recordScreen", "semSearch", "webSearch", "webFetch", "createPlan", "updateTodos"].map(
12
+ (name) => name.toLowerCase(),
13
+ ),
14
+ );
15
+
16
+ const LIFECYCLE_TITLE_KEYS: Partial<Record<string, CursorReplayLegacyToolName>> = {
17
+ task: "cursor_task",
18
+ mcp: "cursor_mcp",
19
+ generateimage: "cursor_generate_image",
20
+ recordscreen: "cursor_record_screen",
21
+ semsearch: "cursor_sem_search",
22
+ websearch: "cursor_web_search",
23
+ webfetch: "cursor_web_fetch",
24
+ createplan: "cursor_create_plan",
25
+ updatetodos: "cursor_update_todos",
26
+ };
27
+
28
+ export function isCursorToolLifecycleEligible(toolCall: unknown): boolean {
29
+ const args = getToolArgs(toolCall);
30
+ const name = resolveTranscriptToolName(getToolName(toolCall), args);
31
+ return LIFECYCLE_ELIGIBLE_TOOLS.has(normalizeToolName(name).toLowerCase());
32
+ }
33
+
34
+ function getCursorToolLifecycleTitle(toolCall: unknown): string {
35
+ const args = getToolArgs(toolCall);
36
+ const name = resolveTranscriptToolName(getToolName(toolCall), args);
37
+ const normalized = normalizeToolName(name).toLowerCase();
38
+ const labelKey = LIFECYCLE_TITLE_KEYS[normalized];
39
+ if (labelKey) return getCursorReplayDisplayLabel(labelKey);
40
+ if (normalized === "shell") return "Cursor shell";
41
+ return `Cursor ${normalizeToolName(name)}`;
42
+ }
43
+
44
+ /** Prefixes that commonly introduce path/URI values in free-text pending lifecycle details. */
45
+ const LIFECYCLE_DETAIL_PATH_PREFIX = String.raw`(?:^|[\s'"({=,:;\[\]{}])`;
46
+
47
+ function containsCursorLifecycleUnsafeDetail(text: string): boolean {
48
+ if (/\b[a-z][a-z0-9+.-]*:\/\//i.test(text)) return true;
49
+ if (/\bwww\.\S+/i.test(text)) return true;
50
+ if (new RegExp(`${LIFECYCLE_DETAIL_PATH_PREFIX}~\\/\\S*`).test(text)) return true;
51
+ if (new RegExp(`${LIFECYCLE_DETAIL_PATH_PREFIX}\\/\\S+`).test(text)) return true;
52
+ if (new RegExp(`${LIFECYCLE_DETAIL_PATH_PREFIX}[A-Za-z]:[\\\\/]`).test(text)) return true;
53
+ return false;
54
+ }
55
+
56
+ function scrubLifecycleDetail(value: string | undefined, apiKey?: string): string | undefined {
57
+ if (!value?.trim()) return undefined;
58
+ const scrubbed = truncateCursorDisplayLine(scrubSensitiveText(value, apiKey));
59
+ if (containsCursorLifecycleUnsafeDetail(scrubbed)) return undefined;
60
+ return scrubbed;
61
+ }
62
+
63
+ export function buildCursorToolLifecycleLabel(toolCall: unknown, apiKey?: string): string | undefined {
64
+ const args = getToolArgs(toolCall);
65
+ const name = resolveTranscriptToolName(getToolName(toolCall), args);
66
+ const normalized = normalizeToolName(name).toLowerCase();
67
+
68
+ switch (normalized) {
69
+ case "task": {
70
+ return scrubLifecycleDetail(getString(args, "description"), apiKey) ?? "task";
71
+ }
72
+ case "shell": {
73
+ return "shell";
74
+ }
75
+ case "mcp": {
76
+ return scrubLifecycleDetail(getString(args, "toolName"), apiKey) ?? "mcp";
77
+ }
78
+ case "generateimage": {
79
+ return scrubLifecycleDetail(getString(args, "prompt") ?? getString(args, "description"), apiKey) ?? "image generation";
80
+ }
81
+ case "recordscreen": {
82
+ return scrubLifecycleDetail(getString(args, "mode"), apiKey) ?? "screen recording";
83
+ }
84
+ case "semsearch": {
85
+ return scrubLifecycleDetail(getString(args, "query"), apiKey) ?? "semantic search";
86
+ }
87
+ case "websearch": {
88
+ return scrubLifecycleDetail(extractWebSearchQuery(args), apiKey) ?? "web search";
89
+ }
90
+ case "webfetch": {
91
+ return "web fetch";
92
+ }
93
+ case "createplan": {
94
+ const plan = getString(args, "plan");
95
+ return scrubLifecycleDetail(plan ? firstNonEmptyLine(plan) ?? plan : undefined, apiKey) ?? "plan";
96
+ }
97
+ case "updatetodos": {
98
+ const todos = getArray(args, "todos") ?? getArray(args, "items");
99
+ if (todos && todos.length > 0) return truncateArg(`${todos.length} item${todos.length === 1 ? "" : "s"}`);
100
+ return "todos";
101
+ }
102
+ default:
103
+ return undefined;
104
+ }
105
+ }
106
+
107
+ export function formatCursorToolLifecycleProgressText(toolCall: unknown, apiKey?: string): string | undefined {
108
+ const label = buildCursorToolLifecycleLabel(toolCall, apiKey);
109
+ if (!label) return undefined;
110
+ return `${getCursorToolLifecycleTitle(toolCall)}: ${label}\n`;
111
+ }
@@ -10,6 +10,10 @@ export const CURSOR_REPLAY_LEGACY_TOOL_NAMES = [
10
10
  "cursor_create_plan",
11
11
  "cursor_generate_image",
12
12
  "cursor_mcp",
13
+ "cursor_sem_search",
14
+ "cursor_record_screen",
15
+ "cursor_web_search",
16
+ "cursor_web_fetch",
13
17
  ] as const;
14
18
 
15
19
  export type CursorReplayLegacyToolName = (typeof CURSOR_REPLAY_LEGACY_TOOL_NAMES)[number];
@@ -25,6 +29,10 @@ const CURSOR_REPLAY_SOURCE_TOOL_NAMES = {
25
29
  cursor_create_plan: "createPlan",
26
30
  cursor_generate_image: "generateImage",
27
31
  cursor_mcp: "MCP",
32
+ cursor_sem_search: "semSearch",
33
+ cursor_record_screen: "recordScreen",
34
+ cursor_web_search: "web search",
35
+ cursor_web_fetch: "web fetch",
28
36
  } as const satisfies Record<CursorReplayLegacyToolName, string>;
29
37
 
30
38
  const CURSOR_REPLAY_PROMPT_LABELS = {
@@ -37,6 +45,10 @@ const CURSOR_REPLAY_PROMPT_LABELS = {
37
45
  cursor_create_plan: "Cursor plan",
38
46
  cursor_generate_image: "Cursor image generation",
39
47
  cursor_mcp: "Cursor MCP",
48
+ cursor_sem_search: "Cursor semantic search",
49
+ cursor_record_screen: "Cursor screen recording",
50
+ cursor_web_search: "Cursor web search",
51
+ cursor_web_fetch: "Cursor web fetch",
40
52
  } as const satisfies Record<CursorReplayLegacyToolName, string>;
41
53
 
42
54
  export function isCursorReplayLegacyToolName(toolName: string): toolName is CursorReplayLegacyToolName {
@@ -14,6 +14,7 @@ import {
14
14
  formatCursorToolTranscriptFromSpec,
15
15
  type ToolDisplayContext,
16
16
  } from "./cursor-transcript-tool-specs.js";
17
+ import { resolveTranscriptToolName } from "./cursor-web-tool-activity.js";
17
18
 
18
19
  export type { CursorPiToolDisplay } from "./cursor-transcript-utils.js";
19
20
 
@@ -29,10 +30,11 @@ export function getCursorCreatePlanText(toolCall: unknown): string | undefined {
29
30
 
30
31
  function buildToolDisplayContext(toolCall: unknown, options: TranscriptOptions): ToolDisplayContext {
31
32
  const rawName = getToolName(toolCall);
33
+ const args = getToolArgs(toolCall);
32
34
  return {
33
35
  rawName,
34
- name: normalizeToolName(rawName),
35
- args: getToolArgs(toolCall),
36
+ name: resolveTranscriptToolName(rawName, args),
37
+ args,
36
38
  result: normalizeResult(getToolResult(toolCall)),
37
39
  options,
38
40
  };
@@ -1,4 +1,6 @@
1
1
  import { resolveCursorEditDiff } from "./cursor-edit-diff.js";
2
+ import { scrubSensitiveText } from "./cursor-sensitive-text.js";
3
+ import { extractWebFetchTarget, extractWebSearchQuery } from "./cursor-web-tool-activity.js";
2
4
  import { getFirstStringByKeys } from "./cursor-record-utils.js";
3
5
  import {
4
6
  asRecord,
@@ -27,6 +29,21 @@ import {
27
29
  type TranscriptOptions,
28
30
  } from "./cursor-transcript-utils.js";
29
31
 
32
+ export function usesLocalReadPreview(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): boolean {
33
+ if (result.status === "error") return false;
34
+ const value = asRecord(result.value);
35
+ const resultContent = getString(value, "content");
36
+ if (resultContent && resultContent.length > 0) return false;
37
+ const rawPath = typeof args.path === "string" ? args.path : undefined;
38
+ if (!rawPath) return false;
39
+ const readOptions = {
40
+ ...options,
41
+ maxChars: options.maxChars ?? DEFAULT_READ_TRANSCRIPT_CHARS,
42
+ maxLines: options.maxLines ?? DEFAULT_READ_TRANSCRIPT_LINES,
43
+ };
44
+ return readFilePreview(rawPath, readOptions) !== undefined;
45
+ }
46
+
30
47
  function getReadContent(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
31
48
  const rawPath = typeof args.path === "string" ? args.path : undefined;
32
49
  const readOptions = {
@@ -57,9 +74,17 @@ export function formatRead(args: Record<string, unknown>, result: NormalizedResu
57
74
  return joinSections(`read ${path}`, limitText(getReadContent(args, result, options), readOptions, totalLines));
58
75
  }
59
76
 
60
- export function buildReadDisplayArgs(args: Record<string, unknown>, options: TranscriptOptions): Record<string, unknown> {
77
+ export function buildReadDisplayArgs(
78
+ args: Record<string, unknown>,
79
+ options: TranscriptOptions,
80
+ result?: NormalizedResult,
81
+ ): Record<string, unknown> {
61
82
  const rawPath = typeof args.path === "string" ? args.path : undefined;
62
- return rawPath ? { ...args, path: formatDisplayPath(rawPath, options.cwd) } : args;
83
+ const displayArgs = rawPath ? { ...args, path: formatDisplayPath(rawPath, options.cwd) } : args;
84
+ if (result && usesLocalReadPreview(args, result, options)) {
85
+ return { ...displayArgs, localReadPreview: true };
86
+ }
87
+ return displayArgs;
63
88
  }
64
89
 
65
90
  function buildPathDisplayArgs(args: Record<string, unknown>, options: TranscriptOptions): Record<string, unknown> {
@@ -617,6 +642,153 @@ function describeNonTextMcpContent(entry: unknown): string {
617
642
  return `[${type} omitted]`;
618
643
  }
619
644
 
645
+ export function summarizeSemSearch(args: Record<string, unknown>): string {
646
+ const query = getString(args, "query") ?? "semantic search";
647
+ const targetDirectories = getArray(args, "targetDirectories");
648
+ const dirHint =
649
+ targetDirectories && targetDirectories.length > 0
650
+ ? ` (${targetDirectories.length} dir${targetDirectories.length === 1 ? "" : "s"})`
651
+ : "";
652
+ return truncateArg(`${query}${dirHint}`);
653
+ }
654
+
655
+ export function formatSemSearch(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
656
+ const query = getString(args, "query") ?? "semantic search";
657
+ const header = `semSearch ${truncateArg(query)}`;
658
+ if (result.status === "error") return joinSections(header, formatError(result.error));
659
+
660
+ const value = asRecord(result.value);
661
+ const results = getString(value, "results");
662
+ const targetDirectories = getArray(args, "targetDirectories");
663
+ const explanation = getString(args, "explanation");
664
+ const lines: string[] = [];
665
+ if (explanation?.trim()) lines.push(`Explanation: ${explanation.trim()}`);
666
+ if (targetDirectories && targetDirectories.length > 0) {
667
+ const dirs = targetDirectories
668
+ .map((entry) => (typeof entry === "string" ? entry : stringifyUnknown(entry)))
669
+ .join(", ");
670
+ lines.push(`Scope: ${dirs}`);
671
+ }
672
+ if (results?.trim()) lines.push(results.trim());
673
+ const body = lines.length > 0 ? lines.join("\n\n") : stringifyUnknown(result.value);
674
+ return joinSections(header, limitText(body, options));
675
+ }
676
+
677
+ function formatRecordScreenMode(mode: string | undefined): string {
678
+ switch (mode) {
679
+ case "START_RECORDING":
680
+ return "start recording";
681
+ case "SAVE_RECORDING":
682
+ return "save recording";
683
+ case "DISCARD_RECORDING":
684
+ return "discard recording";
685
+ default:
686
+ return mode ?? "record screen";
687
+ }
688
+ }
689
+
690
+ function formatRecordingDurationMs(ms: number | undefined): string | undefined {
691
+ if (ms === undefined || !Number.isFinite(ms) || ms < 0) return undefined;
692
+ if (ms < 1000) return `${Math.round(ms)}ms`;
693
+ const seconds = ms / 1000;
694
+ return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
695
+ }
696
+
697
+ export function summarizeRecordScreen(
698
+ args: Record<string, unknown>,
699
+ result: NormalizedResult,
700
+ options: TranscriptOptions,
701
+ ): string {
702
+ const mode = getString(args, "mode");
703
+ if (result.status === "error") return formatRecordScreenMode(mode);
704
+ const value = asRecord(result.value);
705
+ const path = getString(value, "path");
706
+ const displayPath = path ? formatDisplayPath(path, options.cwd) : undefined;
707
+ const duration = formatRecordingDurationMs(getNumber(value, "recordingDurationMs"));
708
+ const modeLabel = formatRecordScreenMode(mode);
709
+ if (displayPath && duration) return `${displayPath} · ${duration}`;
710
+ if (displayPath) return displayPath;
711
+ if (duration) return `${modeLabel} · ${duration}`;
712
+ return modeLabel;
713
+ }
714
+
715
+ export function formatRecordScreen(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
716
+ const mode = getString(args, "mode");
717
+ const header = `recordScreen ${formatRecordScreenMode(mode)}`;
718
+ if (result.status === "error") return joinSections(header, formatError(result.error));
719
+
720
+ const value = asRecord(result.value);
721
+ const path = getString(value, "path");
722
+ const displayPath = path ? formatDisplayPath(path, options.cwd) : undefined;
723
+ const duration = formatRecordingDurationMs(getNumber(value, "recordingDurationMs"));
724
+ const wasCancelled = getBoolean(value, "wasPriorRecordingCancelled");
725
+ const lines: string[] = [];
726
+ if (displayPath) lines.push(`Recording: ${displayPath}`);
727
+ if (duration) lines.push(`Duration: ${duration}`);
728
+ if (wasCancelled === true) lines.push("Prior recording cancelled.");
729
+ if (lines.length === 0) {
730
+ if (mode === "START_RECORDING") lines.push("Recording started.");
731
+ else if (mode === "DISCARD_RECORDING") lines.push("Recording discarded.");
732
+ else lines.push("Screen recording updated.");
733
+ }
734
+ return joinSections(header, lines.join("\n"));
735
+ }
736
+
737
+ export function getMcpResultPreview(result: NormalizedResult): string | undefined {
738
+ if (result.status === "error") return undefined;
739
+ const value = asRecord(result.value);
740
+ const content = getArray(value, "content") ?? [];
741
+ for (const entry of content) {
742
+ const text = getMcpContentText(entry);
743
+ if (text) {
744
+ const line = firstNonEmptyLine(text);
745
+ if (line) return truncateArg(scrubSensitiveText(line), 120);
746
+ }
747
+ const summary = describeNonTextMcpContent(entry);
748
+ if (summary) return summary;
749
+ }
750
+ return undefined;
751
+ }
752
+
753
+ export function summarizeMcp(args: Record<string, unknown>, result: NormalizedResult): string {
754
+ const toolName = truncateArg(getString(args, "toolName") ?? "mcp");
755
+ const preview = getMcpResultPreview(result);
756
+ return preview && preview !== toolName ? `${toolName} · ${preview}` : toolName;
757
+ }
758
+
759
+ function formatWebToolBody(
760
+ toolLabel: string,
761
+ args: Record<string, unknown>,
762
+ result: NormalizedResult,
763
+ options: TranscriptOptions,
764
+ summaryArg: string | undefined,
765
+ ): string {
766
+ if (result.status === "error") return joinSections(toolLabel, formatError(result.error));
767
+ const summary = summaryArg ? `${summaryArg}\n\n` : "";
768
+ const value = asRecord(result.value);
769
+ const isError = getBoolean(value, "isError");
770
+ const content = getArray(value, "content") ?? [];
771
+ const text = content
772
+ .map((entry) => getMcpContentText(entry))
773
+ .filter((entry): entry is string => Boolean(entry))
774
+ .join("\n");
775
+ const contentSummary = content.length > 0 ? content.map(describeNonTextMcpContent).join("\n") : stringifyUnknown(result.value);
776
+ const body = `${isError ? "[tool error]\n" : ""}${text || contentSummary}`;
777
+ return joinSections(toolLabel, limitText(`${summary}${body}`.trim(), options));
778
+ }
779
+
780
+ export function formatWebSearch(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
781
+ const query = extractWebSearchQuery(args);
782
+ const header = query ? `web search ${query}` : "web search";
783
+ return formatWebToolBody(header, args, result, options, undefined);
784
+ }
785
+
786
+ export function formatWebFetch(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
787
+ const target = extractWebFetchTarget(args);
788
+ const header = target ? `web fetch ${target}` : "web fetch";
789
+ return formatWebToolBody(header, args, result, options, undefined);
790
+ }
791
+
620
792
  export function formatMcp(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
621
793
  const toolName = typeof args.toolName === "string" ? args.toolName : "mcp";
622
794
  if (result.status === "error") return joinSections(toolName, formatError(result.error));
@@ -633,9 +805,60 @@ export function formatMcp(args: Record<string, unknown>, result: NormalizedResul
633
805
  return joinSections(toolName, limitText(body, options));
634
806
  }
635
807
 
808
+ const UNKNOWN_TOOL_FALLBACK_MAX_ARGS = 8;
809
+ const UNKNOWN_TOOL_FALLBACK_MAX_CHARS = 240;
810
+ const UNKNOWN_TOOL_FALLBACK_MAX_LINES = 6;
811
+
812
+ function summarizeUnknownToolArgValue(value: unknown): string {
813
+ if (typeof value === "string") return truncateArg(value);
814
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
815
+ if (Array.isArray(value)) {
816
+ const preview = value.slice(0, 3).map((entry) => summarizeUnknownToolArgValue(entry)).join(", ");
817
+ const omitted = value.length - Math.min(value.length, 3);
818
+ return omitted > 0 ? `[${preview}, +${omitted} more]` : `[${preview}]`;
819
+ }
820
+ return truncateArg(stringifyUnknown(value).replace(/\s+/g, " "));
821
+ }
822
+
823
+ function summarizeUnknownToolArgs(args: Record<string, unknown>): string {
824
+ const entries = Object.entries(args).slice(0, UNKNOWN_TOOL_FALLBACK_MAX_ARGS);
825
+ if (entries.length === 0) return "";
826
+ const parts = entries.map(([key, value]) => `${key}=${summarizeUnknownToolArgValue(value)}`);
827
+ const omitted = Object.keys(args).length - entries.length;
828
+ const body = parts.join(", ");
829
+ return omitted > 0 ? `${body} (+${omitted} more)` : body;
830
+ }
831
+
832
+ function summarizeUnknownToolResult(value: unknown, options: TranscriptOptions): string {
833
+ const text = stringifyUnknown(value).trim();
834
+ if (!text) return "";
835
+ return limitText(text.replace(/\s+/g, " "), {
836
+ ...options,
837
+ maxChars: options.maxChars ?? UNKNOWN_TOOL_FALLBACK_MAX_CHARS,
838
+ maxLines: options.maxLines ?? UNKNOWN_TOOL_FALLBACK_MAX_LINES,
839
+ });
840
+ }
841
+
842
+ function summarizeUnknownToolError(error: unknown, options: TranscriptOptions): string {
843
+ const text = formatError(error).trim();
844
+ if (!text) return "Error";
845
+ return limitText(text, {
846
+ ...options,
847
+ maxChars: options.maxChars ?? UNKNOWN_TOOL_FALLBACK_MAX_CHARS,
848
+ maxLines: options.maxLines ?? UNKNOWN_TOOL_FALLBACK_MAX_LINES,
849
+ });
850
+ }
851
+
636
852
  export function formatFallback(name: string, args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
637
853
  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));
854
+ if (result.status === "error") {
855
+ const argsSummary = summarizeUnknownToolArgs(args);
856
+ const errorSummary = summarizeUnknownToolError(result.error, options);
857
+ const body = [argsSummary, errorSummary].filter(Boolean).join("\n\n");
858
+ return joinSections(header, body ? limitText(body, options) : undefined);
859
+ }
860
+ const argsSummary = summarizeUnknownToolArgs(args);
861
+ const resultSummary = summarizeUnknownToolResult(result.value, options);
862
+ const body = [argsSummary, resultSummary].filter(Boolean).join("\n\n");
863
+ return joinSections(header, body ? limitText(body, options) : undefined);
641
864
  }