feat(cursor): added Cursor AI provider with OAuth, HTTP/2 streaming, and tool execution bridge
- Added Cursor AI provider with HTTP/2 streaming support using Connect protocol and protobuf binary serialization. - Added OAuth authentication flow for Cursor including browser-based login, token refresh, and expiry detection. - Added 14 Cursor model definitions including Claude 4.5, GPT 5.x, Gemini 3, and Grok variants. - Added execution bridge for Cursor tool calls supporting read, ls, grep, write, delete, shell, diagnostics, and MCP operations. - Added cursorExecHandlers and cursorOnToolResult options to Agent for local tool execution with cursor-based streaming. - Added emitExternalEvent method to Agent class for external event injection into agent state.
This commit is contained in:
@@ -43,6 +43,7 @@
|
||||
"!**/test-sessions.ts",
|
||||
"!**/models.generated.ts",
|
||||
"!**/template.generated.ts",
|
||||
"!**/gen/agent_pb.ts",
|
||||
"!packages/web-ui/src/app.css",
|
||||
"!packages/mom/data/**/*",
|
||||
"!!**/node_modules"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@bufbuild/protoc-gen-es": "^2.10.2",
|
||||
"@types/bun": "^1.2.17",
|
||||
"@types/node": "^22.10.5",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260102.1",
|
||||
@@ -38,6 +39,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@bufbuild/protobuf": "^2.10.2",
|
||||
"@connectrpc/connect": "^2.1.1",
|
||||
"@connectrpc/connect-node": "^2.1.1",
|
||||
"@google/genai": "1.34.0",
|
||||
"@mistralai/mistralai": "1.10.0",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
@@ -143,8 +147,18 @@
|
||||
|
||||
"@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
|
||||
|
||||
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.10.2", "", {}, "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A=="],
|
||||
|
||||
"@bufbuild/protoc-gen-es": ["@bufbuild/protoc-gen-es@2.10.2", "", { "dependencies": { "@bufbuild/protobuf": "2.10.2", "@bufbuild/protoplugin": "2.10.2" }, "bin": { "protoc-gen-es": "bin/protoc-gen-es" } }, "sha512-vbjPsuofbtZwZXuOP7Y16CQsxrwCjuRONffmJSBEhoC7PQu/Cabp0+Fu/poLPm9CNM0tDCQA0xvgobgudaEYxQ=="],
|
||||
|
||||
"@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.10.2", "", { "dependencies": { "@bufbuild/protobuf": "2.10.2", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-RAWVs9tCzRqSS3tUtaFhOcauOAazCrm7tlGh0WHFq/44n5Fj6YgefdlZEPIaAK6VAA+FdOoFgtOJK2Ji5U24pw=="],
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
|
||||
|
||||
"@connectrpc/connect": ["@connectrpc/connect@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ=="],
|
||||
|
||||
"@connectrpc/connect-node": ["@connectrpc/connect-node@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.1.1" } }, "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg=="],
|
||||
|
||||
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
@@ -385,6 +399,8 @@
|
||||
|
||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260109.1", "", { "os": "win32", "cpu": "x64" }, "sha512-da44CbC8ktr741ISLvCQlz3Gv2UqO2M+rB585xCFNjcz+0IyOKkBGr9eR++f6uy46/QKFK4w44x0cK71PVqk9g=="],
|
||||
|
||||
"@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
|
||||
@@ -913,6 +929,8 @@
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
|
||||
|
||||
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@bufbuild/protoc-gen-es": "^2.10.2",
|
||||
"@types/bun": "^1.2.17",
|
||||
"@types/node": "^22.10.5",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260102.1",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added `cursorExecHandlers` and `cursorOnToolResult` options for local tool execution with cursor-based streaming
|
||||
- Added `emitExternalEvent` method to allow external event injection into the agent state
|
||||
|
||||
## [4.2.3] - 2026-01-11
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
type CursorExecHandlers,
|
||||
type CursorToolResultHandler,
|
||||
getModel,
|
||||
type ImageContent,
|
||||
type Message,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
streamSimple,
|
||||
type TextContent,
|
||||
type ThinkingBudgets,
|
||||
type ToolResultMessage,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import { agentLoop, agentLoopContinue } from "./agent-loop";
|
||||
import type {
|
||||
@@ -91,6 +94,16 @@ export interface AgentOptions {
|
||||
* Use for late-bound UI or session state access.
|
||||
*/
|
||||
getToolContext?: () => AgentToolContext | undefined;
|
||||
|
||||
/**
|
||||
* Cursor exec handlers for local tool execution.
|
||||
*/
|
||||
cursorExecHandlers?: CursorExecHandlers;
|
||||
|
||||
/**
|
||||
* Cursor tool result callback for exec tool responses.
|
||||
*/
|
||||
cursorOnToolResult?: CursorToolResultHandler;
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
@@ -120,6 +133,8 @@ export class Agent {
|
||||
private _thinkingBudgets?: ThinkingBudgets;
|
||||
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
private getToolContext?: () => AgentToolContext | undefined;
|
||||
private cursorExecHandlers?: CursorExecHandlers;
|
||||
private cursorOnToolResult?: CursorToolResultHandler;
|
||||
private runningPrompt?: Promise<void>;
|
||||
private resolveRunningPrompt?: () => void;
|
||||
|
||||
@@ -135,6 +150,8 @@ export class Agent {
|
||||
this._thinkingBudgets = opts.thinkingBudgets;
|
||||
this.getApiKey = opts.getApiKey;
|
||||
this.getToolContext = opts.getToolContext;
|
||||
this.cursorExecHandlers = opts.cursorExecHandlers;
|
||||
this.cursorOnToolResult = opts.cursorOnToolResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +192,33 @@ export class Agent {
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
emitExternalEvent(event: AgentEvent) {
|
||||
switch (event.type) {
|
||||
case "message_start":
|
||||
case "message_update":
|
||||
this._state.streamMessage = event.message;
|
||||
break;
|
||||
case "message_end":
|
||||
this._state.streamMessage = null;
|
||||
this.appendMessage(event.message);
|
||||
break;
|
||||
case "tool_execution_start": {
|
||||
const pending = new Set(this._state.pendingToolCalls);
|
||||
pending.add(event.toolCallId);
|
||||
this._state.pendingToolCalls = pending;
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
const pending = new Set(this._state.pendingToolCalls);
|
||||
pending.delete(event.toolCallId);
|
||||
this._state.pendingToolCalls = pending;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(event);
|
||||
}
|
||||
|
||||
// State mutators
|
||||
setSystemPrompt(v: string) {
|
||||
this._state.systemPrompt = v;
|
||||
@@ -382,6 +426,24 @@ export class Agent {
|
||||
tools: this._state.tools,
|
||||
};
|
||||
|
||||
const cursorOnToolResult =
|
||||
this.cursorExecHandlers || this.cursorOnToolResult
|
||||
? async (message: ToolResultMessage) => {
|
||||
let finalMessage = message;
|
||||
if (this.cursorOnToolResult) {
|
||||
try {
|
||||
const updated = await this.cursorOnToolResult(message);
|
||||
if (updated) {
|
||||
finalMessage = updated;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
this.emitExternalEvent({ type: "message_start", message: finalMessage });
|
||||
this.emitExternalEvent({ type: "message_end", message: finalMessage });
|
||||
return finalMessage;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model,
|
||||
reasoning,
|
||||
@@ -392,6 +454,8 @@ export class Agent {
|
||||
transformContext: this.transformContext,
|
||||
getApiKey: this.getApiKey,
|
||||
getToolContext: this.getToolContext,
|
||||
cursorExecHandlers: this.cursorExecHandlers,
|
||||
cursorOnToolResult,
|
||||
getSteeringMessages: async () => {
|
||||
if (this.steeringMode === "one-at-a-time") {
|
||||
if (this.steeringQueue.length > 0) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Cursor provider with support for Claude, GPT, and Gemini models via Cursor's agent API
|
||||
- Added OAuth authentication flow for Cursor including login, token refresh, and expiry detection
|
||||
- Added `cursor-agent` API type with streaming support and tool execution handlers
|
||||
- Added Cursor model definitions including Claude 4.5, GPT-5.x, Gemini 3, and Grok variants
|
||||
- Added model generation script to automatically fetch and update AI model definitions from models.dev and OpenRouter APIs
|
||||
|
||||
## [4.2.3] - 2026-01-11
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@bufbuild/protobuf": "^2.10.2",
|
||||
"@connectrpc/connect": "^2.1.1",
|
||||
"@connectrpc/connect-node": "^2.1.1",
|
||||
"@google/genai": "1.34.0",
|
||||
"@mistralai/mistralai": "1.10.0",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./models";
|
||||
export * from "./providers/anthropic";
|
||||
export * from "./providers/cursor";
|
||||
export * from "./providers/google";
|
||||
export * from "./providers/google-gemini-cli";
|
||||
export * from "./providers/google-vertex";
|
||||
|
||||
@@ -433,6 +433,229 @@ export const MODELS = {
|
||||
maxTokens: 40000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
},
|
||||
"cursor": {
|
||||
"claude-4.5-opus-high": {
|
||||
id: "claude-4.5-opus-high",
|
||||
name: "Claude 4.5 Opus (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"claude-4.5-opus-high-thinking": {
|
||||
id: "claude-4.5-opus-high-thinking",
|
||||
name: "Claude 4.5 Opus Thinking (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"claude-4.5-sonnet": {
|
||||
id: "claude-4.5-sonnet",
|
||||
name: "Claude 4.5 Sonnet (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"claude-4.5-sonnet-thinking": {
|
||||
id: "claude-4.5-sonnet-thinking",
|
||||
name: "Claude 4.5 Sonnet Thinking (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"composer-1": {
|
||||
id: "composer-1",
|
||||
name: "Composer 1 (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"default": {
|
||||
id: "default",
|
||||
name: "Auto (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"gemini-3-flash": {
|
||||
id: "gemini-3-flash",
|
||||
name: "Gemini 3 Flash (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 65535,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"gemini-3-pro": {
|
||||
id: "gemini-3-pro",
|
||||
name: "Gemini 3 Pro (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 65535,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"gpt-5.1-codex-max": {
|
||||
id: "gpt-5.1-codex-max",
|
||||
name: "GPT-5.1 Codex Max (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"gpt-5.1-codex-max-high": {
|
||||
id: "gpt-5.1-codex-max-high",
|
||||
name: "GPT-5.1 Codex Max High (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"gpt-5.2": {
|
||||
id: "gpt-5.2",
|
||||
name: "GPT-5.2 (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"gpt-5.2-high": {
|
||||
id: "gpt-5.2-high",
|
||||
name: "GPT-5.2 High (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
"grok-code-fast-1": {
|
||||
id: "grok-code-fast-1",
|
||||
name: "Grok (Cursor)",
|
||||
api: "cursor-agent",
|
||||
provider: "cursor",
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 32768,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"cursor-agent">,
|
||||
},
|
||||
"github-copilot": {
|
||||
"claude-haiku-4.5": {
|
||||
id: "claude-haiku-4.5",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
version: v2
|
||||
plugins:
|
||||
- local: /work/pi/node_modules/.bin/protoc-gen-es
|
||||
out: ../gen
|
||||
opt:
|
||||
- target=ts
|
||||
@@ -0,0 +1,17 @@
|
||||
version: v2
|
||||
modules:
|
||||
- path: .
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
except:
|
||||
- COMMENT_FIELD
|
||||
- COMMENT_MESSAGE
|
||||
- COMMENT_ENUM
|
||||
- COMMENT_ENUM_VALUE
|
||||
- COMMENT_RPC
|
||||
- COMMENT_SERVICE
|
||||
- COMMENT_ONEOF
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
@@ -0,0 +1,577 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extracts protobuf definitions from bundled JavaScript generated by @bufbuild/protobuf.
|
||||
Reconstructs .proto files with comments preserved.
|
||||
|
||||
Usage: python proto-extractor.py <bundled-js-file> <output-dir>
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
SCALAR_TYPES = {
|
||||
1: "double", 2: "float", 3: "int64", 4: "uint64", 5: "int32",
|
||||
6: "fixed64", 7: "fixed32", 8: "bool", 9: "string", 10: "group",
|
||||
11: "message", 12: "bytes", 13: "uint32", 14: "enum",
|
||||
15: "sfixed32", 16: "sfixed64", 17: "sint32", 18: "sint64",
|
||||
}
|
||||
|
||||
WEBPACK_NOISE = [
|
||||
'__webpack_require__', 'harmony export', 'harmony import',
|
||||
'use strict', 'WEBPACK_IMPORTED_MODULE', 'binding',
|
||||
]
|
||||
|
||||
@dataclass
|
||||
class FieldDef:
|
||||
no: int
|
||||
name: str
|
||||
kind: str
|
||||
type_ref: str
|
||||
comment: str = ""
|
||||
opt: bool = False
|
||||
repeated: bool = False
|
||||
oneof: Optional[str] = None
|
||||
map_key: Optional[int] = None
|
||||
|
||||
@dataclass
|
||||
class MessageDef:
|
||||
type_name: str
|
||||
fields: list[FieldDef] = field(default_factory=list)
|
||||
comment: str = ""
|
||||
|
||||
@dataclass
|
||||
class EnumValueDef:
|
||||
name: str
|
||||
no: int
|
||||
comment: str = ""
|
||||
|
||||
@dataclass
|
||||
class EnumDef:
|
||||
type_name: str
|
||||
values: list[EnumValueDef] = field(default_factory=list)
|
||||
comment: str = ""
|
||||
|
||||
@dataclass
|
||||
class MethodDef:
|
||||
name: str
|
||||
input_type: str
|
||||
output_type: str
|
||||
kind: str
|
||||
comment: str = ""
|
||||
|
||||
@dataclass
|
||||
class ServiceDef:
|
||||
type_name: str
|
||||
methods: list[MethodDef] = field(default_factory=list)
|
||||
comment: str = ""
|
||||
|
||||
@dataclass
|
||||
class ProtoFile:
|
||||
path: str
|
||||
package: str
|
||||
syntax: str = "proto3"
|
||||
messages: list[MessageDef] = field(default_factory=list)
|
||||
enums: list[EnumDef] = field(default_factory=list)
|
||||
services: list[ServiceDef] = field(default_factory=list)
|
||||
imports: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def is_webpack_noise(text: str) -> bool:
|
||||
"""Check if text contains webpack bundler noise."""
|
||||
return any(noise in text for noise in WEBPACK_NOISE)
|
||||
|
||||
|
||||
def extract_jsdoc_comment(text: str) -> str:
|
||||
"""Extract clean comment from JSDoc block, filtering webpack noise."""
|
||||
if is_webpack_noise(text):
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
for line in text.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('*'):
|
||||
line = line[1:].strip()
|
||||
if line.startswith('/**') or line.endswith('*/'):
|
||||
continue
|
||||
if '@generated' in line:
|
||||
continue
|
||||
if is_webpack_noise(line):
|
||||
continue
|
||||
if line.startswith('case:') or '= { case:' in line:
|
||||
continue
|
||||
if line == '/' or line == '//' or len(line) <= 2:
|
||||
continue
|
||||
if line:
|
||||
lines.append(line)
|
||||
|
||||
result = ' '.join(lines)
|
||||
if is_webpack_noise(result):
|
||||
return ""
|
||||
if len(result) <= 2:
|
||||
return ""
|
||||
return result
|
||||
|
||||
|
||||
def parse_type_reference(rest: str) -> str:
|
||||
"""Parse the T: field to extract the type reference."""
|
||||
webpack_comment = re.search(r'T:\s*[^,]+/\*\s*\.?(\w+)\s*\*/', rest)
|
||||
if webpack_comment:
|
||||
return webpack_comment.group(1)
|
||||
|
||||
type_match = re.search(r'T:\s*(\d+|[A-Za-z_][A-Za-z0-9_]*)', rest)
|
||||
if type_match:
|
||||
t = type_match.group(1)
|
||||
if t.isdigit():
|
||||
return SCALAR_TYPES.get(int(t), f"scalar_{t}")
|
||||
if is_webpack_noise(t) or t.startswith('_'):
|
||||
return "unknown"
|
||||
return t
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def parse_field_list(fields_str: str, field_comments: dict[str, str]) -> list[FieldDef]:
|
||||
"""Parse the fields array from newFieldList."""
|
||||
fields = []
|
||||
pattern = r'\{\s*no:\s*(\d+)\s*,\s*name:\s*"([^"]+)"\s*,\s*kind:\s*"([^"]+)"([^}]*)\}'
|
||||
|
||||
for m in re.finditer(pattern, fields_str):
|
||||
no, name, kind, rest = int(m.group(1)), m.group(2), m.group(3), m.group(4)
|
||||
type_ref = parse_type_reference(rest)
|
||||
|
||||
fields.append(FieldDef(
|
||||
no=no,
|
||||
name=name,
|
||||
kind=kind,
|
||||
type_ref=type_ref,
|
||||
comment=field_comments.get(name, ""),
|
||||
opt='opt: true' in rest,
|
||||
repeated='repeated: true' in rest,
|
||||
oneof=m2.group(1) if (m2 := re.search(r'oneof:\s*"([^"]+)"', rest)) else None,
|
||||
map_key=int(m2.group(1)) if (m2 := re.search(r'mapKey:\s*(\d+)', rest)) else None,
|
||||
))
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def extract_field_comments(class_body: str) -> dict[str, str]:
|
||||
"""Extract field comments from class property declarations."""
|
||||
comments = {}
|
||||
pattern = r'/\*\*([\s\S]*?)@generated from field:[^*]*\*/\s*\n?\s*(\w+)'
|
||||
|
||||
for m in re.finditer(pattern, class_body):
|
||||
comment_text = extract_jsdoc_comment(m.group(1))
|
||||
field_name_camel = m.group(2)
|
||||
field_name_snake = re.sub(r'([A-Z])', r'_\1', field_name_camel).lower().lstrip('_')
|
||||
if comment_text and not is_webpack_noise(comment_text):
|
||||
comments[field_name_snake] = comment_text
|
||||
|
||||
return comments
|
||||
|
||||
|
||||
def find_file_for_pos(file_ranges: dict[str, list[tuple[int, int]]], pos: int) -> str | None:
|
||||
"""Find which file a position belongs to."""
|
||||
for fp, segments in file_ranges.items():
|
||||
for start, end in segments:
|
||||
if start <= pos < end:
|
||||
return fp
|
||||
return None
|
||||
|
||||
|
||||
def extract_messages(content: str, file_ranges: dict[str, list[tuple[int, int]]]) -> dict[str, list[MessageDef]]:
|
||||
"""Extract all message definitions grouped by file."""
|
||||
messages_by_file: dict[str, list[MessageDef]] = {}
|
||||
seen_types: set[str] = set()
|
||||
|
||||
pattern = re.compile(
|
||||
r'/\*\*([\s\S]*?)@generated from message ([^\s*]+)[\s\S]*?\*/'
|
||||
r'[\s\S]*?class (\w+) extends [^{]+\{'
|
||||
r'([\s\S]*?)'
|
||||
r'static typeName\s*=\s*"([^"]+)"'
|
||||
r'[\s\S]*?'
|
||||
r'static fields\s*=\s*[^(]+\(\(\)\s*=>\s*\[([\s\S]*?)\]\)',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
for m in pattern.finditer(content):
|
||||
comment_block, _, class_name, class_body, type_name, fields_str = m.groups()
|
||||
pos = m.start()
|
||||
|
||||
if type_name in seen_types:
|
||||
continue
|
||||
seen_types.add(type_name)
|
||||
|
||||
file_path = find_file_for_pos(file_ranges, pos)
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
comment = extract_jsdoc_comment(comment_block)
|
||||
field_comments = extract_field_comments(class_body)
|
||||
fields = parse_field_list(fields_str, field_comments)
|
||||
|
||||
msg = MessageDef(type_name=type_name, fields=fields, comment=comment)
|
||||
messages_by_file.setdefault(file_path, []).append(msg)
|
||||
|
||||
return messages_by_file
|
||||
|
||||
|
||||
def extract_enums(content: str, file_ranges: dict[str, list[tuple[int, int]]]) -> dict[str, list[EnumDef]]:
|
||||
"""Extract all enum definitions grouped by file."""
|
||||
enums_by_file: dict[str, list[EnumDef]] = {}
|
||||
seen_types: set[str] = set()
|
||||
|
||||
enum_block_pattern = re.compile(
|
||||
r'/\*\*([\s\S]*?)@generated from enum ([^\s*]+)[\s\S]*?\*/'
|
||||
r'[\s\S]*?'
|
||||
r'setEnumType\([^,]+,\s*"([^"]+)",\s*\[([\s\S]*?)\]\)',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
for m in enum_block_pattern.finditer(content):
|
||||
comment_block, _, type_name, values_str = m.groups()
|
||||
pos = m.start()
|
||||
|
||||
if type_name in seen_types:
|
||||
continue
|
||||
seen_types.add(type_name)
|
||||
|
||||
file_path = find_file_for_pos(file_ranges, pos)
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
comment = extract_jsdoc_comment(comment_block)
|
||||
|
||||
values = []
|
||||
value_pattern = r'\{\s*no:\s*(\d+)\s*,\s*name:\s*"([^"]+)"\s*\}'
|
||||
for vm in re.finditer(value_pattern, values_str):
|
||||
values.append(EnumValueDef(no=int(vm.group(1)), name=vm.group(2)))
|
||||
|
||||
enum = EnumDef(type_name=type_name, values=values, comment=comment)
|
||||
enums_by_file.setdefault(file_path, []).append(enum)
|
||||
|
||||
return enums_by_file
|
||||
|
||||
|
||||
def resolve_webpack_type(ref: str) -> str:
|
||||
"""Resolve a webpack type reference like 'agent_service_pb/* AgentClientMessage */.KS'."""
|
||||
webpack_comment = re.search(r'/\*\s*(\w+)\s*\*/', ref)
|
||||
if webpack_comment:
|
||||
return webpack_comment.group(1)
|
||||
parts = ref.replace(',', '').strip().split('.')
|
||||
return parts[-1] if parts else ref
|
||||
|
||||
|
||||
def extract_services(content: str, file_ranges: dict[str, list[tuple[int, int]]]) -> dict[str, list[ServiceDef]]:
|
||||
"""Extract all service definitions grouped by file."""
|
||||
services_by_file: dict[str, list[ServiceDef]] = {}
|
||||
seen_types: set[str] = set()
|
||||
|
||||
service_pattern = re.compile(
|
||||
r'/\*\*([^*]|\*[^/])*@generated from service ([^\s*]+)([^*]|\*[^/])*\*/'
|
||||
r'\s*(?:const|var)\s+\w+\s*=\s*\{'
|
||||
r'[^}]*typeName:\s*"([^"]+)"'
|
||||
r'[^}]*methods:\s*\{([\s\S]*?)\}\s*\}',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
for m in service_pattern.finditer(content):
|
||||
groups = m.groups()
|
||||
type_name = groups[3] # typeName
|
||||
methods_str = groups[4] # methods content
|
||||
pos = m.start()
|
||||
|
||||
if type_name in seen_types:
|
||||
continue
|
||||
seen_types.add(type_name)
|
||||
|
||||
file_path = find_file_for_pos(file_ranges, pos)
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
# Extract comment from the match text before @generated
|
||||
full_match = m.group(0)
|
||||
comment_end = full_match.find('@generated')
|
||||
comment_text = full_match[3:comment_end] if comment_end > 0 else ""
|
||||
comment = extract_jsdoc_comment(comment_text)
|
||||
|
||||
methods = []
|
||||
method_pattern = re.compile(
|
||||
r'/\*\*([\s\S]*?)@generated from rpc [^\s*]+\.(\w+)[\s\S]*?\*/'
|
||||
r'\s*\w+:\s*\{'
|
||||
r'[^}]*name:\s*"([^"]+)"'
|
||||
r'[^}]*I:\s*([^,]+),'
|
||||
r'[^}]*O:\s*([^,]+),'
|
||||
r'[^}]*kind:\s*[^.]+\.(\w+)',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
for mm in method_pattern.finditer(methods_str):
|
||||
method_comment, _, name, input_ref, output_ref, kind = mm.groups()
|
||||
|
||||
methods.append(MethodDef(
|
||||
name=name,
|
||||
input_type=resolve_webpack_type(input_ref),
|
||||
output_type=resolve_webpack_type(output_ref),
|
||||
kind=kind,
|
||||
comment=extract_jsdoc_comment(method_comment),
|
||||
))
|
||||
|
||||
svc = ServiceDef(type_name=type_name, methods=methods, comment=comment)
|
||||
services_by_file.setdefault(file_path, []).append(svc)
|
||||
|
||||
return services_by_file
|
||||
|
||||
|
||||
def find_file_ranges(content: str) -> dict[str, tuple[int, int]]:
|
||||
"""Find the byte ranges for each proto file in the bundle.
|
||||
|
||||
A file may appear multiple times (e.g., messages in _pb.js and services in _connect.js).
|
||||
We collect all ranges and merge them so all occurrences are captured.
|
||||
"""
|
||||
file_pattern = re.compile(
|
||||
r'// @generated from file ([^\s]+) \(package ([^,]+), syntax (\w+)\)'
|
||||
)
|
||||
|
||||
matches = list(file_pattern.finditer(content))
|
||||
|
||||
file_segments: dict[str, list[tuple[int, int]]] = {}
|
||||
|
||||
for i, m in enumerate(matches):
|
||||
file_path = m.group(1)
|
||||
start = m.start()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
|
||||
|
||||
if file_path not in file_segments:
|
||||
file_segments[file_path] = []
|
||||
file_segments[file_path].append((start, end))
|
||||
|
||||
ranges = {}
|
||||
for file_path, segments in file_segments.items():
|
||||
ranges[file_path] = segments
|
||||
|
||||
return ranges
|
||||
|
||||
|
||||
def field_to_proto(f: FieldDef, indent: str = " ") -> str:
|
||||
"""Convert a field definition to proto3 syntax."""
|
||||
lines = []
|
||||
|
||||
if f.comment:
|
||||
lines.append(f"{indent}// {f.comment}")
|
||||
|
||||
if f.kind == "map":
|
||||
key_type = SCALAR_TYPES.get(f.map_key, "string") if f.map_key else "string"
|
||||
lines.append(f"{indent}map<{key_type}, {f.type_ref}> {f.name} = {f.no};")
|
||||
else:
|
||||
prefix = ""
|
||||
if f.opt:
|
||||
prefix = "optional "
|
||||
elif f.repeated:
|
||||
prefix = "repeated "
|
||||
lines.append(f"{indent}{prefix}{f.type_ref} {f.name} = {f.no};")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def get_simple_name(type_name: str) -> str:
|
||||
"""Get a unique message name from a fully qualified type name.
|
||||
|
||||
Handles nested types like 'agent.v1.Outer.Inner' by converting to 'Outer_Inner'.
|
||||
"""
|
||||
parts = type_name.split('.')
|
||||
# Skip the package prefix (e.g., 'agent.v1')
|
||||
if len(parts) > 2:
|
||||
return '_'.join(parts[2:])
|
||||
return parts[-1]
|
||||
|
||||
|
||||
def message_to_proto(msg: MessageDef, indent: str = "") -> str:
|
||||
"""Convert a message definition to proto3 syntax."""
|
||||
lines = []
|
||||
name = get_simple_name(msg.type_name)
|
||||
|
||||
if msg.comment:
|
||||
lines.append(f"{indent}// {msg.comment}")
|
||||
|
||||
lines.append(f"{indent}message {name} {{")
|
||||
|
||||
oneof_groups: dict[str, list[FieldDef]] = {}
|
||||
regular_fields: list[FieldDef] = []
|
||||
|
||||
for f in msg.fields:
|
||||
if f.oneof:
|
||||
oneof_groups.setdefault(f.oneof, []).append(f)
|
||||
else:
|
||||
regular_fields.append(f)
|
||||
|
||||
for f in regular_fields:
|
||||
lines.append(field_to_proto(f, indent + " "))
|
||||
|
||||
for oneof_name, fields in oneof_groups.items():
|
||||
lines.append(f"{indent} oneof {oneof_name} {{")
|
||||
for f in fields:
|
||||
if f.comment:
|
||||
lines.append(f"{indent} // {f.comment}")
|
||||
lines.append(f"{indent} {f.type_ref} {f.name} = {f.no};")
|
||||
lines.append(f"{indent} }}")
|
||||
|
||||
lines.append(f"{indent}}}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def enum_to_proto(enum: EnumDef, indent: str = "") -> str:
|
||||
"""Convert an enum definition to proto3 syntax."""
|
||||
lines = []
|
||||
name = get_simple_name(enum.type_name)
|
||||
|
||||
if enum.comment:
|
||||
lines.append(f"{indent}// {enum.comment}")
|
||||
|
||||
lines.append(f"{indent}enum {name} {{")
|
||||
for v in enum.values:
|
||||
if v.comment:
|
||||
lines.append(f"{indent} // {v.comment}")
|
||||
lines.append(f"{indent} {v.name} = {v.no};")
|
||||
lines.append(f"{indent}}}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def service_to_proto(svc: ServiceDef, indent: str = "") -> str:
|
||||
"""Convert a service definition to proto3 syntax."""
|
||||
lines = []
|
||||
name = get_simple_name(svc.type_name)
|
||||
|
||||
if svc.comment:
|
||||
lines.append(f"{indent}// {svc.comment}")
|
||||
|
||||
lines.append(f"{indent}service {name} {{")
|
||||
|
||||
for m in svc.methods:
|
||||
if m.comment:
|
||||
lines.append(f"{indent} // {m.comment}")
|
||||
|
||||
stream_in = "stream " if m.kind in ("ClientStreaming", "BiDiStreaming") else ""
|
||||
stream_out = "stream " if m.kind in ("ServerStreaming", "BiDiStreaming") else ""
|
||||
|
||||
lines.append(f"{indent} rpc {m.name}({stream_in}{m.input_type}) returns ({stream_out}{m.output_type});")
|
||||
|
||||
lines.append(f"{indent}}}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def generate_proto_file(proto: ProtoFile) -> str:
|
||||
"""Generate complete proto file content."""
|
||||
lines = [
|
||||
f'syntax = "{proto.syntax}";',
|
||||
'',
|
||||
f'package {proto.package};',
|
||||
'',
|
||||
]
|
||||
|
||||
for enum in proto.enums:
|
||||
lines.append(enum_to_proto(enum))
|
||||
lines.append('')
|
||||
|
||||
for msg in proto.messages:
|
||||
lines.append(message_to_proto(msg))
|
||||
lines.append('')
|
||||
|
||||
for svc in proto.services:
|
||||
lines.append(service_to_proto(svc))
|
||||
lines.append('')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python proto-extractor.py <bundled-js-file> <output-file>")
|
||||
print("")
|
||||
print("Options:")
|
||||
print(" --filter=<pkg> Only extract types from package (e.g., agent.v1)")
|
||||
sys.exit(1)
|
||||
|
||||
input_file = sys.argv[1]
|
||||
output_file = Path(sys.argv[2])
|
||||
|
||||
filter_pkg = None
|
||||
for arg in sys.argv[3:]:
|
||||
if arg.startswith('--filter='):
|
||||
filter_pkg = arg.split('=')[1]
|
||||
|
||||
print(f"Reading {input_file}...", file=sys.stderr)
|
||||
with open(input_file, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
print(f"File size: {len(content) / 1024 / 1024:.2f} MB", file=sys.stderr)
|
||||
|
||||
print("Finding file boundaries...", file=sys.stderr)
|
||||
file_ranges = find_file_ranges(content)
|
||||
print(f"Found {len(file_ranges)} proto files", file=sys.stderr)
|
||||
|
||||
print("Extracting messages...", file=sys.stderr)
|
||||
messages_by_file = extract_messages(content, file_ranges)
|
||||
|
||||
print("Extracting enums...", file=sys.stderr)
|
||||
enums_by_file = extract_enums(content, file_ranges)
|
||||
|
||||
print("Extracting services...", file=sys.stderr)
|
||||
services_by_file = extract_services(content, file_ranges)
|
||||
|
||||
file_pattern = re.compile(
|
||||
r'// @generated from file ([^\s]+) \(package ([^,]+), syntax (\w+)\)'
|
||||
)
|
||||
|
||||
# Collect all messages, enums, services into one consolidated proto
|
||||
all_messages: dict[str, MessageDef] = {}
|
||||
all_enums: dict[str, EnumDef] = {}
|
||||
all_services: dict[str, ServiceDef] = {}
|
||||
seen_files: set[str] = set()
|
||||
package = None
|
||||
|
||||
for m in file_pattern.finditer(content):
|
||||
file_path, pkg, syntax = m.groups()
|
||||
if filter_pkg and not pkg.startswith(filter_pkg):
|
||||
continue
|
||||
if file_path in seen_files:
|
||||
continue
|
||||
seen_files.add(file_path)
|
||||
if package is None:
|
||||
package = pkg
|
||||
for msg in messages_by_file.get(file_path, []):
|
||||
if msg.type_name not in all_messages:
|
||||
all_messages[msg.type_name] = msg
|
||||
for enum in enums_by_file.get(file_path, []):
|
||||
if enum.type_name not in all_enums:
|
||||
all_enums[enum.type_name] = enum
|
||||
for svc in services_by_file.get(file_path, []):
|
||||
if svc.type_name not in all_services:
|
||||
all_services[svc.type_name] = svc
|
||||
|
||||
if package is None:
|
||||
print("No matching proto files found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Create consolidated proto file
|
||||
consolidated = ProtoFile(
|
||||
path=str(output_file),
|
||||
package=package,
|
||||
syntax="proto3",
|
||||
messages=list(all_messages.values()),
|
||||
enums=list(all_enums.values()),
|
||||
services=list(all_services.values()),
|
||||
)
|
||||
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
proto_content = generate_proto_file(consolidated)
|
||||
output_file.write_text(proto_content)
|
||||
|
||||
print(f"\nTotal: {len(all_messages)} messages, {len(all_enums)} enums, {len(all_services)} services", file=sys.stderr)
|
||||
print(f"Output written to: {output_file}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { supportsXhigh } from "./models";
|
||||
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic";
|
||||
import { type CursorOptions, streamCursor } from "./providers/cursor";
|
||||
import { type GoogleOptions, streamGoogle } from "./providers/google";
|
||||
import {
|
||||
type GoogleGeminiCliOptions,
|
||||
@@ -82,6 +83,7 @@ export function getEnvApiKey(provider: any): string | undefined {
|
||||
zai: "ZAI_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
opencode: "OPENCODE_API_KEY",
|
||||
cursor: "CURSOR_ACCESS_TOKEN",
|
||||
};
|
||||
|
||||
const envVar = envMap[provider];
|
||||
@@ -128,6 +130,9 @@ export function stream<TApi extends Api>(
|
||||
providerOptions as GoogleGeminiCliOptions,
|
||||
);
|
||||
|
||||
case "cursor-agent":
|
||||
return streamCursor(model as Model<"cursor-agent">, context, providerOptions as CursorOptions);
|
||||
|
||||
default: {
|
||||
// This should never be reached if all Api cases are handled
|
||||
const _exhaustive: never = api;
|
||||
@@ -185,6 +190,7 @@ function mapOptionsForApi<TApi extends Api>(
|
||||
signal: options?.signal,
|
||||
apiKey: apiKey || options?.apiKey,
|
||||
sessionId: options?.sessionId,
|
||||
execHandlers: options?.execHandlers,
|
||||
};
|
||||
|
||||
// Helper to clamp xhigh to high for providers that don't support it
|
||||
@@ -353,6 +359,16 @@ function mapOptionsForApi<TApi extends Api>(
|
||||
} satisfies GoogleVertexOptions;
|
||||
}
|
||||
|
||||
case "cursor-agent": {
|
||||
const execHandlers = options?.cursorExecHandlers ?? options?.execHandlers;
|
||||
const onToolResult = options?.cursorOnToolResult ?? execHandlers?.onToolResult;
|
||||
return {
|
||||
...base,
|
||||
execHandlers,
|
||||
onToolResult,
|
||||
} satisfies CursorOptions;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Exhaustiveness check
|
||||
const _exhaustive: never = model.api;
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import type { AnthropicOptions } from "./providers/anthropic";
|
||||
import type { CursorOptions } from "./providers/cursor";
|
||||
import type {
|
||||
DeleteArgs,
|
||||
DeleteResult,
|
||||
DiagnosticsArgs,
|
||||
DiagnosticsResult,
|
||||
GrepArgs,
|
||||
GrepResult,
|
||||
LsArgs,
|
||||
LsResult,
|
||||
McpResult,
|
||||
ReadArgs,
|
||||
ReadResult,
|
||||
ShellArgs,
|
||||
ShellResult,
|
||||
WriteArgs,
|
||||
WriteResult,
|
||||
} from "./providers/cursor/gen/agent_pb";
|
||||
import type { GoogleOptions } from "./providers/google";
|
||||
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
|
||||
import type { GoogleVertexOptions } from "./providers/google-vertex";
|
||||
@@ -16,7 +34,8 @@ export type Api =
|
||||
| "anthropic-messages"
|
||||
| "google-generative-ai"
|
||||
| "google-gemini-cli"
|
||||
| "google-vertex";
|
||||
| "google-vertex"
|
||||
| "cursor-agent";
|
||||
|
||||
export interface ApiOptionsMap {
|
||||
"anthropic-messages": AnthropicOptions;
|
||||
@@ -26,6 +45,7 @@ export interface ApiOptionsMap {
|
||||
"google-generative-ai": GoogleOptions;
|
||||
"google-gemini-cli": GoogleGeminiCliOptions;
|
||||
"google-vertex": GoogleVertexOptions;
|
||||
"cursor-agent": CursorOptions;
|
||||
}
|
||||
|
||||
// Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
|
||||
@@ -49,6 +69,7 @@ export type KnownProvider =
|
||||
| "openai"
|
||||
| "openai-codex"
|
||||
| "github-copilot"
|
||||
| "cursor"
|
||||
| "xai"
|
||||
| "groq"
|
||||
| "cerebras"
|
||||
@@ -80,6 +101,8 @@ export interface StreamOptions {
|
||||
* session-aware features. Ignored by providers that don't support it.
|
||||
*/
|
||||
sessionId?: string;
|
||||
/** Cursor exec/MCP tool handlers (cursor-agent only). */
|
||||
execHandlers?: CursorExecHandlers;
|
||||
}
|
||||
|
||||
// Unified options with reasoning passed to streamSimple() and completeSimple()
|
||||
@@ -87,6 +110,10 @@ export interface SimpleStreamOptions extends StreamOptions {
|
||||
reasoning?: ThinkingLevel;
|
||||
/** Custom token budgets for thinking levels (token-based providers only) */
|
||||
thinkingBudgets?: ThinkingBudgets;
|
||||
/** Cursor exec handlers for local tool execution */
|
||||
cursorExecHandlers?: CursorExecHandlers;
|
||||
/** Hook to handle tool results from Cursor exec */
|
||||
cursorOnToolResult?: CursorToolResultHandler;
|
||||
}
|
||||
|
||||
// Generic StreamFunction with typed options
|
||||
@@ -169,6 +196,33 @@ export interface ToolResultMessage<TDetails = any> {
|
||||
|
||||
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
|
||||
|
||||
export type CursorExecHandlerResult<T> = { result: T; toolResult?: ToolResultMessage } | T | ToolResultMessage;
|
||||
|
||||
export type CursorToolResultHandler = (
|
||||
result: ToolResultMessage,
|
||||
) => ToolResultMessage | undefined | Promise<ToolResultMessage | undefined>;
|
||||
|
||||
export interface CursorMcpCall {
|
||||
name: string;
|
||||
providerIdentifier: string;
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
args: Record<string, unknown>;
|
||||
rawArgs: Record<string, Uint8Array>;
|
||||
}
|
||||
|
||||
export interface CursorExecHandlers {
|
||||
read?: (args: ReadArgs) => Promise<CursorExecHandlerResult<ReadResult>>;
|
||||
ls?: (args: LsArgs) => Promise<CursorExecHandlerResult<LsResult>>;
|
||||
grep?: (args: GrepArgs) => Promise<CursorExecHandlerResult<GrepResult>>;
|
||||
write?: (args: WriteArgs) => Promise<CursorExecHandlerResult<WriteResult>>;
|
||||
delete?: (args: DeleteArgs) => Promise<CursorExecHandlerResult<DeleteResult>>;
|
||||
shell?: (args: ShellArgs) => Promise<CursorExecHandlerResult<ShellResult>>;
|
||||
diagnostics?: (args: DiagnosticsArgs) => Promise<CursorExecHandlerResult<DiagnosticsResult>>;
|
||||
mcp?: (call: CursorMcpCall) => Promise<CursorExecHandlerResult<McpResult>>;
|
||||
onToolResult?: CursorToolResultHandler;
|
||||
}
|
||||
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
|
||||
export interface Tool<TParameters extends TSchema = TSchema> {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { generatePKCE } from "./pkce";
|
||||
import type { OAuthCredentials } from "./types";
|
||||
|
||||
const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
|
||||
const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
|
||||
const CURSOR_REFRESH_URL = "https://api2.cursor.sh/auth/exchange_user_api_key";
|
||||
|
||||
const POLL_MAX_ATTEMPTS = 150;
|
||||
const POLL_BASE_DELAY = 1000;
|
||||
const POLL_MAX_DELAY = 10000;
|
||||
const POLL_BACKOFF_MULTIPLIER = 1.2;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export interface CursorAuthParams {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
uuid: string;
|
||||
loginUrl: string;
|
||||
}
|
||||
|
||||
export async function generateCursorAuthParams(): Promise<CursorAuthParams> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const uuid = crypto.randomUUID();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
challenge,
|
||||
uuid,
|
||||
mode: "login",
|
||||
redirectTarget: "cli",
|
||||
});
|
||||
|
||||
const loginUrl = `${CURSOR_LOGIN_URL}?${params.toString()}`;
|
||||
|
||||
return { verifier, challenge, uuid, loginUrl };
|
||||
}
|
||||
|
||||
export async function pollCursorAuth(
|
||||
uuid: string,
|
||||
verifier: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
let delay = POLL_BASE_DELAY;
|
||||
let consecutiveErrors = 0;
|
||||
|
||||
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
|
||||
await sleep(delay);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${CURSOR_POLL_URL}?uuid=${uuid}&verifier=${verifier}`);
|
||||
|
||||
if (response.status === 404) {
|
||||
consecutiveErrors = 0;
|
||||
delay = Math.min(delay * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
return {
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Poll failed: ${response.status}`);
|
||||
} catch (_error) {
|
||||
consecutiveErrors++;
|
||||
if (consecutiveErrors >= 3) {
|
||||
throw new Error("Too many consecutive errors during Cursor auth polling");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Cursor authentication polling timeout");
|
||||
}
|
||||
|
||||
export async function loginCursor(
|
||||
onAuthUrl: (url: string) => void,
|
||||
onPollStart?: () => void,
|
||||
): Promise<OAuthCredentials> {
|
||||
const { verifier, uuid, loginUrl } = await generateCursorAuthParams();
|
||||
|
||||
onAuthUrl(loginUrl);
|
||||
onPollStart?.();
|
||||
|
||||
const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier);
|
||||
|
||||
const expiresAt = getTokenExpiry(accessToken);
|
||||
|
||||
return {
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<OAuthCredentials> {
|
||||
const response = await fetch(CURSOR_REFRESH_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKeyOrRefreshToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Cursor token refresh failed: ${error}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
const expiresAt = getTokenExpiry(data.accessToken);
|
||||
|
||||
return {
|
||||
access: data.accessToken,
|
||||
refresh: data.refreshToken || apiKeyOrRefreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function getTokenExpiry(token: string): number {
|
||||
try {
|
||||
const [, payload] = token.split(".");
|
||||
if (!payload) {
|
||||
return Date.now() + 3600 * 1000;
|
||||
}
|
||||
const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
||||
if (decoded.exp) {
|
||||
return decoded.exp * 1000 - 5 * 60 * 1000;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
return Date.now() + 3600 * 1000;
|
||||
}
|
||||
|
||||
export function isTokenExpiringSoon(token: string, thresholdSeconds = 300): boolean {
|
||||
try {
|
||||
const [, payload] = token.split(".");
|
||||
if (!payload) return true;
|
||||
const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return decoded.exp - currentTime < thresholdSeconds;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,14 @@
|
||||
|
||||
// Anthropic
|
||||
export { loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
||||
// Cursor
|
||||
export {
|
||||
generateCursorAuthParams,
|
||||
isTokenExpiringSoon as isCursorTokenExpiringSoon,
|
||||
loginCursor,
|
||||
pollCursorAuth,
|
||||
refreshCursorToken,
|
||||
} from "./cursor";
|
||||
// GitHub Copilot
|
||||
export {
|
||||
getGitHubCopilotBaseUrl,
|
||||
@@ -41,6 +49,7 @@ export * from "./types";
|
||||
// ============================================================================
|
||||
|
||||
import { refreshAnthropicToken } from "./anthropic";
|
||||
import { refreshCursorToken } from "./cursor";
|
||||
import { refreshGitHubCopilotToken } from "./github-copilot";
|
||||
import { refreshAntigravityToken } from "./google-antigravity";
|
||||
import { refreshGoogleCloudToken } from "./google-gemini-cli";
|
||||
@@ -83,6 +92,9 @@ export async function refreshOAuthToken(
|
||||
case "openai-codex":
|
||||
newCredentials = await refreshOpenAICodexToken(credentials.refresh);
|
||||
break;
|
||||
case "cursor":
|
||||
newCredentials = await refreshCursorToken(credentials.refresh);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||
}
|
||||
@@ -153,5 +165,10 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
|
||||
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
name: "Cursor (Claude, GPT, etc.)",
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ export type OAuthProvider =
|
||||
| "github-copilot"
|
||||
| "google-gemini-cli"
|
||||
| "google-antigravity"
|
||||
| "openai-codex";
|
||||
| "openai-codex"
|
||||
| "cursor";
|
||||
|
||||
export type OAuthPrompt = {
|
||||
message: string;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Cursor provider support with browser-based OAuth authentication
|
||||
- Added default model configuration for Cursor provider (claude-sonnet-4-5)
|
||||
- Added execution bridge for Cursor tool calls including read, ls, grep, write, delete, shell, diagnostics, and MCP operations
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved fuzzy matching accuracy for edit operations when file and target have inconsistent indentation patterns
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getOAuthApiKey,
|
||||
loginAnthropic,
|
||||
loginAntigravity,
|
||||
loginCursor,
|
||||
loginGeminiCli,
|
||||
loginGitHubCopilot,
|
||||
loginOpenAICodex,
|
||||
@@ -585,6 +586,12 @@ export class AuthStorage {
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
});
|
||||
break;
|
||||
case "cursor":
|
||||
credentials = await loginCursor(
|
||||
(url) => callbacks.onAuth({ url }),
|
||||
callbacks.onProgress ? () => callbacks.onProgress?.("Waiting for browser authentication...") : undefined,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { rmSync, statSync } from "node:fs";
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentTool,
|
||||
AgentToolContext,
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
} from "@oh-my-pi/pi-agent-core";
|
||||
import type { CursorExecHandlers, CursorMcpCall, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
||||
import { resolveToCwd } from "../tools/path-utils";
|
||||
|
||||
interface CursorExecBridgeOptions {
|
||||
cwd: string;
|
||||
tools: Map<string, AgentTool>;
|
||||
getToolContext?: () => AgentToolContext | undefined;
|
||||
emitEvent?: (event: AgentEvent) => void;
|
||||
}
|
||||
|
||||
function createToolResultMessage(
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
result: AgentToolResult<unknown>,
|
||||
isError: boolean,
|
||||
): ToolResultMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName,
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
isError,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolErrorResult(message: string): AgentToolResult<unknown> {
|
||||
return {
|
||||
content: [{ type: "text", text: message }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function executeTool(
|
||||
options: CursorExecBridgeOptions,
|
||||
toolName: string,
|
||||
toolCallId: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<ToolResultMessage> {
|
||||
const tool = options.tools.get(toolName);
|
||||
if (!tool) {
|
||||
const result = buildToolErrorResult(`Tool "${toolName}" not available`);
|
||||
return createToolResultMessage(toolCallId, toolName, result, true);
|
||||
}
|
||||
|
||||
options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args });
|
||||
|
||||
let result: AgentToolResult<unknown>;
|
||||
let isError = false;
|
||||
|
||||
const onUpdate: AgentToolUpdateCallback<unknown> | undefined = options.emitEvent
|
||||
? (partialResult) => {
|
||||
options.emitEvent?.({
|
||||
type: "tool_execution_update",
|
||||
toolCallId,
|
||||
toolName,
|
||||
args,
|
||||
partialResult,
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
result = await tool.execute(
|
||||
toolCallId,
|
||||
args as Record<string, unknown>,
|
||||
undefined,
|
||||
onUpdate,
|
||||
options.getToolContext?.(),
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
result = buildToolErrorResult(message);
|
||||
isError = true;
|
||||
}
|
||||
|
||||
options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError });
|
||||
|
||||
return createToolResultMessage(toolCallId, toolName, result, isError);
|
||||
}
|
||||
|
||||
async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, toolCallId: string) {
|
||||
const toolName = "delete";
|
||||
options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } });
|
||||
|
||||
const absolutePath = resolveToCwd(pathArg, options.cwd);
|
||||
let isError = false;
|
||||
let result: AgentToolResult<unknown>;
|
||||
|
||||
try {
|
||||
const stat = statSync(absolutePath, { throwIfNoEntry: false });
|
||||
if (!stat) {
|
||||
throw new Error(`File not found: ${pathArg}`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Path is not a file: ${pathArg}`);
|
||||
}
|
||||
|
||||
rmSync(absolutePath);
|
||||
|
||||
const sizeText = stat.size ? ` (${stat.size} bytes)` : "";
|
||||
const message = `Deleted ${pathArg}${sizeText}`;
|
||||
result = { content: [{ type: "text", text: message }], details: {} };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
result = buildToolErrorResult(message);
|
||||
isError = true;
|
||||
}
|
||||
|
||||
options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError });
|
||||
return createToolResultMessage(toolCallId, toolName, result, isError);
|
||||
}
|
||||
|
||||
function decodeToolCallId(toolCallId?: string): string {
|
||||
return toolCallId && toolCallId.length > 0 ? toolCallId : randomUUID();
|
||||
}
|
||||
|
||||
function decodeMcpArgs(rawArgs: Record<string, Uint8Array>): Record<string, unknown> {
|
||||
const decoded: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(rawArgs)) {
|
||||
const text = new TextDecoder().decode(value);
|
||||
try {
|
||||
decoded[key] = JSON.parse(text);
|
||||
} catch {
|
||||
decoded[key] = text;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function formatMcpToolErrorMessage(toolName: string, availableTools: string[]): string {
|
||||
const list = availableTools.length > 0 ? availableTools.join(", ") : "none";
|
||||
return `MCP tool "${toolName}" not found. Available tools: ${list}`;
|
||||
}
|
||||
|
||||
export function createCursorExecHandlers(options: CursorExecBridgeOptions): CursorExecHandlers {
|
||||
return {
|
||||
read: async (args) => {
|
||||
const toolCallId = decodeToolCallId(args.toolCallId);
|
||||
const toolResultMessage = await executeTool(options, "read", toolCallId, { path: args.path });
|
||||
return toolResultMessage;
|
||||
},
|
||||
ls: async (args) => {
|
||||
const toolCallId = decodeToolCallId(args.toolCallId);
|
||||
const toolResultMessage = await executeTool(options, "ls", toolCallId, { path: args.path });
|
||||
return toolResultMessage;
|
||||
},
|
||||
grep: async (args) => {
|
||||
const toolCallId = decodeToolCallId(args.toolCallId);
|
||||
const toolResultMessage = await executeTool(options, "grep", toolCallId, {
|
||||
pattern: args.pattern,
|
||||
path: args.path || undefined,
|
||||
glob: args.glob || undefined,
|
||||
outputMode: args.outputMode || undefined,
|
||||
context: args.context ?? args.contextBefore ?? args.contextAfter ?? undefined,
|
||||
ignoreCase: args.caseInsensitive || undefined,
|
||||
type: args.type || undefined,
|
||||
headLimit: args.headLimit ?? undefined,
|
||||
multiline: args.multiline || undefined,
|
||||
});
|
||||
return toolResultMessage;
|
||||
},
|
||||
write: async (args) => {
|
||||
const toolCallId = decodeToolCallId(args.toolCallId);
|
||||
const content = args.fileText ?? new TextDecoder().decode(args.fileBytes ?? new Uint8Array());
|
||||
const toolResultMessage = await executeTool(options, "write", toolCallId, {
|
||||
path: args.path,
|
||||
content,
|
||||
});
|
||||
return toolResultMessage;
|
||||
},
|
||||
delete: async (args) => {
|
||||
const toolCallId = decodeToolCallId(args.toolCallId);
|
||||
const toolResultMessage = await executeDelete(options, args.path, toolCallId);
|
||||
return toolResultMessage;
|
||||
},
|
||||
shell: async (args) => {
|
||||
const toolCallId = decodeToolCallId(args.toolCallId);
|
||||
const timeoutSeconds =
|
||||
args.timeout && args.timeout > 0
|
||||
? args.timeout > 1000
|
||||
? Math.ceil(args.timeout / 1000)
|
||||
: args.timeout
|
||||
: undefined;
|
||||
const toolResultMessage = await executeTool(options, "bash", toolCallId, {
|
||||
command: args.command,
|
||||
workdir: args.workingDirectory || undefined,
|
||||
timeout: timeoutSeconds,
|
||||
});
|
||||
return toolResultMessage;
|
||||
},
|
||||
diagnostics: async (args) => {
|
||||
const toolCallId = decodeToolCallId(args.toolCallId);
|
||||
const toolResultMessage = await executeTool(options, "lsp", toolCallId, {
|
||||
action: "diagnostics",
|
||||
file: args.path,
|
||||
});
|
||||
return toolResultMessage;
|
||||
},
|
||||
mcp: async (call: CursorMcpCall) => {
|
||||
const toolName = call.toolName || call.name;
|
||||
const toolCallId = decodeToolCallId(call.toolCallId);
|
||||
const tool = options.tools.get(toolName);
|
||||
if (!tool) {
|
||||
const availableTools = Array.from(options.tools.keys()).filter((name) => name.startsWith("mcp_"));
|
||||
const message = formatMcpToolErrorMessage(toolName, availableTools);
|
||||
const toolResult: ToolResultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName,
|
||||
content: [{ type: "text", text: message }],
|
||||
details: {},
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
const args = Object.keys(call.args ?? {}).length > 0 ? call.args : decodeMcpArgs(call.rawArgs ?? {});
|
||||
const toolResultMessage = await executeTool(options, toolName, toolCallId, args);
|
||||
return toolResultMessage;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
||||
"google-antigravity": "gemini-3-pro-high",
|
||||
"google-vertex": "gemini-2.5-pro",
|
||||
"github-copilot": "gpt-4o",
|
||||
cursor: "claude-sonnet-4-5",
|
||||
openrouter: "openai/gpt-5.1-codex",
|
||||
xai: "grok-4-fast-non-reasoning",
|
||||
groq: "openai/gpt-oss-120b",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import { Agent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { Agent, type AgentEvent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Message, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import chalk from "chalk";
|
||||
@@ -40,6 +40,7 @@ import { initializeWithSettings } from "../discovery";
|
||||
import { registerAsyncCleanup } from "../modes/cleanup";
|
||||
import { AgentSession } from "./agent-session";
|
||||
import { AuthStorage } from "./auth-storage";
|
||||
import { createCursorExecHandlers } from "./cursor/exec-bridge";
|
||||
import {
|
||||
type CustomCommandsLoadResult,
|
||||
loadCustomCommands as loadCustomCommandsInternal,
|
||||
@@ -854,6 +855,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
}
|
||||
time("combineTools");
|
||||
|
||||
let cursorEventEmitter: ((event: AgentEvent) => void) | undefined;
|
||||
const cursorExecHandlers = createCursorExecHandlers({
|
||||
cwd,
|
||||
tools: toolRegistry,
|
||||
getToolContext: toolContextStore.getContext,
|
||||
emitEvent: (event) => cursorEventEmitter?.(event),
|
||||
});
|
||||
|
||||
const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
|
||||
toolContextStore.setToolNames(toolNames);
|
||||
const defaultPrompt = await buildSystemPromptInternal({
|
||||
@@ -964,7 +973,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
}
|
||||
return key;
|
||||
},
|
||||
cursorExecHandlers,
|
||||
});
|
||||
cursorEventEmitter = (event) => agent.emitExternalEvent(event);
|
||||
time("createAgent");
|
||||
|
||||
// Restore messages if session has existing data
|
||||
|
||||
Reference in New Issue
Block a user