fix(cursor): address code review edge cases in protobuf and executor

CRITICAL FIX:
- CursorCredentials interface now matches types.ts (machineId, ghostMode as top-level)
- Fixes runtime error when cursor-auth saves credentials and cursor-executor reads them

HIGH:
- Replace 18+ non-null assertions with guard clauses across executor and decoder
- Prefix unused params in translator (_model, _stream, _credentials)
- HTTP/2 client closes on connection error to prevent leak
- AbortSignal listener leak documented with TODO (inline arrow prevents cleanup)

MEDIUM:
- Export concatArrays from encoder, remove duplicate from protobuf.ts
- Varint decoder now enforces 5-byte max to prevent overflow
- Buffer slice bounds check prevents out-of-range read
- Empty messages array validation with explicit error
- Buffered streaming limitation documented with TODO comment

All edge cases from code review now addressed.
This commit is contained in:
Tam Nhu Tran committed 2026-02-11 19:13:58 +07:00
1 parent 9daf9430bb
commit cc5a9039e4
6 files changed
+265 -325

No files matched your search

+124 -127
View File
@@ -3,12 +3,12 @@
* Handles HTTP/2 requests to Cursor API with protobuf encoding/decoding * Handles HTTP/2 requests to Cursor API with protobuf encoding/decoding
*/ */
import * as crypto from "crypto"; import * as crypto from 'crypto';
import * as zlib from "zlib"; import * as zlib from 'zlib';
import type { IncomingHttpHeaders } from "http"; import type { IncomingHttpHeaders } from 'http';
import { generateCursorBody, extractTextFromResponse } from "./cursor-protobuf.js"; import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js';
import { buildCursorRequest } from "./cursor-translator.js"; import { buildCursorRequest } from './cursor-translator.js';
import type { CursorMessage, CursorTool } from "./cursor-protobuf-schema.js"; import type { CursorTool } from './cursor-protobuf-schema.js';
/** Compression flags for response parsing */ /** Compression flags for response parsing */
const COMPRESS_FLAG = { const COMPRESS_FLAG = {
@@ -21,10 +21,8 @@ const COMPRESS_FLAG = {
/** Cursor credentials structure */ /** Cursor credentials structure */
interface CursorCredentials { interface CursorCredentials {
accessToken: string; accessToken: string;
providerSpecificData?: { machineId: string;
machineId?: string;
ghostMode?: boolean; ghostMode?: boolean;
};
} }
/** Executor parameters */ /** Executor parameters */
@@ -59,10 +57,15 @@ interface Http2Response {
/** Detect cloud environment */ /** Detect cloud environment */
function isCloudEnv(): boolean { function isCloudEnv(): boolean {
if (typeof caches !== "undefined" && typeof caches === "object") return true; if (
typeof globalThis !== 'undefined' &&
'caches' in globalThis &&
typeof (globalThis as { caches?: unknown }).caches === 'object'
)
return true;
try { try {
// Check for EdgeRuntime without causing compilation error // Check for EdgeRuntime without causing compilation error
if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== "undefined") return true; if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== 'undefined') return true;
} catch { } catch {
// Continue // Continue
} }
@@ -70,12 +73,12 @@ function isCloudEnv(): boolean {
} }
/** Lazy import http2 */ /** Lazy import http2 */
let http2Module: typeof import("http2") | null = null; let http2Module: typeof import('http2') | null = null;
async function getHttp2() { async function getHttp2() {
if (http2Module) return http2Module; if (http2Module) return http2Module;
if (!isCloudEnv()) { if (!isCloudEnv()) {
try { try {
http2Module = await import("http2"); http2Module = await import('http2');
return http2Module; return http2Module;
} catch { } catch {
return null; return null;
@@ -91,7 +94,7 @@ function decompressPayload(payload: Buffer, flags: number): Buffer {
// Check if payload is JSON error // Check if payload is JSON error
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) { if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
try { try {
const text = payload.toString("utf-8"); const text = payload.toString('utf-8');
if (text.startsWith('{"error"')) { if (text.startsWith('{"error"')) {
return payload; return payload;
} }
@@ -128,28 +131,28 @@ function createErrorResponse(jsonError: {
jsonError?.error?.details?.[0]?.debug?.details?.title || jsonError?.error?.details?.[0]?.debug?.details?.title ||
jsonError?.error?.details?.[0]?.debug?.details?.detail || jsonError?.error?.details?.[0]?.debug?.details?.detail ||
jsonError?.error?.message || jsonError?.error?.message ||
"API Error"; 'API Error';
const isRateLimit = jsonError?.error?.code === "resource_exhausted"; const isRateLimit = jsonError?.error?.code === 'resource_exhausted';
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: { error: {
message: errorMsg, message: errorMsg,
type: isRateLimit ? "rate_limit_error" : "api_error", type: isRateLimit ? 'rate_limit_error' : 'api_error',
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown", code: jsonError?.error?.details?.[0]?.debug?.error || 'unknown',
}, },
}), }),
{ {
status: isRateLimit ? 429 : 400, status: isRateLimit ? 429 : 400,
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
} }
); );
} }
export class CursorExecutor { export class CursorExecutor {
private readonly baseUrl = "https://api2.cursor.sh"; private readonly baseUrl = 'https://api2.cursor.sh';
private readonly chatPath = "/aiserver.v1.AiService/StreamChat"; private readonly chatPath = '/aiserver.v1.AiService/StreamChat';
buildUrl(): string { buildUrl(): string {
return `${this.baseUrl}${this.chatPath}`; return `${this.baseUrl}${this.chatPath}`;
@@ -175,8 +178,8 @@ export class CursorExecutor {
t = byteArray[i]; t = byteArray[i];
} }
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
let encoded = ""; let encoded = '';
for (let i = 0; i < byteArray.length; i += 3) { for (let i = 0; i < byteArray.length; i += 3) {
const a = byteArray[i]; const a = byteArray[i];
@@ -199,52 +202,45 @@ export class CursorExecutor {
buildHeaders(credentials: CursorCredentials): Record<string, string> { buildHeaders(credentials: CursorCredentials): Record<string, string> {
const accessToken = credentials.accessToken; const accessToken = credentials.accessToken;
const machineId = credentials.providerSpecificData?.machineId; const machineId = credentials.machineId;
const ghostMode = credentials.providerSpecificData?.ghostMode !== false; const ghostMode = credentials.ghostMode !== false;
if (!machineId) { if (!machineId) {
throw new Error("Machine ID is required for Cursor API"); throw new Error('Machine ID is required for Cursor API');
} }
const cleanToken = accessToken.includes("::") const cleanToken = accessToken.includes('::') ? accessToken.split('::')[1] : accessToken;
? accessToken.split("::")[1]
: accessToken;
return { return {
authorization: `Bearer ${cleanToken}`, authorization: `Bearer ${cleanToken}`,
"connect-accept-encoding": "gzip", 'connect-accept-encoding': 'gzip',
"connect-protocol-version": "1", 'connect-protocol-version': '1',
"content-type": "application/connect+proto", 'content-type': 'application/connect+proto',
"user-agent": "connect-es/1.6.1", 'user-agent': 'connect-es/1.6.1',
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`, 'x-amzn-trace-id': `Root=${crypto.randomUUID()}`,
"x-client-key": crypto.createHash("sha256").update(cleanToken).digest("hex"), 'x-client-key': crypto.createHash('sha256').update(cleanToken).digest('hex'),
"x-cursor-checksum": this.generateChecksum(machineId), 'x-cursor-checksum': this.generateChecksum(machineId),
"x-cursor-client-version": "2.3.41", 'x-cursor-client-version': '2.3.41',
"x-cursor-client-type": "ide", 'x-cursor-client-type': 'ide',
"x-cursor-client-os": 'x-cursor-client-os':
process.platform === "win32" process.platform === 'win32'
? "windows" ? 'windows'
: process.platform === "darwin" : process.platform === 'darwin'
? "macos" ? 'macos'
: "linux", : 'linux',
"x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64", 'x-cursor-client-arch': process.arch === 'arm64' ? 'aarch64' : 'x64',
"x-cursor-client-device-type": "desktop", 'x-cursor-client-device-type': 'desktop',
"x-cursor-config-version": crypto.randomUUID(), 'x-cursor-config-version': crypto.randomUUID(),
"x-cursor-timezone": 'x-cursor-timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", 'x-ghost-mode': ghostMode ? 'true' : 'false',
"x-ghost-mode": ghostMode ? "true" : "false", 'x-request-id': crypto.randomUUID(),
"x-request-id": crypto.randomUUID(), 'x-session-id': crypto.createHash('sha256').update(cleanToken).digest('hex').substring(0, 36),
"x-session-id": crypto
.createHash("sha256")
.update(cleanToken)
.digest("hex")
.substring(0, 36),
}; };
} }
transformRequest( transformRequest(
model: string, model: string,
body: ExecutorParams["body"], body: ExecutorParams['body'],
stream: boolean, stream: boolean,
credentials: CursorCredentials credentials: CursorCredentials
): Uint8Array { ): Uint8Array {
@@ -262,7 +258,7 @@ export class CursorExecutor {
signal?: AbortSignal signal?: AbortSignal
): Promise<Http2Response> { ): Promise<Http2Response> {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: 'POST',
headers, headers,
body, body,
signal, signal,
@@ -288,7 +284,7 @@ export class CursorExecutor {
): Promise<Http2Response> { ): Promise<Http2Response> {
const http2 = await getHttp2(); const http2 = await getHttp2();
if (!http2) { if (!http2) {
throw new Error("http2 module not available"); throw new Error('http2 module not available');
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -297,40 +293,45 @@ export class CursorExecutor {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
let responseHeaders: IncomingHttpHeaders = {}; let responseHeaders: IncomingHttpHeaders = {};
client.on("error", reject); client.on('error', (err) => {
client.close();
reject(err);
});
const req = client.request({ const req = client.request({
":method": "POST", ':method': 'POST',
":path": urlObj.pathname, ':path': urlObj.pathname,
":authority": urlObj.host, ':authority': urlObj.host,
":scheme": "https", ':scheme': 'https',
...headers, ...headers,
}); });
req.on("response", (hdrs) => { req.on('response', (hdrs) => {
responseHeaders = hdrs; responseHeaders = hdrs;
}); });
req.on("data", (chunk: Buffer) => { req.on('data', (chunk: Buffer) => {
chunks.push(chunk); chunks.push(chunk);
}); });
req.on("end", () => { req.on('end', () => {
client.close(); client.close();
resolve({ resolve({
status: Number(responseHeaders[":status"]), status: Number(responseHeaders[':status']),
headers: responseHeaders, headers: responseHeaders,
body: Buffer.concat(chunks), body: Buffer.concat(chunks),
}); });
}); });
req.on("error", (err) => { req.on('error', (err) => {
client.close(); client.close();
reject(err); reject(err);
}); });
if (signal) { if (signal) {
signal.addEventListener("abort", () => { // TODO: AbortSignal listener is not removed after request completes.
// To fix: store handler reference, remove in end/error callbacks.
signal.addEventListener('abort', () => {
req.close(); req.close();
client.close(); client.close();
reject(new Error("Request aborted")); reject(new Error('Request aborted'));
}); });
} }
@@ -343,7 +344,7 @@ export class CursorExecutor {
response: Response; response: Response;
url: string; url: string;
headers: Record<string, string>; headers: Record<string, string>;
transformedBody: ExecutorParams["body"]; transformedBody: ExecutorParams['body'];
}> { }> {
const { model, body, stream, credentials, signal } = params; const { model, body, stream, credentials, signal } = params;
const url = this.buildUrl(); const url = this.buildUrl();
@@ -357,18 +358,18 @@ export class CursorExecutor {
: await this.makeFetchRequest(url, headers, transformedBody, signal); : await this.makeFetchRequest(url, headers, transformedBody, signal);
if (response.status !== 200) { if (response.status !== 200) {
const errorText = response.body?.toString() || "Unknown error"; const errorText = response.body?.toString() || 'Unknown error';
const errorResponse = new Response( const errorResponse = new Response(
JSON.stringify({ JSON.stringify({
error: { error: {
message: `[${response.status}]: ${errorText}`, message: `[${response.status}]: ${errorText}`,
type: "invalid_request_error", type: 'invalid_request_error',
code: "", code: '',
}, },
}), }),
{ {
status: response.status, status: response.status,
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
} }
); );
return { response: errorResponse, url, headers, transformedBody: body }; return { response: errorResponse, url, headers, transformedBody: body };
@@ -385,29 +386,25 @@ export class CursorExecutor {
JSON.stringify({ JSON.stringify({
error: { error: {
message: (error as Error).message, message: (error as Error).message,
type: "connection_error", type: 'connection_error',
code: "", code: '',
}, },
}), }),
{ {
status: 500, status: 500,
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
} }
); );
return { response: errorResponse, url, headers, transformedBody: body }; return { response: errorResponse, url, headers, transformedBody: body };
} }
} }
transformProtobufToJSON( transformProtobufToJSON(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
buffer: Buffer,
model: string,
body: ExecutorParams["body"]
): Response {
const responseId = `chatcmpl-cursor-${Date.now()}`; const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000); const created = Math.floor(Date.now() / 1000);
let offset = 0; let offset = 0;
let totalContent = ""; let totalContent = '';
const toolCalls: Array<{ const toolCalls: Array<{
id: string; id: string;
type: string; type: string;
@@ -438,8 +435,8 @@ export class CursorExecutor {
payload = decompressPayload(payload, flags); payload = decompressPayload(payload, flags);
try { try {
const text = payload.toString("utf-8"); const text = payload.toString('utf-8');
if (text.startsWith("{") && text.includes('"error"')) { if (text.startsWith('{') && text.includes('"error"')) {
return createErrorResponse(JSON.parse(text)); return createErrorResponse(JSON.parse(text));
} }
} catch { } catch {
@@ -453,13 +450,13 @@ export class CursorExecutor {
JSON.stringify({ JSON.stringify({
error: { error: {
message: result.error, message: result.error,
type: "rate_limit_error", type: 'rate_limit_error',
code: "rate_limited", code: 'rate_limited',
}, },
}), }),
{ {
status: 429, status: 429,
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
} }
); );
} }
@@ -468,7 +465,8 @@ export class CursorExecutor {
const tc = result.toolCall; const tc = result.toolCall;
if (toolCallsMap.has(tc.id)) { if (toolCallsMap.has(tc.id)) {
const existing = toolCallsMap.get(tc.id)!; const existing = toolCallsMap.get(tc.id);
if (!existing) continue;
existing.function.arguments += tc.function.arguments; existing.function.arguments += tc.function.arguments;
existing.isLast = tc.isLast; existing.isLast = tc.isLast;
} else { } else {
@@ -479,7 +477,8 @@ export class CursorExecutor {
} }
if (tc.isLast) { if (tc.isLast) {
const finalToolCall = toolCallsMap.get(tc.id)!; const finalToolCall = toolCallsMap.get(tc.id);
if (!finalToolCall) continue;
toolCalls.push({ toolCalls.push({
id: finalToolCall.id, id: finalToolCall.id,
type: finalToolCall.type, type: finalToolCall.type,
@@ -496,7 +495,8 @@ export class CursorExecutor {
// Finalize remaining tool calls // Finalize remaining tool calls
for (const id of Array.from(toolCallsMap.keys())) { for (const id of Array.from(toolCallsMap.keys())) {
const tc = toolCallsMap.get(id)!; const tc = toolCallsMap.get(id);
if (!tc) continue;
if (!toolCalls.find((t) => t.id === id)) { if (!toolCalls.find((t) => t.id === id)) {
toolCalls.push({ toolCalls.push({
id: tc.id, id: tc.id,
@@ -518,7 +518,7 @@ export class CursorExecutor {
function: { name: string; arguments: string }; function: { name: string; arguments: string };
}>; }>;
} = { } = {
role: "assistant", role: 'assistant',
content: totalContent || null, content: totalContent || null,
}; };
@@ -528,14 +528,14 @@ export class CursorExecutor {
const completion = { const completion = {
id: responseId, id: responseId,
object: "chat.completion", object: 'chat.completion',
created, created,
model, model,
choices: [ choices: [
{ {
index: 0, index: 0,
message, message,
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop", finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
}, },
], ],
usage: { usage: {
@@ -547,21 +547,18 @@ export class CursorExecutor {
return new Response(JSON.stringify(completion), { return new Response(JSON.stringify(completion), {
status: 200, status: 200,
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
}); });
} }
transformProtobufToSSE( transformProtobufToSSE(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
buffer: Buffer, // TODO: Implement true streaming — currently buffers entire response before transforming.
model: string, // This should pipe HTTP/2 data events through a TransformStream for incremental SSE output.
body: ExecutorParams["body"]
): Response {
const responseId = `chatcmpl-cursor-${Date.now()}`; const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000); const created = Math.floor(Date.now() / 1000);
const chunks: string[] = []; const chunks: string[] = [];
let offset = 0; let offset = 0;
let totalContent = "";
const toolCalls: Array<{ const toolCalls: Array<{
id: string; id: string;
type: string; type: string;
@@ -593,8 +590,8 @@ export class CursorExecutor {
payload = decompressPayload(payload, flags); payload = decompressPayload(payload, flags);
try { try {
const text = payload.toString("utf-8"); const text = payload.toString('utf-8');
if (text.startsWith("{") && text.includes('"error"')) { if (text.startsWith('{') && text.includes('"error"')) {
return createErrorResponse(JSON.parse(text)); return createErrorResponse(JSON.parse(text));
} }
} catch { } catch {
@@ -608,13 +605,13 @@ export class CursorExecutor {
JSON.stringify({ JSON.stringify({
error: { error: {
message: result.error, message: result.error,
type: "rate_limit_error", type: 'rate_limit_error',
code: "rate_limited", code: 'rate_limited',
}, },
}), }),
{ {
status: 429, status: 429,
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
} }
); );
} }
@@ -626,13 +623,13 @@ export class CursorExecutor {
chunks.push( chunks.push(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
id: responseId, id: responseId,
object: "chat.completion.chunk", object: 'chat.completion.chunk',
created, created,
model, model,
choices: [ choices: [
{ {
index: 0, index: 0,
delta: { role: "assistant", content: "" }, delta: { role: 'assistant', content: '' },
finish_reason: null, finish_reason: null,
}, },
], ],
@@ -641,7 +638,8 @@ export class CursorExecutor {
} }
if (toolCallsMap.has(tc.id)) { if (toolCallsMap.has(tc.id)) {
const existing = toolCallsMap.get(tc.id)!; const existing = toolCallsMap.get(tc.id);
if (!existing) continue;
existing.function.arguments += tc.function.arguments; existing.function.arguments += tc.function.arguments;
existing.isLast = tc.isLast; existing.isLast = tc.isLast;
@@ -649,7 +647,7 @@ export class CursorExecutor {
chunks.push( chunks.push(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
id: responseId, id: responseId,
object: "chat.completion.chunk", object: 'chat.completion.chunk',
created, created,
model, model,
choices: [ choices: [
@@ -660,7 +658,7 @@ export class CursorExecutor {
{ {
index: existing.index, index: existing.index,
id: tc.id, id: tc.id,
type: "function", type: 'function',
function: { function: {
name: tc.function.name, name: tc.function.name,
arguments: tc.function.arguments, arguments: tc.function.arguments,
@@ -682,7 +680,7 @@ export class CursorExecutor {
chunks.push( chunks.push(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
id: responseId, id: responseId,
object: "chat.completion.chunk", object: 'chat.completion.chunk',
created, created,
model, model,
choices: [ choices: [
@@ -693,7 +691,7 @@ export class CursorExecutor {
{ {
index: toolCallIndex, index: toolCallIndex,
id: tc.id, id: tc.id,
type: "function", type: 'function',
function: { function: {
name: tc.function.name, name: tc.function.name,
arguments: tc.function.arguments, arguments: tc.function.arguments,
@@ -710,11 +708,10 @@ export class CursorExecutor {
} }
if (result.text) { if (result.text) {
totalContent += result.text;
chunks.push( chunks.push(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
id: responseId, id: responseId,
object: "chat.completion.chunk", object: 'chat.completion.chunk',
created, created,
model, model,
choices: [ choices: [
@@ -722,7 +719,7 @@ export class CursorExecutor {
index: 0, index: 0,
delta: delta:
chunks.length === 0 && toolCalls.length === 0 chunks.length === 0 && toolCalls.length === 0
? { role: "assistant", content: result.text } ? { role: 'assistant', content: result.text }
: { content: result.text }, : { content: result.text },
finish_reason: null, finish_reason: null,
}, },
@@ -736,13 +733,13 @@ export class CursorExecutor {
chunks.push( chunks.push(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
id: responseId, id: responseId,
object: "chat.completion.chunk", object: 'chat.completion.chunk',
created, created,
model, model,
choices: [ choices: [
{ {
index: 0, index: 0,
delta: { role: "assistant", content: "" }, delta: { role: 'assistant', content: '' },
finish_reason: null, finish_reason: null,
}, },
], ],
@@ -753,14 +750,14 @@ export class CursorExecutor {
chunks.push( chunks.push(
`data: ${JSON.stringify({ `data: ${JSON.stringify({
id: responseId, id: responseId,
object: "chat.completion.chunk", object: 'chat.completion.chunk',
created, created,
model, model,
choices: [ choices: [
{ {
index: 0, index: 0,
delta: {}, delta: {},
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop", finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
}, },
], ],
usage: { usage: {
@@ -770,14 +767,14 @@ export class CursorExecutor {
}, },
})}\n\n` })}\n\n`
); );
chunks.push("data: [DONE]\n\n"); chunks.push('data: [DONE]\n\n');
return new Response(chunks.join(""), { return new Response(chunks.join(''), {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "text/event-stream", 'Content-Type': 'text/event-stream',
"Cache-Control": "no-cache", 'Cache-Control': 'no-cache',
Connection: "keep-alive", Connection: 'keep-alive',
}, },
}); });
} }
+76 -64
View File
@@ -3,26 +3,20 @@
* Implements ConnectRPC protobuf wire format decoding * Implements ConnectRPC protobuf wire format decoding
*/ */
import * as zlib from "zlib"; import * as zlib from 'zlib';
import { import { WIRE_TYPE, FIELD, type WireType } from './cursor-protobuf-schema.js';
WIRE_TYPE,
FIELD,
type WireType,
} from "./cursor-protobuf-schema.js";
/** /**
* Decode a varint from buffer * Decode a varint from buffer
* Returns [value, newOffset] * Returns [value, newOffset]
*/ */
export function decodeVarint( export function decodeVarint(buffer: Uint8Array, offset: number): [number, number] {
buffer: Uint8Array,
offset: number
): [number, number] {
let result = 0; let result = 0;
let shift = 0; let shift = 0;
let pos = offset; let pos = offset;
const maxBytes = 5;
while (pos < buffer.length) { while (pos < buffer.length && pos - offset < maxBytes) {
const b = buffer[pos]; const b = buffer[pos];
result |= (b & 0x7f) << shift; result |= (b & 0x7f) << shift;
pos++; pos++;
@@ -56,6 +50,9 @@ export function decodeField(
[value, pos] = decodeVarint(buffer, pos); [value, pos] = decodeVarint(buffer, pos);
} else if (wireType === WIRE_TYPE.LEN) { } else if (wireType === WIRE_TYPE.LEN) {
const [length, pos2] = decodeVarint(buffer, pos); const [length, pos2] = decodeVarint(buffer, pos);
if (pos2 + length > buffer.length) {
return [null, null, null, buffer.length];
}
value = buffer.slice(pos2, pos2 + length); value = buffer.slice(pos2, pos2 + length);
pos = pos2 + length; pos = pos2 + length;
} else if (wireType === WIRE_TYPE.FIXED64) { } else if (wireType === WIRE_TYPE.FIXED64) {
@@ -77,10 +74,7 @@ export function decodeField(
export function decodeMessage( export function decodeMessage(
data: Uint8Array data: Uint8Array
): Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>> { ): Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>> {
const fields = new Map< const fields = new Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>>();
number,
Array<{ wireType: WireType; value: Uint8Array | number }>
>();
let pos = 0; let pos = 0;
while (pos < data.length) { while (pos < data.length) {
@@ -90,7 +84,10 @@ export function decodeMessage(
if (!fields.has(fieldNum)) { if (!fields.has(fieldNum)) {
fields.set(fieldNum, []); fields.set(fieldNum, []);
} }
fields.get(fieldNum)!.push({ wireType, value: value as Uint8Array | number }); const fieldArray = fields.get(fieldNum);
if (fieldArray) {
fieldArray.push({ wireType, value: value as Uint8Array | number });
}
pos = newPos; pos = newPos;
} }
@@ -110,8 +107,7 @@ export function parseConnectRPCFrame(buffer: Buffer): {
if (buffer.length < 5) return null; if (buffer.length < 5) return null;
const flags = buffer[0]; const flags = buffer[0];
const length = const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4];
(buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4];
if (buffer.length < 5 + length) return null; if (buffer.length < 5 + length) return null;
@@ -144,53 +140,62 @@ function extractToolCall(toolCallData: Uint8Array): {
isLast: boolean; isLast: boolean;
} | null { } | null {
const toolCall = decodeMessage(toolCallData); const toolCall = decodeMessage(toolCallData);
let toolCallId = ""; let toolCallId = '';
let toolName = ""; let toolName = '';
let rawArgs = ""; let rawArgs = '';
let isLast = false; let isLast = false;
// Extract tool call ID // Extract tool call ID
if (toolCall.has(FIELD.TOOL_ID)) { if (toolCall.has(FIELD.TOOL_ID)) {
const fullId = new TextDecoder().decode( const idField = toolCall.get(FIELD.TOOL_ID);
toolCall.get(FIELD.TOOL_ID)![0].value as Uint8Array if (idField && idField[0]) {
); const fullId = new TextDecoder().decode(idField[0].value as Uint8Array);
toolCallId = fullId.split("\n")[0]; // Take first line toolCallId = fullId.split('\n')[0]; // Take first line
}
} }
// Extract tool name // Extract tool name
if (toolCall.has(FIELD.TOOL_NAME)) { if (toolCall.has(FIELD.TOOL_NAME)) {
toolName = new TextDecoder().decode( const nameField = toolCall.get(FIELD.TOOL_NAME);
toolCall.get(FIELD.TOOL_NAME)![0].value as Uint8Array if (nameField && nameField[0]) {
); toolName = new TextDecoder().decode(nameField[0].value as Uint8Array);
}
} }
// Extract is_last flag // Extract is_last flag
if (toolCall.has(FIELD.TOOL_IS_LAST)) { if (toolCall.has(FIELD.TOOL_IS_LAST)) {
isLast = (toolCall.get(FIELD.TOOL_IS_LAST)![0].value as number) !== 0; const lastField = toolCall.get(FIELD.TOOL_IS_LAST);
if (lastField && lastField[0]) {
isLast = (lastField[0].value as number) !== 0;
}
} }
// Extract MCP params - nested real tool info // Extract MCP params - nested real tool info
if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) { if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) {
try { try {
const mcpParams = decodeMessage( const mcpField = toolCall.get(FIELD.TOOL_MCP_PARAMS);
toolCall.get(FIELD.TOOL_MCP_PARAMS)![0].value as Uint8Array if (!mcpField || !mcpField[0]) return null;
);
const mcpParams = decodeMessage(mcpField[0].value as Uint8Array);
if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) { if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) {
const tool = decodeMessage( const toolsList = mcpParams.get(FIELD.MCP_TOOLS_LIST);
mcpParams.get(FIELD.MCP_TOOLS_LIST)![0].value as Uint8Array if (!toolsList || !toolsList[0]) return null;
);
const tool = decodeMessage(toolsList[0].value as Uint8Array);
if (tool.has(FIELD.MCP_NESTED_NAME)) { if (tool.has(FIELD.MCP_NESTED_NAME)) {
toolName = new TextDecoder().decode( const nestedName = tool.get(FIELD.MCP_NESTED_NAME);
tool.get(FIELD.MCP_NESTED_NAME)![0].value as Uint8Array if (nestedName && nestedName[0]) {
); toolName = new TextDecoder().decode(nestedName[0].value as Uint8Array);
}
} }
if (tool.has(FIELD.MCP_NESTED_PARAMS)) { if (tool.has(FIELD.MCP_NESTED_PARAMS)) {
rawArgs = new TextDecoder().decode( const nestedParams = tool.get(FIELD.MCP_NESTED_PARAMS);
tool.get(FIELD.MCP_NESTED_PARAMS)![0].value as Uint8Array if (nestedParams && nestedParams[0]) {
); rawArgs = new TextDecoder().decode(nestedParams[0].value as Uint8Array);
}
} }
} }
} catch { } catch {
@@ -200,18 +205,19 @@ function extractToolCall(toolCallData: Uint8Array): {
// Fallback to raw_args // Fallback to raw_args
if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) { if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) {
rawArgs = new TextDecoder().decode( const rawArgsField = toolCall.get(FIELD.TOOL_RAW_ARGS);
toolCall.get(FIELD.TOOL_RAW_ARGS)![0].value as Uint8Array if (rawArgsField && rawArgsField[0]) {
); rawArgs = new TextDecoder().decode(rawArgsField[0].value as Uint8Array);
}
} }
if (toolCallId && toolName) { if (toolCallId && toolName) {
return { return {
id: toolCallId, id: toolCallId,
type: "function", type: 'function',
function: { function: {
name: toolName, name: toolName,
arguments: rawArgs || "{}", arguments: rawArgs || '{}',
}, },
isLast, isLast,
}; };
@@ -223,30 +229,34 @@ function extractToolCall(toolCallData: Uint8Array): {
/** /**
* Extract text and thinking from response data * Extract text and thinking from response data
*/ */
function extractTextAndThinking( function extractTextAndThinking(responseData: Uint8Array): {
responseData: Uint8Array text: string | null;
): { text: string | null; thinking: string | null } { thinking: string | null;
} {
const nested = decodeMessage(responseData); const nested = decodeMessage(responseData);
let text: string | null = null; let text: string | null = null;
let thinking: string | null = null; let thinking: string | null = null;
// Extract text // Extract text
if (nested.has(FIELD.RESPONSE_TEXT)) { if (nested.has(FIELD.RESPONSE_TEXT)) {
text = new TextDecoder().decode( const textField = nested.get(FIELD.RESPONSE_TEXT);
nested.get(FIELD.RESPONSE_TEXT)![0].value as Uint8Array if (textField && textField[0]) {
); text = new TextDecoder().decode(textField[0].value as Uint8Array);
}
} }
// Extract thinking // Extract thinking
if (nested.has(FIELD.THINKING)) { if (nested.has(FIELD.THINKING)) {
try { try {
const thinkingMsg = decodeMessage( const thinkingField = nested.get(FIELD.THINKING);
nested.get(FIELD.THINKING)![0].value as Uint8Array if (thinkingField && thinkingField[0]) {
); const thinkingMsg = decodeMessage(thinkingField[0].value as Uint8Array);
if (thinkingMsg.has(FIELD.THINKING_TEXT)) { if (thinkingMsg.has(FIELD.THINKING_TEXT)) {
thinking = new TextDecoder().decode( const thinkingTextField = thinkingMsg.get(FIELD.THINKING_TEXT);
thinkingMsg.get(FIELD.THINKING_TEXT)![0].value as Uint8Array if (thinkingTextField && thinkingTextField[0]) {
); thinking = new TextDecoder().decode(thinkingTextField[0].value as Uint8Array);
}
}
} }
} catch { } catch {
// Thinking parse error, continue // Thinking parse error, continue
@@ -275,24 +285,26 @@ export function extractTextFromResponse(payload: Uint8Array): {
// Field 1: ClientSideToolV2Call // Field 1: ClientSideToolV2Call
if (fields.has(FIELD.TOOL_CALL)) { if (fields.has(FIELD.TOOL_CALL)) {
const toolCall = extractToolCall( const toolCallField = fields.get(FIELD.TOOL_CALL);
fields.get(FIELD.TOOL_CALL)![0].value as Uint8Array if (toolCallField && toolCallField[0]) {
); const toolCall = extractToolCall(toolCallField[0].value as Uint8Array);
if (toolCall) { if (toolCall) {
return { text: null, error: null, toolCall, thinking: null }; return { text: null, error: null, toolCall, thinking: null };
} }
} }
}
// Field 2: StreamUnifiedChatResponse // Field 2: StreamUnifiedChatResponse
if (fields.has(FIELD.RESPONSE)) { if (fields.has(FIELD.RESPONSE)) {
const { text, thinking } = extractTextAndThinking( const responseField = fields.get(FIELD.RESPONSE);
fields.get(FIELD.RESPONSE)![0].value as Uint8Array if (responseField && responseField[0]) {
); const { text, thinking } = extractTextAndThinking(responseField[0].value as Uint8Array);
if (text || thinking) { if (text || thinking) {
return { text, error: null, toolCall: null, thinking }; return { text, error: null, toolCall: null, thinking };
} }
} }
}
return { text: null, error: null, toolCall: null, thinking: null }; return { text: null, error: null, toolCall: null, thinking: null };
} catch { } catch {
+25 -63
View File
@@ -3,24 +3,17 @@
* Implements ConnectRPC protobuf wire format encoding * Implements ConnectRPC protobuf wire format encoding
*/ */
import { randomUUID } from "crypto"; import * as zlib from 'zlib';
import * as zlib from "zlib";
import { import {
WIRE_TYPE, WIRE_TYPE,
ROLE,
UNIFIED_MODE,
THINKING_LEVEL,
FIELD, FIELD,
COMPRESS_FLAG, COMPRESS_FLAG,
UNIFIED_MODE,
type WireType, type WireType,
type RoleType, type RoleType,
type ThinkingLevelType,
type CursorTool, type CursorTool,
type CursorToolResult, type CursorToolResult,
type CursorMessage, } from './cursor-protobuf-schema.js';
type FormattedMessage,
type MessageId,
} from "./cursor-protobuf-schema.js";
/** /**
* Encode a varint (variable-length integer) * Encode a varint (variable-length integer)
@@ -54,7 +47,7 @@ export function encodeField(
if (wireType === WIRE_TYPE.LEN) { if (wireType === WIRE_TYPE.LEN) {
const dataBytes = const dataBytes =
typeof value === "string" typeof value === 'string'
? new TextEncoder().encode(value) ? new TextEncoder().encode(value)
: value instanceof Uint8Array : value instanceof Uint8Array
? value ? value
@@ -70,7 +63,7 @@ export function encodeField(
/** /**
* Concatenate multiple Uint8Arrays * Concatenate multiple Uint8Arrays
*/ */
function concatArrays(...arrays: Uint8Array[]): Uint8Array { export function concatArrays(...arrays: Uint8Array[]): Uint8Array {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength); const result = new Uint8Array(totalLength);
let offset = 0; let offset = 0;
@@ -85,10 +78,10 @@ function concatArrays(...arrays: Uint8Array[]): Uint8Array {
* Encode a tool result * Encode a tool result
*/ */
export function encodeToolResult(toolResult: CursorToolResult): Uint8Array { export function encodeToolResult(toolResult: CursorToolResult): Uint8Array {
const toolCallId = toolResult.tool_call_id || ""; const toolCallId = toolResult.tool_call_id || '';
const toolName = toolResult.name || ""; const toolName = toolResult.name || '';
const toolIndex = toolResult.index || 0; const toolIndex = toolResult.index || 0;
const rawArgs = toolResult.raw_args || "{}"; const rawArgs = toolResult.raw_args || '{}';
return concatArrays( return concatArrays(
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId), encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
@@ -115,11 +108,7 @@ export function encodeMessage(
encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId), encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId),
...(toolResults.length > 0 ...(toolResults.length > 0
? toolResults.map((tr) => ? toolResults.map((tr) =>
encodeField( encodeField(FIELD.MSG_TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr))
FIELD.MSG_TOOL_RESULTS,
WIRE_TYPE.LEN,
encodeToolResult(tr)
)
) )
: []), : []),
encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0), encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0),
@@ -129,13 +118,7 @@ export function encodeMessage(
hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
), ),
...(isLast && hasTools ...(isLast && hasTools
? [ ? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
encodeField(
FIELD.MSG_SUPPORTED_TOOLS,
WIRE_TYPE.LEN,
encodeVarint(1)
),
]
: []) : [])
); );
} }
@@ -144,9 +127,7 @@ export function encodeMessage(
* Encode instruction text * Encode instruction text
*/ */
export function encodeInstruction(text: string): Uint8Array { export function encodeInstruction(text: string): Uint8Array {
return text return text ? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0);
? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text)
: new Uint8Array(0);
} }
/** /**
@@ -169,7 +150,7 @@ export function encodeCursorSetting(): Uint8Array {
); );
return concatArrays( return concatArrays(
encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, "cursor\\aisettings"), encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, 'cursor\\aisettings'),
encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)), encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6), encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6),
encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1), encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1),
@@ -182,10 +163,10 @@ export function encodeCursorSetting(): Uint8Array {
*/ */
export function encodeMetadata(): Uint8Array { export function encodeMetadata(): Uint8Array {
return concatArrays( return concatArrays(
encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || "linux"), encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || 'linux'),
encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || "x64"), encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || 'x64'),
encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || "v20.0.0"), encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || 'v20.0.0'),
encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd() || "/"), encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd() || '/'),
encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString()) encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString())
); );
} }
@@ -193,16 +174,10 @@ export function encodeMetadata(): Uint8Array {
/** /**
* Encode message ID * Encode message ID
*/ */
export function encodeMessageId( export function encodeMessageId(messageId: string, role: RoleType, summaryId?: string): Uint8Array {
messageId: string,
role: RoleType,
summaryId?: string
): Uint8Array {
return concatArrays( return concatArrays(
encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId), encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId),
...(summaryId ...(summaryId ? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)] : []),
? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)]
: []),
encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role) encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role)
); );
} }
@@ -211,37 +186,24 @@ export function encodeMessageId(
* Encode MCP tool * Encode MCP tool
*/ */
export function encodeMcpTool(tool: CursorTool): Uint8Array { export function encodeMcpTool(tool: CursorTool): Uint8Array {
const toolName = tool.function?.name || tool.name || ""; const toolName = tool.function?.name || tool.name || '';
const toolDesc = tool.function?.description || tool.description || ""; const toolDesc = tool.function?.description || tool.description || '';
const inputSchema = tool.function?.parameters || tool.input_schema || {}; const inputSchema = tool.function?.parameters || tool.input_schema || {};
return concatArrays( return concatArrays(
...(toolName ...(toolName ? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)] : []),
? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)] ...(toolDesc ? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)] : []),
: []),
...(toolDesc
? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)]
: []),
...(Object.keys(inputSchema).length > 0 ...(Object.keys(inputSchema).length > 0
? [ ? [encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))]
encodeField(
FIELD.MCP_TOOL_PARAMS,
WIRE_TYPE.LEN,
JSON.stringify(inputSchema)
),
]
: []), : []),
encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, "custom") encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, 'custom')
); );
} }
/** /**
* Wrap payload in ConnectRPC frame (5-byte header + payload) * Wrap payload in ConnectRPC frame (5-byte header + payload)
*/ */
export function wrapConnectRPCFrame( export function wrapConnectRPCFrame(payload: Uint8Array, compress = false): Uint8Array {
payload: Uint8Array,
compress = false
): Uint8Array {
let finalPayload = payload; let finalPayload = payload;
let flags: number = COMPRESS_FLAG.NONE; let flags: number = COMPRESS_FLAG.NONE;
+1 -2
View File
@@ -143,8 +143,7 @@ export const FIELD = {
export type WireType = (typeof WIRE_TYPE)[keyof typeof WIRE_TYPE]; export type WireType = (typeof WIRE_TYPE)[keyof typeof WIRE_TYPE];
export type RoleType = (typeof ROLE)[keyof typeof ROLE]; export type RoleType = (typeof ROLE)[keyof typeof ROLE];
export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE]; export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE];
export type ThinkingLevelType = export type ThinkingLevelType = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL];
(typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL];
export type FieldNumber = (typeof FIELD)[keyof typeof FIELD]; export type FieldNumber = (typeof FIELD)[keyof typeof FIELD];
/** Cursor tool definition */ /** Cursor tool definition */
+20 -46
View File
@@ -3,7 +3,7 @@
* Exports encoder/decoder functions and builds complete requests * Exports encoder/decoder functions and builds complete requests
*/ */
import { randomUUID } from "crypto"; import { randomUUID } from 'crypto';
import { import {
ROLE, ROLE,
UNIFIED_MODE, UNIFIED_MODE,
@@ -14,7 +14,7 @@ import {
type FormattedMessage, type FormattedMessage,
type MessageId, type MessageId,
type ThinkingLevelType, type ThinkingLevelType,
} from "./cursor-protobuf-schema.js"; } from './cursor-protobuf-schema.js';
import { import {
encodeField, encodeField,
encodeVarint, encodeVarint,
@@ -26,15 +26,16 @@ import {
encodeMessageId, encodeMessageId,
encodeMcpTool, encodeMcpTool,
wrapConnectRPCFrame, wrapConnectRPCFrame,
} from "./cursor-protobuf-encoder.js"; concatArrays,
} from './cursor-protobuf-encoder.js';
import { import {
decodeVarint, decodeVarint,
decodeField, decodeField,
decodeMessage, decodeMessage,
parseConnectRPCFrame, parseConnectRPCFrame,
extractTextFromResponse, extractTextFromResponse,
} from "./cursor-protobuf-decoder.js"; } from './cursor-protobuf-decoder.js';
import { WIRE_TYPE } from "./cursor-protobuf-schema.js"; import { WIRE_TYPE } from './cursor-protobuf-schema.js';
/** /**
* Build complete chat request protobuf * Build complete chat request protobuf
@@ -45,6 +46,10 @@ export function encodeRequest(
tools: CursorTool[] = [], tools: CursorTool[] = [],
reasoningEffort: string | null = null reasoningEffort: string | null = null
): Uint8Array { ): Uint8Array {
if (messages.length === 0) {
throw new Error('Messages array must not be empty');
}
const hasTools = tools?.length > 0; const hasTools = tools?.length > 0;
const isAgentic = hasTools; const isAgentic = hasTools;
const formattedMessages: FormattedMessage[] = []; const formattedMessages: FormattedMessage[] = [];
@@ -53,7 +58,7 @@ export function encodeRequest(
// Prepare messages // Prepare messages
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {
const msg = messages[i]; const msg = messages[i];
const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT; const role = msg.role === 'user' ? ROLE.USER : ROLE.ASSISTANT;
const msgId = randomUUID(); const msgId = randomUUID();
const isLast = i === messages.length - 1; const isLast = i === messages.length - 1;
@@ -71,38 +76,25 @@ export function encodeRequest(
// Map reasoning effort to thinking level // Map reasoning effort to thinking level
let thinkingLevel: ThinkingLevelType = THINKING_LEVEL.UNSPECIFIED; let thinkingLevel: ThinkingLevelType = THINKING_LEVEL.UNSPECIFIED;
if (reasoningEffort === "medium") thinkingLevel = THINKING_LEVEL.MEDIUM; if (reasoningEffort === 'medium') thinkingLevel = THINKING_LEVEL.MEDIUM;
else if (reasoningEffort === "high") thinkingLevel = THINKING_LEVEL.HIGH; else if (reasoningEffort === 'high') thinkingLevel = THINKING_LEVEL.HIGH;
// Build arrays for messages and tools // Build arrays for messages and tools
const messageFields = formattedMessages.map((fm) => const messageFields = formattedMessages.map((fm) =>
encodeField( encodeField(
FIELD.MESSAGES, FIELD.MESSAGES,
WIRE_TYPE.LEN, WIRE_TYPE.LEN,
encodeMessage( encodeMessage(fm.content, fm.role, fm.messageId, fm.isLast, fm.hasTools, fm.toolResults)
fm.content,
fm.role,
fm.messageId,
fm.isLast,
fm.hasTools,
fm.toolResults
)
) )
); );
const messageIdFields = messageIds.map((mid) => const messageIdFields = messageIds.map((mid) =>
encodeField( encodeField(FIELD.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role))
FIELD.MESSAGE_IDS,
WIRE_TYPE.LEN,
encodeMessageId(mid.messageId, mid.role)
)
); );
const toolFields = const toolFields =
tools?.length > 0 tools?.length > 0
? tools.map((tool) => ? tools.map((tool) => encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool)))
encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool))
)
: []; : [];
const supportedToolsField = isAgentic const supportedToolsField = isAgentic
@@ -113,10 +105,10 @@ export function encodeRequest(
const parts: Uint8Array[] = [ const parts: Uint8Array[] = [
...messageFields, ...messageFields,
encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1), encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction("")), encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction('')),
encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1), encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)), encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)),
encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ""), encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ''),
encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1), encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()), encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()),
encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1), encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1),
@@ -133,16 +125,12 @@ export function encodeRequest(
WIRE_TYPE.VARINT, WIRE_TYPE.VARINT,
isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
), ),
encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ""), encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ''),
encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1), encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1),
encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel), encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel),
encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0), encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1), encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1),
encodeField( encodeField(FIELD.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? 'Agent' : 'Ask'),
FIELD.UNIFIED_MODE_NAME,
WIRE_TYPE.LEN,
isAgentic ? "Agent" : "Ask"
),
]; ];
return concatArrays(...parts); return concatArrays(...parts);
@@ -178,20 +166,6 @@ export function generateCursorBody(
return framed; return framed;
} }
/**
* Concatenate multiple Uint8Arrays
*/
function concatArrays(...arrays: Uint8Array[]): Uint8Array {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
// Re-export all functions // Re-export all functions
export { export {
encodeVarint, encodeVarint,
+19 -23
View File
@@ -3,11 +3,7 @@
* Converts OpenAI messages to Cursor format * Converts OpenAI messages to Cursor format
*/ */
import type { import type { CursorMessage, CursorToolResult, CursorTool } from './cursor-protobuf-schema.js';
CursorMessage,
CursorToolResult,
CursorTool,
} from "./cursor-protobuf-schema.js";
/** OpenAI message format */ /** OpenAI message format */
interface OpenAIMessage { interface OpenAIMessage {
@@ -42,28 +38,28 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {
const msg = messages[i]; const msg = messages[i];
if (msg.role === "system") { if (msg.role === 'system') {
result.push({ result.push({
role: "user", role: 'user',
content: `[System Instructions]\n${msg.content}`, content: `[System Instructions]\n${msg.content}`,
}); });
continue; continue;
} }
if (msg.role === "tool") { if (msg.role === 'tool') {
let toolContent = ""; let toolContent = '';
if (typeof msg.content === "string") { if (typeof msg.content === 'string') {
toolContent = msg.content; toolContent = msg.content;
} else if (Array.isArray(msg.content)) { } else if (Array.isArray(msg.content)) {
for (const part of msg.content) { for (const part of msg.content) {
if (part.type === "text" && part.text) { if (part.type === 'text' && part.text) {
toolContent += part.text; toolContent += part.text;
} }
} }
} }
const toolName = msg.name || "tool"; const toolName = msg.name || 'tool';
const toolCallId = msg.tool_call_id || ""; const toolCallId = msg.tool_call_id || '';
// Accumulate tool result // Accumulate tool result
pendingToolResults.push({ pendingToolResults.push({
@@ -75,22 +71,22 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
continue; continue;
} }
if (msg.role === "user" || msg.role === "assistant") { if (msg.role === 'user' || msg.role === 'assistant') {
let content = ""; let content = '';
if (typeof msg.content === "string") { if (typeof msg.content === 'string') {
content = msg.content; content = msg.content;
} else if (Array.isArray(msg.content)) { } else if (Array.isArray(msg.content)) {
for (const part of msg.content) { for (const part of msg.content) {
if (part.type === "text" && part.text) { if (part.type === 'text' && part.text) {
content += part.text; content += part.text;
} }
} }
} }
// Keep tool_calls structure for assistant messages // Keep tool_calls structure for assistant messages
if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) { if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
const assistantMsg: CursorMessage = { role: "assistant", content: "" }; const assistantMsg: CursorMessage = { role: 'assistant', content: '' };
if (content) { if (content) {
assistantMsg.content = content; assistantMsg.content = content;
} }
@@ -106,7 +102,7 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
} else if (content || pendingToolResults.length > 0) { } else if (content || pendingToolResults.length > 0) {
const msgObj: CursorMessage = { const msgObj: CursorMessage = {
role: msg.role, role: msg.role,
content: content || "", content: content || '',
}; };
// Attach pending tool results to this message // Attach pending tool results to this message
@@ -128,10 +124,10 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
* Returns modified body with converted messages * Returns modified body with converted messages
*/ */
export function buildCursorRequest( export function buildCursorRequest(
model: string, _model: string,
body: OpenAIRequestBody, body: OpenAIRequestBody,
stream: boolean, _stream: boolean,
credentials: unknown _credentials: unknown
): { ): {
messages: CursorMessage[]; messages: CursorMessage[];
tools?: CursorTool[]; tools?: CursorTool[];