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,121 @@
1
+ import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-works/pi-ai";
2
+
3
+ const DEFAULT_THINKING_TRACE_MAX_CHARS = 50000;
4
+
5
+ export interface CursorPartialContentEmitterOptions {
6
+ stream: AssistantMessageEventStream;
7
+ partial: AssistantMessage;
8
+ thinkingMaxChars?: number;
9
+ mutuallyExclusive?: boolean;
10
+ }
11
+
12
+ export class CursorPartialContentEmitter {
13
+ private thinkingContentIndex = -1;
14
+ private textContentIndex = -1;
15
+ private activityTraceChars = 0;
16
+ private activityTraceTruncated = false;
17
+
18
+ constructor(
19
+ private readonly stream: AssistantMessageEventStream,
20
+ private readonly partial: AssistantMessage,
21
+ private readonly thinkingMaxChars = DEFAULT_THINKING_TRACE_MAX_CHARS,
22
+ private readonly mutuallyExclusive = true,
23
+ ) {}
24
+
25
+ closeThinking(): void {
26
+ if (this.thinkingContentIndex < 0) return;
27
+ const block = this.partial.content[this.thinkingContentIndex];
28
+ if (block.type === "thinking") {
29
+ this.stream.push({
30
+ type: "thinking_end",
31
+ contentIndex: this.thinkingContentIndex,
32
+ content: block.thinking,
33
+ partial: this.partial,
34
+ });
35
+ }
36
+ this.thinkingContentIndex = -1;
37
+ }
38
+
39
+ closeText(): string {
40
+ if (this.textContentIndex < 0) return "";
41
+ const contentIndex = this.textContentIndex;
42
+ const block = this.partial.content[contentIndex];
43
+ this.textContentIndex = -1;
44
+ if (block.type !== "text") return "";
45
+ this.stream.push({
46
+ type: "text_end",
47
+ contentIndex,
48
+ content: block.text,
49
+ partial: this.partial,
50
+ });
51
+ return block.text;
52
+ }
53
+
54
+ closeAll(): string {
55
+ this.closeThinking();
56
+ return this.closeText();
57
+ }
58
+
59
+ appendThinkingDelta(delta: string, options?: { closeText?: boolean }): void {
60
+ const closeText = options?.closeText ?? this.mutuallyExclusive;
61
+ if (closeText) this.closeText();
62
+ if (this.activityTraceTruncated || !delta) return;
63
+
64
+ let text = delta;
65
+ if (this.thinkingMaxChars >= 0 && this.activityTraceChars + text.length > this.thinkingMaxChars) {
66
+ const remainingChars = Math.max(this.thinkingMaxChars - this.activityTraceChars, 0);
67
+ text = `${text.slice(0, remainingChars)}\n[Cursor activity trace truncated]\n`;
68
+ this.activityTraceTruncated = true;
69
+ }
70
+ if (!text) return;
71
+
72
+ if (this.thinkingContentIndex < 0) {
73
+ this.thinkingContentIndex = this.partial.content.length;
74
+ this.partial.content.push({ type: "thinking", thinking: "" });
75
+ this.stream.push({ type: "thinking_start", contentIndex: this.thinkingContentIndex, partial: this.partial });
76
+ }
77
+ const block = this.partial.content[this.thinkingContentIndex];
78
+ if (block.type !== "thinking") return;
79
+ block.thinking += text;
80
+ this.activityTraceChars += text.length;
81
+ this.stream.push({
82
+ type: "thinking_delta",
83
+ contentIndex: this.thinkingContentIndex,
84
+ delta: text,
85
+ partial: this.partial,
86
+ });
87
+ }
88
+
89
+ appendTextDelta(delta: string, options?: { closeThinking?: boolean }): void {
90
+ const closeThinking = options?.closeThinking ?? this.mutuallyExclusive;
91
+ if (closeThinking) this.closeThinking();
92
+ if (!delta) return;
93
+ if (this.textContentIndex < 0) {
94
+ this.textContentIndex = this.partial.content.length;
95
+ this.partial.content.push({ type: "text", text: "" });
96
+ this.stream.push({ type: "text_start", contentIndex: this.textContentIndex, partial: this.partial });
97
+ }
98
+ const block = this.partial.content[this.textContentIndex];
99
+ if (block.type !== "text") return;
100
+ block.text += delta;
101
+ this.stream.push({
102
+ type: "text_delta",
103
+ contentIndex: this.textContentIndex,
104
+ delta,
105
+ partial: this.partial,
106
+ });
107
+ }
108
+
109
+ appendThinkingBlock(text: string, options?: { closeText?: boolean }): void {
110
+ const closeText = options?.closeText ?? this.mutuallyExclusive;
111
+ if (closeText) this.closeAll();
112
+ else this.closeThinking();
113
+ this.appendThinkingDelta(text.endsWith("\n") ? text : `${text}\n`, { closeText: false });
114
+ this.closeThinking();
115
+ }
116
+
117
+ flushText(deltas: string[]): string {
118
+ for (const delta of deltas) this.appendTextDelta(delta);
119
+ return this.closeText();
120
+ }
121
+ }
@@ -0,0 +1,133 @@
1
+ interface CursorPiToolBridgeActiveToolExecution {
2
+ toolCallId: string;
3
+ abort: () => Promise<void> | void;
4
+ cancelPending: (reason: string) => void;
5
+ signal?: AbortSignal;
6
+ onAbort?: () => void;
7
+ }
8
+
9
+ class CursorPiToolBridgeToolExecutionAbortTracker {
10
+ private readonly activeExecutions = new Map<string, CursorPiToolBridgeActiveToolExecution>();
11
+ private processSignalHandlersInstalled = false;
12
+
13
+ track(
14
+ toolCallId: string,
15
+ options: {
16
+ signal?: AbortSignal;
17
+ abort: () => Promise<void> | void;
18
+ cancelPending: (reason: string) => void;
19
+ },
20
+ ): boolean {
21
+ this.finish(toolCallId);
22
+ const execution: CursorPiToolBridgeActiveToolExecution = {
23
+ toolCallId,
24
+ abort: options.abort,
25
+ cancelPending: options.cancelPending,
26
+ signal: options.signal,
27
+ };
28
+ if (options.signal?.aborted) {
29
+ this.cancelExecution(execution, "Cursor pi bridge tool execution was already aborted");
30
+ this.abortExecution(execution);
31
+ return false;
32
+ }
33
+
34
+ execution.onAbort = () => {
35
+ this.cancelExecution(execution, "Cursor pi bridge tool execution was aborted");
36
+ this.finish(toolCallId);
37
+ };
38
+ execution.signal?.addEventListener("abort", execution.onAbort, { once: true });
39
+ this.activeExecutions.set(toolCallId, execution);
40
+ this.installProcessSignalHandlers();
41
+ return true;
42
+ }
43
+
44
+ finish(toolCallId: string): void {
45
+ const execution = this.activeExecutions.get(toolCallId);
46
+ if (!execution) return;
47
+ if (execution.onAbort) execution.signal?.removeEventListener("abort", execution.onAbort);
48
+ this.activeExecutions.delete(toolCallId);
49
+ this.uninstallProcessSignalHandlersIfIdle();
50
+ }
51
+
52
+ finishAll(): void {
53
+ for (const toolCallId of [...this.activeExecutions.keys()]) this.finish(toolCallId);
54
+ }
55
+
56
+ abortAll(reason: string): void {
57
+ for (const execution of [...this.activeExecutions.values()]) {
58
+ this.cancelExecution(execution, reason);
59
+ this.abortExecution(execution);
60
+ this.finish(execution.toolCallId);
61
+ }
62
+ }
63
+
64
+ getActiveCount(): number {
65
+ return this.activeExecutions.size;
66
+ }
67
+
68
+ emitProcessAbortSignalForTests(signal: NodeJS.Signals): void {
69
+ this.abortActiveExecutions(signal, { preserveProcessSignalBehavior: true });
70
+ }
71
+
72
+ private readonly handleSigint = (): void => {
73
+ this.abortActiveExecutions("SIGINT");
74
+ };
75
+
76
+ private readonly handleSigterm = (): void => {
77
+ this.abortActiveExecutions("SIGTERM");
78
+ };
79
+
80
+ private installProcessSignalHandlers(): void {
81
+ if (this.processSignalHandlersInstalled) return;
82
+ this.processSignalHandlersInstalled = true;
83
+ process.on("SIGINT", this.handleSigint);
84
+ process.on("SIGTERM", this.handleSigterm);
85
+ }
86
+
87
+ private uninstallProcessSignalHandlersIfIdle(): void {
88
+ if (!this.processSignalHandlersInstalled || this.activeExecutions.size > 0) return;
89
+ this.processSignalHandlersInstalled = false;
90
+ process.off("SIGINT", this.handleSigint);
91
+ process.off("SIGTERM", this.handleSigterm);
92
+ }
93
+
94
+ private abortActiveExecutions(
95
+ signal: NodeJS.Signals,
96
+ options: { preserveProcessSignalBehavior?: boolean } = {},
97
+ ): void {
98
+ if (this.activeExecutions.size === 0) return;
99
+ const shouldRestoreDefaultSignalBehavior =
100
+ options.preserveProcessSignalBehavior !== true && !this.hasExternalProcessSignalListeners(signal);
101
+ this.abortAll(`Cursor pi bridge tool execution interrupted by ${signal}`);
102
+ if (shouldRestoreDefaultSignalBehavior) this.restoreDefaultProcessSignalBehavior(signal);
103
+ }
104
+
105
+ private cancelExecution(execution: CursorPiToolBridgeActiveToolExecution, reason: string): void {
106
+ try {
107
+ execution.cancelPending(reason);
108
+ } catch {
109
+ // Cancellation is best-effort during process abort/shutdown cleanup; keep aborting siblings.
110
+ }
111
+ }
112
+
113
+ private abortExecution(execution: CursorPiToolBridgeActiveToolExecution): void {
114
+ try {
115
+ Promise.resolve(execution.abort()).catch(() => undefined);
116
+ } catch {
117
+ // Abort is best-effort during process abort/shutdown cleanup; keep aborting siblings.
118
+ }
119
+ }
120
+
121
+ private hasExternalProcessSignalListeners(signal: NodeJS.Signals): boolean {
122
+ const ownHandler = signal === "SIGINT" ? this.handleSigint : this.handleSigterm;
123
+ return process.listeners(signal).some((listener) => listener !== ownHandler);
124
+ }
125
+
126
+ private restoreDefaultProcessSignalBehavior(signal: NodeJS.Signals): void {
127
+ setImmediate(() => {
128
+ process.kill(process.pid, signal);
129
+ });
130
+ }
131
+ }
132
+
133
+ export const bridgeToolExecutionAbortTracker = new CursorPiToolBridgeToolExecutionAbortTracker();
@@ -0,0 +1,179 @@
1
+ import { stableNameHash } from "./cursor-pi-tool-bridge-mcp.js";
2
+ import { parseEnvBoolean } from "./cursor-env-boolean.js";
3
+
4
+ export const CURSOR_PI_TOOL_BRIDGE_DEBUG_ENV = "PI_CURSOR_PI_TOOL_BRIDGE_DEBUG";
5
+ export const CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX = "[pi-cursor-sdk:bridge]";
6
+
7
+ export function resolveCursorPiToolBridgeDebugEnabled(env: Record<string, string | undefined> = process.env): boolean {
8
+ return parseEnvBoolean(env[CURSOR_PI_TOOL_BRIDGE_DEBUG_ENV], false);
9
+ }
10
+
11
+ function createCursorMcpCallDiagnosticId(cursorMcpCallId: string | undefined): string | undefined {
12
+ return cursorMcpCallId ? `cursor-mcp-call-${stableNameHash(cursorMcpCallId)}` : undefined;
13
+ }
14
+
15
+ type CursorPiToolBridgeSkippedReason = "disabled" | "no_exposed_tools";
16
+ export type CursorPiToolBridgeRejectionKind = "cancelled" | "error";
17
+
18
+ export interface CursorPiToolBridgeLifecycleDiagnosticFields {
19
+ runId: string;
20
+ enabled: boolean;
21
+ exposedToolCount: number;
22
+ pendingCount: number;
23
+ }
24
+
25
+ interface CursorPiToolBridgeRunCreatedDiagnostic extends CursorPiToolBridgeLifecycleDiagnosticFields {
26
+ event: "run_created";
27
+ }
28
+
29
+ interface CursorPiToolBridgeRunSkippedDiagnostic extends CursorPiToolBridgeLifecycleDiagnosticFields {
30
+ event: "run_skipped";
31
+ reason: CursorPiToolBridgeSkippedReason;
32
+ }
33
+
34
+ interface CursorPiToolBridgeToolsExposedDiagnostic extends CursorPiToolBridgeLifecycleDiagnosticFields {
35
+ event: "tools_exposed";
36
+ pairs: Array<{ piToolName: string; mcpToolName: string }>;
37
+ }
38
+
39
+ interface CursorPiToolBridgeRunCancelledDiagnostic extends CursorPiToolBridgeLifecycleDiagnosticFields {
40
+ event: "run_cancelled";
41
+ queuedCount: number;
42
+ cancelledRequestCount: number;
43
+ }
44
+
45
+ interface CursorPiToolBridgeRunDisposedDiagnostic extends CursorPiToolBridgeLifecycleDiagnosticFields {
46
+ event: "run_disposed";
47
+ }
48
+
49
+ export interface CursorPiToolBridgeRequestDiagnosticFields {
50
+ runId: string;
51
+ bridgeCallId: string;
52
+ cursorMcpCallId?: string;
53
+ piToolCallId: string;
54
+ mcpToolName: string;
55
+ piToolName: string;
56
+ pendingCount: number;
57
+ }
58
+
59
+ interface CursorPiToolBridgeRequestQueuedDiagnostic extends CursorPiToolBridgeRequestDiagnosticFields {
60
+ event: "request_queued";
61
+ }
62
+
63
+ interface CursorPiToolBridgeRequestResolvedDiagnostic extends CursorPiToolBridgeRequestDiagnosticFields {
64
+ event: "request_resolved";
65
+ isError: boolean;
66
+ }
67
+
68
+ interface CursorPiToolBridgeRequestRejectedDiagnostic extends CursorPiToolBridgeRequestDiagnosticFields {
69
+ event: "request_rejected";
70
+ rejectionKind: CursorPiToolBridgeRejectionKind;
71
+ }
72
+
73
+ export type CursorPiToolBridgeDiagnosticEvent =
74
+ | CursorPiToolBridgeRunCreatedDiagnostic
75
+ | CursorPiToolBridgeRunSkippedDiagnostic
76
+ | CursorPiToolBridgeToolsExposedDiagnostic
77
+ | CursorPiToolBridgeRunCancelledDiagnostic
78
+ | CursorPiToolBridgeRunDisposedDiagnostic
79
+ | CursorPiToolBridgeRequestQueuedDiagnostic
80
+ | CursorPiToolBridgeRequestResolvedDiagnostic
81
+ | CursorPiToolBridgeRequestRejectedDiagnostic;
82
+
83
+ function assertNeverDiagnosticEvent(_event: never): never {
84
+ throw new Error("Unhandled Cursor pi tool bridge diagnostic event");
85
+ }
86
+
87
+ export function serializeCursorPiToolBridgeDiagnostic(event: CursorPiToolBridgeDiagnosticEvent): Record<string, unknown> {
88
+ switch (event.event) {
89
+ case "run_created":
90
+ return {
91
+ event: event.event,
92
+ runId: event.runId,
93
+ enabled: event.enabled,
94
+ exposedToolCount: event.exposedToolCount,
95
+ pendingCount: event.pendingCount,
96
+ };
97
+ case "run_skipped":
98
+ return {
99
+ event: event.event,
100
+ runId: event.runId,
101
+ enabled: event.enabled,
102
+ exposedToolCount: event.exposedToolCount,
103
+ pendingCount: event.pendingCount,
104
+ reason: event.reason,
105
+ };
106
+ case "tools_exposed":
107
+ return {
108
+ event: event.event,
109
+ runId: event.runId,
110
+ enabled: event.enabled,
111
+ exposedToolCount: event.exposedToolCount,
112
+ pendingCount: event.pendingCount,
113
+ pairs: event.pairs.map((pair) => ({ piToolName: pair.piToolName, mcpToolName: pair.mcpToolName })),
114
+ };
115
+ case "run_cancelled":
116
+ return {
117
+ event: event.event,
118
+ runId: event.runId,
119
+ enabled: event.enabled,
120
+ exposedToolCount: event.exposedToolCount,
121
+ pendingCount: event.pendingCount,
122
+ queuedCount: event.queuedCount,
123
+ cancelledRequestCount: event.cancelledRequestCount,
124
+ };
125
+ case "run_disposed":
126
+ return {
127
+ event: event.event,
128
+ runId: event.runId,
129
+ enabled: event.enabled,
130
+ exposedToolCount: event.exposedToolCount,
131
+ pendingCount: event.pendingCount,
132
+ };
133
+ case "request_queued":
134
+ return {
135
+ event: event.event,
136
+ runId: event.runId,
137
+ bridgeCallId: event.bridgeCallId,
138
+ cursorMcpCallId: createCursorMcpCallDiagnosticId(event.cursorMcpCallId),
139
+ piToolCallId: event.piToolCallId,
140
+ mcpToolName: event.mcpToolName,
141
+ piToolName: event.piToolName,
142
+ pendingCount: event.pendingCount,
143
+ };
144
+ case "request_resolved":
145
+ return {
146
+ event: event.event,
147
+ runId: event.runId,
148
+ bridgeCallId: event.bridgeCallId,
149
+ cursorMcpCallId: createCursorMcpCallDiagnosticId(event.cursorMcpCallId),
150
+ piToolCallId: event.piToolCallId,
151
+ mcpToolName: event.mcpToolName,
152
+ piToolName: event.piToolName,
153
+ pendingCount: event.pendingCount,
154
+ isError: event.isError,
155
+ };
156
+ case "request_rejected":
157
+ return {
158
+ event: event.event,
159
+ runId: event.runId,
160
+ bridgeCallId: event.bridgeCallId,
161
+ cursorMcpCallId: createCursorMcpCallDiagnosticId(event.cursorMcpCallId),
162
+ piToolCallId: event.piToolCallId,
163
+ mcpToolName: event.mcpToolName,
164
+ piToolName: event.piToolName,
165
+ pendingCount: event.pendingCount,
166
+ rejectionKind: event.rejectionKind,
167
+ };
168
+ }
169
+ return assertNeverDiagnosticEvent(event);
170
+ }
171
+
172
+ export function writeCursorPiToolBridgeDiagnostic(env: Record<string, string | undefined>, event: CursorPiToolBridgeDiagnosticEvent): void {
173
+ if (!resolveCursorPiToolBridgeDebugEnabled(env)) return;
174
+ try {
175
+ process.stderr.write(`${CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX} ${JSON.stringify(serializeCursorPiToolBridgeDiagnostic(event))}\n`);
176
+ } catch {
177
+ // Diagnostics must never affect bridge execution.
178
+ }
179
+ }
@@ -0,0 +1,118 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Context, ToolResultMessage } from "@earendil-works/pi-ai";
3
+ import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
4
+ import { buildCursorPiBridgeMcpToolDescription, CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX } from "./cursor-bridge-contract.js";
5
+ import type { CursorPiBridgeToolDefinition, CursorPiMcpInputSchema } from "./cursor-pi-tool-bridge-types.js";
6
+ import { getFirstStringByKeys } from "./cursor-record-utils.js";
7
+
8
+ export function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === "object" && value !== null;
10
+ }
11
+
12
+ export function normalizeMcpInputSchema(schema: unknown): CursorPiMcpInputSchema {
13
+ if (isRecord(schema) && schema.type === "object") return schema as CursorPiMcpInputSchema;
14
+ return { type: "object", properties: {} };
15
+ }
16
+
17
+ export function normalizeMcpArgs(args: unknown): Record<string, unknown> {
18
+ return isRecord(args) ? { ...args } : {};
19
+ }
20
+
21
+ export function waitForProtocolFlush(): Promise<void> {
22
+ return new Promise((resolve) => setTimeout(resolve, 0));
23
+ }
24
+
25
+ function sanitizeMcpToolNameStem(toolName: string): string {
26
+ const stem = toolName
27
+ .trim()
28
+ .replace(/[^A-Za-z0-9_-]+/g, "_")
29
+ .replace(/^_+|_+$/g, "");
30
+ return stem || "tool";
31
+ }
32
+
33
+ export function stableNameHash(value: string): string {
34
+ return createHash("sha256").update(value).digest("hex").slice(0, 8);
35
+ }
36
+
37
+ export function createMcpToolName(piToolName: string, usedMcpToolNames: Set<string>): string {
38
+ const baseName = `${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}${sanitizeMcpToolNameStem(piToolName)}`;
39
+ if (!usedMcpToolNames.has(baseName)) {
40
+ usedMcpToolNames.add(baseName);
41
+ return baseName;
42
+ }
43
+
44
+ const hashedName = `${baseName}__${stableNameHash(piToolName)}`;
45
+ if (!usedMcpToolNames.has(hashedName)) {
46
+ usedMcpToolNames.add(hashedName);
47
+ return hashedName;
48
+ }
49
+
50
+ let counter = 2;
51
+ let candidate = `${hashedName}_${counter}`;
52
+ while (usedMcpToolNames.has(candidate)) {
53
+ counter += 1;
54
+ candidate = `${hashedName}_${counter}`;
55
+ }
56
+ usedMcpToolNames.add(candidate);
57
+ return candidate;
58
+ }
59
+
60
+ export function snapshotToolToMcpTool(tool: CursorPiBridgeToolDefinition): Tool {
61
+ return {
62
+ name: tool.mcpToolName,
63
+ description: buildCursorPiBridgeMcpToolDescription({
64
+ piToolName: tool.piToolName,
65
+ mcpToolName: tool.mcpToolName,
66
+ piToolDescription: tool.description,
67
+ }),
68
+ inputSchema: tool.inputSchema,
69
+ _meta: { piToolName: tool.piToolName },
70
+ };
71
+ }
72
+
73
+ export function convertPiContentToMcpContent(content: unknown): CallToolResult["content"] {
74
+ if (!Array.isArray(content)) {
75
+ return [{ type: "text", text: typeof content === "string" ? content : JSON.stringify(content) }];
76
+ }
77
+
78
+ const mcpContent: CallToolResult["content"] = [];
79
+ for (const block of content) {
80
+ if (!isRecord(block)) continue;
81
+ if (block.type === "text" && typeof block.text === "string") {
82
+ mcpContent.push({ type: "text", text: block.text });
83
+ continue;
84
+ }
85
+ if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") {
86
+ mcpContent.push({ type: "image", data: block.data, mimeType: block.mimeType });
87
+ continue;
88
+ }
89
+ mcpContent.push({ type: "text", text: JSON.stringify(block) });
90
+ }
91
+
92
+ return mcpContent.length > 0 ? mcpContent : [{ type: "text", text: "" }];
93
+ }
94
+
95
+ export function asToolResultMessage(value: Context["messages"][number]): ToolResultMessage | undefined {
96
+ return value.role === "toolResult" ? value : undefined;
97
+ }
98
+
99
+ export function getStringField(record: Record<string, unknown>, fields: string[]): string | undefined {
100
+ return getFirstStringByKeys(record, fields, { nonEmpty: true });
101
+ }
102
+
103
+ export function containsKnownMcpToolName(value: unknown, knownMcpToolNames: ReadonlySet<string>, depth = 0): boolean {
104
+ if (depth > 4) return false;
105
+ if (Array.isArray(value)) return value.some((entry) => containsKnownMcpToolName(entry, knownMcpToolNames, depth + 1));
106
+ if (!isRecord(value)) return false;
107
+
108
+ for (const field of ["tool", "toolName", "name", "mcpToolName", "serverToolName"]) {
109
+ const fieldValue = value[field];
110
+ if (typeof fieldValue === "string" && knownMcpToolNames.has(fieldValue)) return true;
111
+ }
112
+
113
+ for (const nestedField of ["args", "arguments", "input"]) {
114
+ if (containsKnownMcpToolName(value[nestedField], knownMcpToolNames, depth + 1)) return true;
115
+ }
116
+
117
+ return false;
118
+ }