mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
342 lines
9.4 KiB
TypeScript
342 lines
9.4 KiB
TypeScript
/**
|
|
* Cursor Protobuf Decoder
|
|
* Implements ConnectRPC protobuf wire format decoding
|
|
*/
|
|
|
|
import * as zlib from 'zlib';
|
|
import {
|
|
WIRE_TYPE,
|
|
FIELD,
|
|
isCompressedConnectFrame,
|
|
type WireType,
|
|
} from './cursor-protobuf-schema.js';
|
|
|
|
/**
|
|
* Decode a varint from buffer
|
|
* Returns [value, newOffset]
|
|
*/
|
|
export function decodeVarint(buffer: Uint8Array, offset: number): [number, number] {
|
|
let result = 0;
|
|
let shift = 0;
|
|
let pos = offset;
|
|
const maxBytes = 5;
|
|
|
|
while (pos < buffer.length && pos - offset < maxBytes) {
|
|
const b = buffer[pos];
|
|
result |= (b & 0x7f) << shift;
|
|
pos++;
|
|
if (!(b & 0x80)) break;
|
|
shift += 7;
|
|
}
|
|
|
|
return [result >>> 0, pos]; // Ensure unsigned
|
|
}
|
|
|
|
/**
|
|
* Decode a single protobuf field
|
|
* Returns [fieldNum, wireType, value, newOffset]
|
|
*/
|
|
export function decodeField(
|
|
buffer: Uint8Array,
|
|
offset: number
|
|
): [number | null, WireType | null, Uint8Array | number | null, number] {
|
|
if (offset >= buffer.length) {
|
|
return [null, null, null, offset];
|
|
}
|
|
|
|
const [tag, pos1] = decodeVarint(buffer, offset);
|
|
const fieldNum = tag >> 3;
|
|
const wireType = (tag & 0x07) as WireType;
|
|
|
|
let value: Uint8Array | number | null;
|
|
let pos = pos1;
|
|
|
|
if (wireType === WIRE_TYPE.VARINT) {
|
|
[value, pos] = decodeVarint(buffer, pos);
|
|
} else if (wireType === WIRE_TYPE.LEN) {
|
|
const [length, pos2] = decodeVarint(buffer, pos);
|
|
if (pos2 + length > buffer.length) {
|
|
return [null, null, null, buffer.length];
|
|
}
|
|
value = buffer.slice(pos2, pos2 + length);
|
|
pos = pos2 + length;
|
|
} else if (wireType === WIRE_TYPE.FIXED64) {
|
|
if (pos + 8 > buffer.length) {
|
|
return [null, null, null, buffer.length];
|
|
}
|
|
value = buffer.slice(pos, pos + 8);
|
|
pos += 8;
|
|
} else if (wireType === WIRE_TYPE.FIXED32) {
|
|
if (pos + 4 > buffer.length) {
|
|
return [null, null, null, buffer.length];
|
|
}
|
|
value = buffer.slice(pos, pos + 4);
|
|
pos += 4;
|
|
} else {
|
|
value = null;
|
|
}
|
|
|
|
return [fieldNum, wireType, value, pos];
|
|
}
|
|
|
|
/**
|
|
* Decode a protobuf message into a map of fields
|
|
*/
|
|
export function decodeMessage(
|
|
data: Uint8Array
|
|
): Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>> {
|
|
const fields = new Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>>();
|
|
let pos = 0;
|
|
|
|
// NOTE: If two fields share the same field number but different wire types, later values overwrite earlier ones.
|
|
while (pos < data.length) {
|
|
const [fieldNum, wireType, value, newPos] = decodeField(data, pos);
|
|
if (fieldNum === null || wireType === null || value === null) break;
|
|
|
|
if (!fields.has(fieldNum)) {
|
|
fields.set(fieldNum, []);
|
|
}
|
|
const fieldArray = fields.get(fieldNum);
|
|
if (fieldArray) {
|
|
fieldArray.push({ wireType, value: value as Uint8Array | number });
|
|
}
|
|
pos = newPos;
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* Parse ConnectRPC frame from buffer
|
|
* Returns frame data or null if incomplete
|
|
*/
|
|
export function parseConnectRPCFrame(buffer: Buffer): {
|
|
flags: number;
|
|
length: number;
|
|
payload: Uint8Array;
|
|
consumed: number;
|
|
} | null {
|
|
if (buffer.length < 5) return null;
|
|
|
|
const flags = buffer[0];
|
|
const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4];
|
|
|
|
if (buffer.length < 5 + length) return null;
|
|
|
|
let payload = buffer.slice(5, 5 + length);
|
|
|
|
// Decompress if gzip
|
|
if (isCompressedConnectFrame(flags)) {
|
|
try {
|
|
payload = Buffer.from(zlib.gunzipSync(payload));
|
|
} catch (err) {
|
|
if (process.env.CCS_DEBUG) {
|
|
console.error('[cursor] parseConnectRPCFrame decompression failed:', err);
|
|
}
|
|
// Decompression failed, use raw payload
|
|
}
|
|
}
|
|
|
|
return {
|
|
flags,
|
|
length,
|
|
payload: new Uint8Array(payload),
|
|
consumed: 5 + length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract tool call from protobuf data
|
|
*/
|
|
function extractToolCall(toolCallData: Uint8Array): {
|
|
id: string;
|
|
type: string;
|
|
function: { name: string; arguments: string };
|
|
isLast: boolean;
|
|
} | null {
|
|
const toolCall = decodeMessage(toolCallData);
|
|
let toolCallId = '';
|
|
let toolName = '';
|
|
let rawArgs = '';
|
|
let isLast = false;
|
|
|
|
// Extract tool call ID
|
|
if (toolCall.has(FIELD.ToolCall.ID)) {
|
|
const idField = toolCall.get(FIELD.ToolCall.ID);
|
|
if (idField && idField[0]) {
|
|
const fullId = new TextDecoder().decode(idField[0].value as Uint8Array);
|
|
toolCallId = fullId.split('\n')[0]; // Take first line
|
|
}
|
|
}
|
|
|
|
// Extract tool name
|
|
if (toolCall.has(FIELD.ToolCall.NAME)) {
|
|
const nameField = toolCall.get(FIELD.ToolCall.NAME);
|
|
if (nameField && nameField[0]) {
|
|
toolName = new TextDecoder().decode(nameField[0].value as Uint8Array);
|
|
}
|
|
}
|
|
|
|
// Extract is_last flag
|
|
if (toolCall.has(FIELD.ToolCall.IS_LAST)) {
|
|
const lastField = toolCall.get(FIELD.ToolCall.IS_LAST);
|
|
if (lastField && lastField[0]) {
|
|
isLast = (lastField[0].value as number) !== 0;
|
|
}
|
|
}
|
|
|
|
// Extract MCP params - nested real tool info
|
|
if (toolCall.has(FIELD.ToolCall.MCP_PARAMS)) {
|
|
try {
|
|
const mcpField = toolCall.get(FIELD.ToolCall.MCP_PARAMS);
|
|
if (!mcpField || !mcpField[0]) return null;
|
|
|
|
const mcpParams = decodeMessage(mcpField[0].value as Uint8Array);
|
|
|
|
if (mcpParams.has(FIELD.McpParams.TOOLS_LIST)) {
|
|
const toolsList = mcpParams.get(FIELD.McpParams.TOOLS_LIST);
|
|
if (!toolsList || !toolsList[0]) return null;
|
|
|
|
const tool = decodeMessage(toolsList[0].value as Uint8Array);
|
|
|
|
if (tool.has(FIELD.McpNested.NAME)) {
|
|
const nestedName = tool.get(FIELD.McpNested.NAME);
|
|
if (nestedName && nestedName[0]) {
|
|
toolName = new TextDecoder().decode(nestedName[0].value as Uint8Array);
|
|
}
|
|
}
|
|
|
|
if (tool.has(FIELD.McpNested.PARAMS)) {
|
|
const nestedParams = tool.get(FIELD.McpNested.PARAMS);
|
|
if (nestedParams && nestedParams[0]) {
|
|
rawArgs = new TextDecoder().decode(nestedParams[0].value as Uint8Array);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (process.env.CCS_DEBUG) {
|
|
console.error('[cursor] extractToolCall MCP parsing failed:', err);
|
|
}
|
|
// MCP parse error, continue
|
|
}
|
|
}
|
|
|
|
// Fallback to raw_args
|
|
if (!rawArgs && toolCall.has(FIELD.ToolCall.RAW_ARGS)) {
|
|
const rawArgsField = toolCall.get(FIELD.ToolCall.RAW_ARGS);
|
|
if (rawArgsField && rawArgsField[0]) {
|
|
rawArgs = new TextDecoder().decode(rawArgsField[0].value as Uint8Array);
|
|
}
|
|
}
|
|
|
|
if (toolCallId && toolName) {
|
|
return {
|
|
id: toolCallId,
|
|
type: 'function',
|
|
function: {
|
|
name: toolName,
|
|
arguments: rawArgs || '{}',
|
|
},
|
|
isLast,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract text and thinking from response data
|
|
*/
|
|
function extractTextAndThinking(responseData: Uint8Array): {
|
|
text: string | null;
|
|
thinking: string | null;
|
|
} {
|
|
const nested = decodeMessage(responseData);
|
|
let text: string | null = null;
|
|
let thinking: string | null = null;
|
|
|
|
// Extract text
|
|
if (nested.has(FIELD.ChatResponse.TEXT)) {
|
|
const textField = nested.get(FIELD.ChatResponse.TEXT);
|
|
if (textField && textField[0]) {
|
|
text = new TextDecoder().decode(textField[0].value as Uint8Array);
|
|
}
|
|
}
|
|
|
|
// Extract thinking
|
|
if (nested.has(FIELD.ChatResponse.THINKING)) {
|
|
try {
|
|
const thinkingField = nested.get(FIELD.ChatResponse.THINKING);
|
|
if (thinkingField && thinkingField[0]) {
|
|
const thinkingMsg = decodeMessage(thinkingField[0].value as Uint8Array);
|
|
if (thinkingMsg.has(FIELD.Thinking.TEXT)) {
|
|
const thinkingTextField = thinkingMsg.get(FIELD.Thinking.TEXT);
|
|
if (thinkingTextField && thinkingTextField[0]) {
|
|
thinking = new TextDecoder().decode(thinkingTextField[0].value as Uint8Array);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (process.env.CCS_DEBUG) {
|
|
console.error('[cursor] extractTextAndThinking parsing failed:', err);
|
|
}
|
|
// Thinking parse error, continue
|
|
}
|
|
}
|
|
|
|
return { text, thinking };
|
|
}
|
|
|
|
/**
|
|
* Extract text and tool calls from response payload
|
|
*/
|
|
export function extractTextFromResponse(payload: Uint8Array): {
|
|
text: string | null;
|
|
error: string | null;
|
|
toolCall: {
|
|
id: string;
|
|
type: string;
|
|
function: { name: string; arguments: string };
|
|
isLast: boolean;
|
|
} | null;
|
|
thinking: string | null;
|
|
} {
|
|
try {
|
|
const fields = decodeMessage(payload);
|
|
|
|
// Field 1: ClientSideToolV2Call
|
|
if (fields.has(FIELD.Response.TOOL_CALL)) {
|
|
const toolCallField = fields.get(FIELD.Response.TOOL_CALL);
|
|
if (toolCallField && toolCallField[0]) {
|
|
const toolCall = extractToolCall(toolCallField[0].value as Uint8Array);
|
|
if (toolCall) {
|
|
return { text: null, error: null, toolCall, thinking: null };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Field 2: StreamUnifiedChatResponse
|
|
if (fields.has(FIELD.Response.RESPONSE)) {
|
|
const responseField = fields.get(FIELD.Response.RESPONSE);
|
|
if (responseField && responseField[0]) {
|
|
const { text, thinking } = extractTextAndThinking(responseField[0].value as Uint8Array);
|
|
|
|
if (text || thinking) {
|
|
return { text, error: null, toolCall: null, thinking };
|
|
}
|
|
}
|
|
}
|
|
|
|
if (payload.length > 0) {
|
|
return { text: null, error: 'Malformed protobuf response', toolCall: null, thinking: null };
|
|
}
|
|
|
|
return { text: null, error: null, toolCall: null, thinking: null };
|
|
} catch (err) {
|
|
if (process.env.CCS_DEBUG) {
|
|
console.error('[cursor] extractTextFromResponse parsing failed:', err);
|
|
}
|
|
return { text: null, error: 'Malformed protobuf response', toolCall: null, thinking: null };
|
|
}
|
|
}
|