feat(cursor): Integrate Cursor IDE support with OAuth import token flow
- Add CursorExecutor for handling requests to the Cursor API using protobuf over HTTP/2. - Implement CursorAuthModal for user token import from local SQLite database. - Update provider models and constants to include Cursor as a supported provider. - Enhance API service with token validation and user info extraction from Cursor tokens. - Introduce utility functions for checksum generation and protobuf encoding/decoding for Cursor API interactions.
This commit is contained in:
@@ -149,6 +149,18 @@ export const PROVIDERS = {
|
||||
// Kiro OAuth endpoints
|
||||
tokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
|
||||
authUrl: "https://prod.us-east-1.auth.desktop.kiro.dev"
|
||||
},
|
||||
cursor: {
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
chatPath: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
|
||||
format: "cursor",
|
||||
headers: {
|
||||
"connect-accept-encoding": "gzip",
|
||||
"connect-protocol-version": "1",
|
||||
"Content-Type": "application/connect+proto",
|
||||
"User-Agent": "connect-es/1.6.1"
|
||||
},
|
||||
clientVersion: "1.1.3"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -73,6 +73,18 @@ export const PROVIDER_MODELS = {
|
||||
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
cu: [ // Cursor IDE
|
||||
{ id: "default", name: "Default (Server Picks)" },
|
||||
{ id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" },
|
||||
{ id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" },
|
||||
{ id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" },
|
||||
{ id: "claude-4-sonnet", name: "Claude 4 Sonnet" },
|
||||
{ id: "gpt-4o", name: "GPT-4o" },
|
||||
{ id: "gpt-5.1-codex", name: "GPT 5.1 Codex" },
|
||||
{ id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet" },
|
||||
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
|
||||
{ id: "cursor-small", name: "Cursor Small" },
|
||||
],
|
||||
|
||||
// API Key Providers (alias = id)
|
||||
openai: [
|
||||
@@ -149,6 +161,7 @@ export const PROVIDER_ID_TO_ALIAS = {
|
||||
antigravity: "ag",
|
||||
github: "gh",
|
||||
kiro: "kr",
|
||||
cursor: "cu",
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
gemini: "gemini",
|
||||
|
||||
516
open-sse/executors/cursor.js
Normal file
516
open-sse/executors/cursor.js
Normal file
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* CursorExecutor - Executor for Cursor AI IDE
|
||||
* Uses ConnectRPC/protobuf protocol with HTTP/2 for streaming chat
|
||||
*/
|
||||
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/constants.js";
|
||||
import {
|
||||
generateCursorBody,
|
||||
parseConnectRPCFrame,
|
||||
extractTextFromResponse
|
||||
} from "../utils/cursorProtobuf.js";
|
||||
import crypto from "crypto";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import http2 from "http2";
|
||||
|
||||
export class CursorExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("cursor", PROVIDERS.cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL for Cursor API
|
||||
*/
|
||||
buildUrl() {
|
||||
return `${this.config.baseUrl}${this.config.chatPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Cursor checksum (jyh cipher) - timestamp integer version
|
||||
* This is the format that works with Cursor API
|
||||
*/
|
||||
generateChecksum(machineId) {
|
||||
// Use timestamp / 1e6 format (same as Python demo that works)
|
||||
const timestamp = Math.floor(Date.now() / 1000000);
|
||||
|
||||
// Create 6-byte big-endian array
|
||||
const byteArray = new Uint8Array([
|
||||
(timestamp >> 40) & 0xFF,
|
||||
(timestamp >> 32) & 0xFF,
|
||||
(timestamp >> 24) & 0xFF,
|
||||
(timestamp >> 16) & 0xFF,
|
||||
(timestamp >> 8) & 0xFF,
|
||||
timestamp & 0xFF
|
||||
]);
|
||||
|
||||
// Jyh cipher obfuscation
|
||||
let t = 165;
|
||||
for (let i = 0; i < byteArray.length; i++) {
|
||||
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xFF;
|
||||
t = byteArray[i];
|
||||
}
|
||||
|
||||
// URL-safe base64 encode (without padding)
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let encoded = "";
|
||||
|
||||
for (let i = 0; i < byteArray.length; i += 3) {
|
||||
const a = byteArray[i];
|
||||
const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0;
|
||||
const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0;
|
||||
|
||||
encoded += alphabet[a >> 2];
|
||||
encoded += alphabet[((a & 3) << 4) | (b >> 4)];
|
||||
|
||||
if (i + 1 < byteArray.length) {
|
||||
encoded += alphabet[((b & 15) << 2) | (c >> 6)];
|
||||
}
|
||||
if (i + 2 < byteArray.length) {
|
||||
encoded += alphabet[c & 63];
|
||||
}
|
||||
}
|
||||
|
||||
return `${encoded}${machineId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate client key from token
|
||||
*/
|
||||
generateClientKey(token) {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate session ID
|
||||
*/
|
||||
generateSessionId(token) {
|
||||
return uuidv5(token, uuidv5.DNS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers with Cursor checksum authentication
|
||||
*/
|
||||
buildHeaders(credentials) {
|
||||
const accessToken = credentials.accessToken;
|
||||
const machineId = credentials.providerSpecificData?.machineId;
|
||||
const ghostMode = credentials.providerSpecificData?.ghostMode !== false;
|
||||
|
||||
if (!machineId) {
|
||||
throw new Error("Machine ID is required for Cursor API");
|
||||
}
|
||||
|
||||
const cleanToken = accessToken.includes("::")
|
||||
? accessToken.split("::")[1]
|
||||
: accessToken;
|
||||
|
||||
return {
|
||||
"authorization": `Bearer ${cleanToken}`,
|
||||
"connect-accept-encoding": "gzip",
|
||||
"connect-protocol-version": "1",
|
||||
"content-type": "application/connect+proto",
|
||||
"user-agent": "connect-es/1.6.1",
|
||||
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
||||
"x-client-key": this.generateClientKey(cleanToken),
|
||||
"x-cursor-checksum": this.generateChecksum(machineId),
|
||||
"x-cursor-client-version": "2.3.41",
|
||||
"x-cursor-client-type": "ide",
|
||||
"x-cursor-client-os": process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux",
|
||||
"x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64",
|
||||
"x-cursor-client-device-type": "desktop",
|
||||
"x-cursor-config-version": crypto.randomUUID(),
|
||||
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
"x-ghost-mode": ghostMode ? "true" : "false",
|
||||
"x-request-id": crypto.randomUUID(),
|
||||
"x-session-id": this.generateSessionId(cleanToken),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI-format messages to Cursor format
|
||||
*/
|
||||
convertMessages(body) {
|
||||
const messages = body.messages || [];
|
||||
const result = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "system") {
|
||||
result.push({
|
||||
role: "user",
|
||||
content: `[System Instructions]\n${msg.content}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "user" || msg.role === "assistant") {
|
||||
let content = "";
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") {
|
||||
content += part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
result.push({ role: msg.role, content });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP/2 request to Cursor API
|
||||
*/
|
||||
makeHttp2Request(url, headers, body, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const client = http2.connect(`https://${urlObj.host}`);
|
||||
|
||||
const chunks = [];
|
||||
let responseHeaders = {};
|
||||
|
||||
client.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
const req = client.request({
|
||||
":method": "POST",
|
||||
":path": urlObj.pathname,
|
||||
":authority": urlObj.host,
|
||||
":scheme": "https",
|
||||
...headers
|
||||
});
|
||||
|
||||
req.on("response", (hdrs) => {
|
||||
responseHeaders = hdrs;
|
||||
});
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
client.close();
|
||||
const data = Buffer.concat(chunks);
|
||||
resolve({
|
||||
status: responseHeaders[":status"],
|
||||
headers: responseHeaders,
|
||||
body: data
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
client.close();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
req.close();
|
||||
client.close();
|
||||
reject(new Error("Request aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom execute for Cursor - handles protobuf binary protocol with HTTP/2
|
||||
*/
|
||||
async execute({ model, body, stream, credentials, signal, log }) {
|
||||
const url = this.buildUrl();
|
||||
const headers = this.buildHeaders(credentials);
|
||||
|
||||
// Convert messages and build protobuf body
|
||||
const messages = this.convertMessages(body);
|
||||
const cursorBody = generateCursorBody(messages, model);
|
||||
|
||||
log?.debug?.("CURSOR", `Sending ${messages.length} messages to ${model}, stream=${stream}`);
|
||||
|
||||
try {
|
||||
// Use HTTP/2 for Cursor API (required)
|
||||
const response = await this.makeHttp2Request(url, headers, cursorBody, signal);
|
||||
|
||||
if (response.status !== 200) {
|
||||
// Create error response
|
||||
const errorResponse = new Response(JSON.stringify({
|
||||
error: {
|
||||
message: `[${response.status}]: ${response.body.toString() || "Unknown error"}`,
|
||||
type: "invalid_request_error",
|
||||
code: ""
|
||||
}
|
||||
}), {
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
return { response: errorResponse, url, headers, transformedBody: body };
|
||||
}
|
||||
|
||||
// Transform based on stream parameter
|
||||
const transformedResponse = stream !== false
|
||||
? this.transformProtobufToSSE(response.body, model)
|
||||
: this.transformProtobufToJSON(response.body, model);
|
||||
|
||||
return { response: transformedResponse, url, headers, transformedBody: body };
|
||||
} catch (error) {
|
||||
log?.error?.("CURSOR", `Request failed: ${error.message}`);
|
||||
const errorResponse = new Response(JSON.stringify({
|
||||
error: {
|
||||
message: error.message,
|
||||
type: "connection_error",
|
||||
code: ""
|
||||
}
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
return { response: errorResponse, url, headers, transformedBody: body };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform ConnectRPC protobuf buffer to JSON Response (non-streaming)
|
||||
*/
|
||||
transformProtobufToJSON(buffer, model) {
|
||||
const responseId = `chatcmpl-cursor-${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Parse all frames and collect content
|
||||
let offset = 0;
|
||||
let totalContent = "";
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) break;
|
||||
|
||||
const flags = buffer[offset];
|
||||
const length = buffer.readUInt32BE(offset + 1);
|
||||
|
||||
if (offset + 5 + length > buffer.length) break;
|
||||
|
||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||
offset += 5 + length;
|
||||
|
||||
// Decompress if gzip (flags 0x01 or 0x03)
|
||||
if (flags === 0x01 || flags === 0x03) {
|
||||
try {
|
||||
const zlib = require("zlib");
|
||||
payload = zlib.gunzipSync(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if payload is JSON error (ConnectRPC error format)
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.startsWith("{") && text.includes('"error"')) {
|
||||
const jsonError = JSON.parse(text);
|
||||
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|
||||
|| jsonError?.error?.details?.[0]?.debug?.details?.detail
|
||||
|| jsonError?.error?.message
|
||||
|| "API Error";
|
||||
return new Response(JSON.stringify({
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: jsonError?.error?.code === "resource_exhausted" ? "rate_limit_error" : "api_error",
|
||||
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown"
|
||||
}
|
||||
}), {
|
||||
status: jsonError?.error?.code === "resource_exhausted" ? 429 : 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Extract text or error from protobuf
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
|
||||
if (result.error) {
|
||||
// Return error response
|
||||
return new Response(JSON.stringify({
|
||||
error: {
|
||||
message: result.error,
|
||||
type: "rate_limit_error",
|
||||
code: "rate_limited"
|
||||
}
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (result.text) {
|
||||
totalContent += result.text;
|
||||
}
|
||||
}
|
||||
|
||||
// Build non-streaming response
|
||||
const estimatedPromptTokens = 10;
|
||||
const estimatedCompletionTokens = Math.max(1, Math.floor(totalContent.length / 4));
|
||||
|
||||
const completion = {
|
||||
id: responseId,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: totalContent
|
||||
},
|
||||
finish_reason: "stop"
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: estimatedPromptTokens,
|
||||
completion_tokens: estimatedCompletionTokens,
|
||||
total_tokens: estimatedPromptTokens + estimatedCompletionTokens
|
||||
}
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(completion), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform ConnectRPC protobuf buffer to SSE Response
|
||||
*/
|
||||
transformProtobufToSSE(buffer, model) {
|
||||
const responseId = `chatcmpl-cursor-${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Parse all frames from buffer
|
||||
const chunks = [];
|
||||
let offset = 0;
|
||||
let totalContent = "";
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) break;
|
||||
|
||||
const flags = buffer[offset];
|
||||
const length = buffer.readUInt32BE(offset + 1);
|
||||
|
||||
if (offset + 5 + length > buffer.length) break;
|
||||
|
||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||
offset += 5 + length;
|
||||
|
||||
// Decompress if gzip (flags 0x01 or 0x03)
|
||||
if (flags === 0x01 || flags === 0x03) {
|
||||
try {
|
||||
const zlib = require("zlib");
|
||||
payload = zlib.gunzipSync(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if payload is JSON error (ConnectRPC error format)
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.startsWith("{") && text.includes('"error"')) {
|
||||
const jsonError = JSON.parse(text);
|
||||
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|
||||
|| jsonError?.error?.details?.[0]?.debug?.details?.detail
|
||||
|| jsonError?.error?.message
|
||||
|| "API Error";
|
||||
return new Response(JSON.stringify({
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: jsonError?.error?.code === "resource_exhausted" ? "rate_limit_error" : "api_error",
|
||||
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown"
|
||||
}
|
||||
}), {
|
||||
status: jsonError?.error?.code === "resource_exhausted" ? 429 : 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Extract text or error from protobuf
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
|
||||
if (result.error) {
|
||||
// Return error response
|
||||
return new Response(JSON.stringify({
|
||||
error: {
|
||||
message: result.error,
|
||||
type: "rate_limit_error",
|
||||
code: "rate_limited"
|
||||
}
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (result.text) {
|
||||
totalContent += result.text;
|
||||
const chunk = {
|
||||
id: responseId,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: chunks.length === 0
|
||||
? { role: "assistant", content: result.text }
|
||||
: { content: result.text },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
chunks.push(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add finish chunk
|
||||
const estimatedTokens = Math.max(1, Math.floor(totalContent.length / 4));
|
||||
const finishChunk = {
|
||||
id: responseId,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: "stop"
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: estimatedTokens,
|
||||
total_tokens: estimatedTokens
|
||||
}
|
||||
};
|
||||
chunks.push(`data: ${JSON.stringify(finishChunk)}\n\n`);
|
||||
chunks.push("data: [DONE]\n\n");
|
||||
|
||||
return new Response(chunks.join(""), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor doesn't support standard OAuth refresh
|
||||
*/
|
||||
async refreshCredentials() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default CursorExecutor;
|
||||
@@ -3,6 +3,7 @@ import { GeminiCLIExecutor } from "./gemini-cli.js";
|
||||
import { GithubExecutor } from "./github.js";
|
||||
import { KiroExecutor } from "./kiro.js";
|
||||
import { CodexExecutor } from "./codex.js";
|
||||
import { CursorExecutor } from "./cursor.js";
|
||||
import { DefaultExecutor } from "./default.js";
|
||||
|
||||
const executors = {
|
||||
@@ -10,7 +11,9 @@ const executors = {
|
||||
"gemini-cli": new GeminiCLIExecutor(),
|
||||
github: new GithubExecutor(),
|
||||
kiro: new KiroExecutor(),
|
||||
codex: new CodexExecutor()
|
||||
codex: new CodexExecutor(),
|
||||
cursor: new CursorExecutor(),
|
||||
cu: new CursorExecutor() // Alias for cursor
|
||||
};
|
||||
|
||||
const defaultCache = new Map();
|
||||
@@ -31,4 +34,5 @@ export { GeminiCLIExecutor } from "./gemini-cli.js";
|
||||
export { GithubExecutor } from "./github.js";
|
||||
export { KiroExecutor } from "./kiro.js";
|
||||
export { CodexExecutor } from "./codex.js";
|
||||
export { CursorExecutor } from "./cursor.js";
|
||||
export { DefaultExecutor } from "./default.js";
|
||||
|
||||
@@ -8,11 +8,13 @@ const ALIAS_TO_PROVIDER_ID = {
|
||||
ag: "antigravity",
|
||||
gh: "github",
|
||||
kr: "kiro",
|
||||
cu: "cursor",
|
||||
// API Key providers (alias = id)
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
gemini: "gemini",
|
||||
openrouter: "openrouter",
|
||||
cursor: "cursor",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
133
open-sse/utils/cursorChecksum.js
Normal file
133
open-sse/utils/cursorChecksum.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Cursor Checksum Utility (Jyh Cipher)
|
||||
*
|
||||
* Generates the x-cursor-checksum header required for Cursor API authentication.
|
||||
* Based on the JavaScript implementation from Cursor IDE.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
/**
|
||||
* Generate SHA-256 hash like generateHashed64Hex
|
||||
* @param {string} input - Input string
|
||||
* @param {string} salt - Optional salt
|
||||
* @returns {string} - 64-character hex string
|
||||
*/
|
||||
export function generateHashed64Hex(input, salt = "") {
|
||||
return crypto.createHash("sha256").update(input + salt).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate session ID using UUID v5 with DNS namespace
|
||||
* @param {string} authToken - Auth token
|
||||
* @returns {string} - UUID string
|
||||
*/
|
||||
export function generateSessionId(authToken) {
|
||||
return uuidv5(authToken, uuidv5.DNS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cursor checksum (Jyh cipher)
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Get Unix timestamp in specific format
|
||||
* 2. XOR each byte with key (starting 165)
|
||||
* 3. Update key: key = (key + byte) & 0xFF
|
||||
* 4. URL-safe base64 encode
|
||||
* 5. Format: {base64_encoded}{machineId}
|
||||
*
|
||||
* @param {string} machineId - Machine ID from Cursor storage or generated
|
||||
* @returns {string} - Checksum string
|
||||
*/
|
||||
export function generateCursorChecksum(machineId) {
|
||||
// Math.floor(Date.now() / 1e6) - same as Python implementation
|
||||
const timestamp = Math.floor(Date.now() / 1000000);
|
||||
|
||||
// Create byte array from timestamp (6 bytes, big-endian)
|
||||
const byteArray = new Uint8Array([
|
||||
(timestamp >> 40) & 0xFF,
|
||||
(timestamp >> 32) & 0xFF,
|
||||
(timestamp >> 24) & 0xFF,
|
||||
(timestamp >> 16) & 0xFF,
|
||||
(timestamp >> 8) & 0xFF,
|
||||
timestamp & 0xFF
|
||||
]);
|
||||
|
||||
// Jyh cipher obfuscation
|
||||
let t = 165;
|
||||
for (let i = 0; i < byteArray.length; i++) {
|
||||
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xFF;
|
||||
t = byteArray[i];
|
||||
}
|
||||
|
||||
// URL-safe base64 encode (without padding)
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let encoded = "";
|
||||
|
||||
for (let i = 0; i < byteArray.length; i += 3) {
|
||||
const a = byteArray[i];
|
||||
const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0;
|
||||
const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0;
|
||||
|
||||
encoded += alphabet[a >> 2];
|
||||
encoded += alphabet[((a & 3) << 4) | (b >> 4)];
|
||||
|
||||
if (i + 1 < byteArray.length) {
|
||||
encoded += alphabet[((b & 15) << 2) | (c >> 6)];
|
||||
}
|
||||
if (i + 2 < byteArray.length) {
|
||||
encoded += alphabet[c & 63];
|
||||
}
|
||||
}
|
||||
|
||||
return `${encoded}${machineId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build all Cursor API headers
|
||||
*
|
||||
* @param {string} accessToken - Bearer token
|
||||
* @param {string} machineId - Machine ID (or will be generated from token)
|
||||
* @param {boolean} ghostMode - Enable ghost mode (privacy)
|
||||
* @returns {Object} - Headers object
|
||||
*/
|
||||
export function buildCursorHeaders(accessToken, machineId = null, ghostMode = true) {
|
||||
// Clean token if it has prefix
|
||||
const cleanToken = accessToken.includes("::")
|
||||
? accessToken.split("::")[1]
|
||||
: accessToken;
|
||||
|
||||
// Generate machine ID if not provided
|
||||
const effectiveMachineId = machineId || generateHashed64Hex(cleanToken, "machineId");
|
||||
|
||||
// Generate derived values
|
||||
const sessionId = generateSessionId(cleanToken);
|
||||
const clientKey = generateHashed64Hex(cleanToken);
|
||||
const checksum = generateCursorChecksum(effectiveMachineId);
|
||||
|
||||
return {
|
||||
"Authorization": `Bearer ${cleanToken}`,
|
||||
"connect-accept-encoding": "gzip",
|
||||
"connect-protocol-version": "1",
|
||||
"Content-Type": "application/connect+proto",
|
||||
"User-Agent": "connect-es/1.6.1",
|
||||
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
||||
"x-client-key": clientKey,
|
||||
"x-cursor-checksum": checksum,
|
||||
"x-cursor-client-version": "1.1.3",
|
||||
"x-cursor-config-version": crypto.randomUUID(),
|
||||
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
"x-ghost-mode": ghostMode ? "true" : "false",
|
||||
"x-request-id": crypto.randomUUID(),
|
||||
"x-session-id": sessionId,
|
||||
"Host": "api2.cursor.sh"
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
generateCursorChecksum,
|
||||
buildCursorHeaders,
|
||||
generateHashed64Hex,
|
||||
generateSessionId
|
||||
};
|
||||
539
open-sse/utils/cursorProtobuf.js
Normal file
539
open-sse/utils/cursorProtobuf.js
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Cursor Protobuf Encoding/Decoding Utility
|
||||
*
|
||||
* Implements protobuf wire format encoding for Cursor API requests
|
||||
* and decoding for streaming responses.
|
||||
*
|
||||
* Wire format reference:
|
||||
* - Wire type 0: Varint (int32, int64, uint32, uint64, bool, enum)
|
||||
* - Wire type 2: Length-delimited (string, bytes, embedded messages)
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import zlib from "zlib";
|
||||
|
||||
// =============================================================================
|
||||
// Encoding Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Encode an integer as a varint
|
||||
* @param {number} value - Integer to encode
|
||||
* @returns {Uint8Array} - Encoded bytes
|
||||
*/
|
||||
export function encodeVarint(value) {
|
||||
const bytes = [];
|
||||
while (value >= 0x80) {
|
||||
bytes.push((value & 0x7F) | 0x80);
|
||||
value >>>= 7;
|
||||
}
|
||||
bytes.push(value & 0x7F);
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a protobuf field
|
||||
* @param {number} fieldNum - Field number
|
||||
* @param {number} wireType - Wire type (0=varint, 2=length-delimited)
|
||||
* @param {*} value - Value to encode
|
||||
* @returns {Uint8Array} - Encoded bytes
|
||||
*/
|
||||
export function encodeField(fieldNum, wireType, value) {
|
||||
const tag = (fieldNum << 3) | wireType;
|
||||
const tagBytes = encodeVarint(tag);
|
||||
|
||||
if (wireType === 0) {
|
||||
// Varint
|
||||
const valueBytes = encodeVarint(value);
|
||||
const result = new Uint8Array(tagBytes.length + valueBytes.length);
|
||||
result.set(tagBytes);
|
||||
result.set(valueBytes, tagBytes.length);
|
||||
return result;
|
||||
} else if (wireType === 2) {
|
||||
// Length-delimited (string, bytes, nested message)
|
||||
let dataBytes;
|
||||
if (typeof value === "string") {
|
||||
dataBytes = new TextEncoder().encode(value);
|
||||
} else if (value instanceof Uint8Array) {
|
||||
dataBytes = value;
|
||||
} else if (Buffer.isBuffer(value)) {
|
||||
dataBytes = new Uint8Array(value);
|
||||
} else {
|
||||
dataBytes = new Uint8Array(0);
|
||||
}
|
||||
|
||||
const lengthBytes = encodeVarint(dataBytes.length);
|
||||
const result = new Uint8Array(tagBytes.length + lengthBytes.length + dataBytes.length);
|
||||
result.set(tagBytes);
|
||||
result.set(lengthBytes, tagBytes.length);
|
||||
result.set(dataBytes, tagBytes.length + lengthBytes.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate multiple Uint8Arrays
|
||||
* @param {...Uint8Array} arrays - Arrays to concatenate
|
||||
* @returns {Uint8Array} - Concatenated array
|
||||
*/
|
||||
function concatArrays(...arrays) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a Message (conversation message)
|
||||
*
|
||||
* Schema:
|
||||
* string content = 1;
|
||||
* int32 role = 2;
|
||||
* string messageId = 13;
|
||||
* int32 chatModeEnum = 47; (only for user)
|
||||
*/
|
||||
export function encodeMessage(content, role, messageId, chatModeEnum = null) {
|
||||
const parts = [];
|
||||
|
||||
// Field 1: content (string)
|
||||
parts.push(encodeField(1, 2, content));
|
||||
|
||||
// Field 2: role (int32) - 1=user, 2=assistant
|
||||
parts.push(encodeField(2, 0, role));
|
||||
|
||||
// Field 13: messageId (string)
|
||||
parts.push(encodeField(13, 2, messageId));
|
||||
|
||||
// Field 47: chatModeEnum (only for user messages)
|
||||
if (chatModeEnum !== null) {
|
||||
parts.push(encodeField(47, 0, chatModeEnum));
|
||||
}
|
||||
|
||||
return concatArrays(...parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode Instruction message
|
||||
* Schema: string instruction = 1;
|
||||
*/
|
||||
export function encodeInstruction(instructionText) {
|
||||
if (!instructionText) return new Uint8Array(0);
|
||||
return encodeField(1, 2, instructionText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode Model message
|
||||
* Schema:
|
||||
* string name = 1;
|
||||
* bytes empty = 4;
|
||||
*/
|
||||
export function encodeModel(modelName) {
|
||||
return concatArrays(
|
||||
encodeField(1, 2, modelName),
|
||||
encodeField(4, 2, new Uint8Array(0))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode CursorSetting message
|
||||
*/
|
||||
export function encodeCursorSetting() {
|
||||
// Unknown6 nested message
|
||||
const unknown6 = concatArrays(
|
||||
encodeField(1, 2, new Uint8Array(0)),
|
||||
encodeField(2, 2, new Uint8Array(0))
|
||||
);
|
||||
|
||||
return concatArrays(
|
||||
encodeField(1, 2, "cursor\\aisettings"),
|
||||
encodeField(3, 2, new Uint8Array(0)),
|
||||
encodeField(6, 2, unknown6),
|
||||
encodeField(8, 0, 1),
|
||||
encodeField(9, 0, 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode Metadata message
|
||||
*/
|
||||
export function encodeMetadata() {
|
||||
return concatArrays(
|
||||
encodeField(1, 2, process.platform || "linux"),
|
||||
encodeField(2, 2, process.arch || "x64"),
|
||||
encodeField(3, 2, process.version || "v20.0.0"),
|
||||
encodeField(4, 2, process.cwd?.() || "/"),
|
||||
encodeField(5, 2, new Date().toISOString())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode MessageId message
|
||||
*/
|
||||
export function encodeMessageId(messageId, role, summaryId = null) {
|
||||
const parts = [
|
||||
encodeField(1, 2, messageId),
|
||||
];
|
||||
|
||||
if (summaryId) {
|
||||
parts.push(encodeField(2, 2, summaryId));
|
||||
}
|
||||
|
||||
parts.push(encodeField(3, 0, role));
|
||||
|
||||
return concatArrays(...parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the Request message (inner request)
|
||||
*/
|
||||
export function encodeRequest(messages, modelName) {
|
||||
const parts = [];
|
||||
const formattedMessages = [];
|
||||
const messageIds = [];
|
||||
|
||||
// Format messages
|
||||
for (const msg of messages) {
|
||||
const role = msg.role === "user" ? 1 : 2;
|
||||
const msgId = uuidv4();
|
||||
|
||||
formattedMessages.push({
|
||||
content: msg.content,
|
||||
role,
|
||||
messageId: msgId,
|
||||
chatModeEnum: role === 1 ? 1 : null // Only for user messages
|
||||
});
|
||||
|
||||
messageIds.push({ messageId: msgId, role });
|
||||
}
|
||||
|
||||
// Field 1: repeated Message messages
|
||||
for (const fm of formattedMessages) {
|
||||
const messageBytes = encodeMessage(fm.content, fm.role, fm.messageId, fm.chatModeEnum);
|
||||
parts.push(encodeField(1, 2, messageBytes));
|
||||
}
|
||||
|
||||
// Field 2: unknown2 = 1
|
||||
parts.push(encodeField(2, 0, 1));
|
||||
|
||||
// Field 3: Instruction
|
||||
parts.push(encodeField(3, 2, encodeInstruction("")));
|
||||
|
||||
// Field 4: unknown4 = 1
|
||||
parts.push(encodeField(4, 0, 1));
|
||||
|
||||
// Field 5: Model - always send, even for "default"
|
||||
if (modelName) {
|
||||
parts.push(encodeField(5, 2, encodeModel(modelName)));
|
||||
}
|
||||
|
||||
// Field 8: webTool = ""
|
||||
parts.push(encodeField(8, 2, ""));
|
||||
|
||||
// Field 13: unknown13 = 1
|
||||
parts.push(encodeField(13, 0, 1));
|
||||
|
||||
// Field 15: CursorSetting
|
||||
parts.push(encodeField(15, 2, encodeCursorSetting()));
|
||||
|
||||
// Field 19: unknown19 = 1
|
||||
parts.push(encodeField(19, 0, 1));
|
||||
|
||||
// Field 23: conversationId
|
||||
parts.push(encodeField(23, 2, uuidv4()));
|
||||
|
||||
// Field 26: Metadata
|
||||
parts.push(encodeField(26, 2, encodeMetadata()));
|
||||
|
||||
// Field 27: unknown27 = 0
|
||||
parts.push(encodeField(27, 0, 0));
|
||||
|
||||
// Field 30: repeated MessageId
|
||||
for (const mid of messageIds) {
|
||||
parts.push(encodeField(30, 2, encodeMessageId(mid.messageId, mid.role)));
|
||||
}
|
||||
|
||||
// Field 35: largeContext = 0
|
||||
parts.push(encodeField(35, 0, 0));
|
||||
|
||||
// Field 38: unknown38 = 0
|
||||
parts.push(encodeField(38, 0, 0));
|
||||
|
||||
// Field 46: chatModeEnum = 1
|
||||
parts.push(encodeField(46, 0, 1));
|
||||
|
||||
// Field 47: unknown47 = ""
|
||||
parts.push(encodeField(47, 2, ""));
|
||||
|
||||
// Field 48-51, 53
|
||||
parts.push(encodeField(48, 0, 0));
|
||||
parts.push(encodeField(49, 0, 0));
|
||||
parts.push(encodeField(51, 0, 0));
|
||||
parts.push(encodeField(53, 0, 1));
|
||||
|
||||
// Field 54: chatMode = "Ask"
|
||||
parts.push(encodeField(54, 2, "Ask"));
|
||||
|
||||
return concatArrays(...parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full StreamUnifiedChatWithToolsRequest
|
||||
*/
|
||||
export function buildChatRequest(messages, modelName) {
|
||||
// Field 1: Request request
|
||||
const requestBytes = encodeRequest(messages, modelName);
|
||||
return encodeField(1, 2, requestBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap payload with ConnectRPC frame header
|
||||
*
|
||||
* Frame format: [flags:1][length:4][payload]
|
||||
* - flags: 0x00 = uncompressed, 0x01 = gzip compressed
|
||||
* - length: big-endian 32-bit length
|
||||
*
|
||||
* @param {Uint8Array} payload - Protobuf payload
|
||||
* @param {boolean} compress - Whether to gzip compress (for messages >= 3)
|
||||
* @returns {Uint8Array} - Framed data
|
||||
*/
|
||||
export function wrapConnectRPCFrame(payload, compress = false) {
|
||||
let finalPayload = payload;
|
||||
let flags = 0x00;
|
||||
|
||||
if (compress) {
|
||||
finalPayload = new Uint8Array(zlib.gzipSync(Buffer.from(payload)));
|
||||
flags = 0x01;
|
||||
}
|
||||
|
||||
// Create frame: [flags:1][length:4][payload]
|
||||
const frame = new Uint8Array(5 + finalPayload.length);
|
||||
frame[0] = flags;
|
||||
|
||||
// Big-endian length
|
||||
const length = finalPayload.length;
|
||||
frame[1] = (length >> 24) & 0xFF;
|
||||
frame[2] = (length >> 16) & 0xFF;
|
||||
frame[3] = (length >> 8) & 0xFF;
|
||||
frame[4] = length & 0xFF;
|
||||
|
||||
frame.set(finalPayload, 5);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete Cursor request body
|
||||
* @param {Array} messages - Array of {role, content} messages
|
||||
* @param {string} modelName - Model name
|
||||
* @returns {Uint8Array} - Complete request body
|
||||
*/
|
||||
export function generateCursorBody(messages, modelName) {
|
||||
const protobuf = buildChatRequest(messages, modelName);
|
||||
|
||||
// Compress if >= 3 messages
|
||||
const shouldCompress = messages.length >= 3;
|
||||
return wrapConnectRPCFrame(protobuf, shouldCompress);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Decoding Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Decode a varint from buffer
|
||||
* @param {Uint8Array} buffer - Input buffer
|
||||
* @param {number} offset - Start offset
|
||||
* @returns {[number, number]} - [value, newOffset]
|
||||
*/
|
||||
export function decodeVarint(buffer, offset) {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
let pos = offset;
|
||||
|
||||
while (pos < buffer.length) {
|
||||
const b = buffer[pos];
|
||||
result |= (b & 0x7F) << shift;
|
||||
pos++;
|
||||
if (!(b & 0x80)) break;
|
||||
shift += 7;
|
||||
}
|
||||
|
||||
return [result, pos];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a single protobuf field
|
||||
* @param {Uint8Array} buffer - Input buffer
|
||||
* @param {number} offset - Start offset
|
||||
* @returns {[number, number, any, number]} - [fieldNum, wireType, value, newOffset]
|
||||
*/
|
||||
export function decodeField(buffer, offset) {
|
||||
if (offset >= buffer.length) {
|
||||
return [null, null, null, offset];
|
||||
}
|
||||
|
||||
const [tag, pos1] = decodeVarint(buffer, offset);
|
||||
const fieldNum = tag >> 3;
|
||||
const wireType = tag & 0x07;
|
||||
|
||||
let value;
|
||||
let pos = pos1;
|
||||
|
||||
if (wireType === 0) {
|
||||
// Varint
|
||||
[value, pos] = decodeVarint(buffer, pos);
|
||||
} else if (wireType === 2) {
|
||||
// Length-delimited
|
||||
const [length, pos2] = decodeVarint(buffer, pos);
|
||||
value = buffer.slice(pos2, pos2 + length);
|
||||
pos = pos2 + length;
|
||||
} else if (wireType === 1) {
|
||||
// Fixed64
|
||||
value = buffer.slice(pos, pos + 8);
|
||||
pos += 8;
|
||||
} else if (wireType === 5) {
|
||||
// Fixed32
|
||||
value = buffer.slice(pos, pos + 4);
|
||||
pos += 4;
|
||||
} else {
|
||||
value = null;
|
||||
}
|
||||
|
||||
return [fieldNum, wireType, value, pos];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode all fields from a protobuf message
|
||||
* @param {Uint8Array} data - Protobuf data
|
||||
* @returns {Map<number, Array>} - Map of fieldNum -> [{wireType, value}]
|
||||
*/
|
||||
export function decodeMessage(data) {
|
||||
const fields = new Map();
|
||||
let pos = 0;
|
||||
|
||||
while (pos < data.length) {
|
||||
const [fieldNum, wireType, value, newPos] = decodeField(data, pos);
|
||||
if (fieldNum === null) break;
|
||||
|
||||
if (!fields.has(fieldNum)) {
|
||||
fields.set(fieldNum, []);
|
||||
}
|
||||
fields.get(fieldNum).push({ wireType, value });
|
||||
pos = newPos;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ConnectRPC frame
|
||||
* @param {Uint8Array} buffer - Input buffer
|
||||
* @returns {{flags: number, length: number, payload: Uint8Array, consumed: number} | null}
|
||||
*/
|
||||
export function parseConnectRPCFrame(buffer) {
|
||||
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 flag is set
|
||||
if (flags === 0x01) {
|
||||
try {
|
||||
payload = new Uint8Array(zlib.gunzipSync(Buffer.from(payload)));
|
||||
} catch {
|
||||
// Decompression failed, return raw
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
flags,
|
||||
length,
|
||||
payload,
|
||||
consumed: 5 + length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content or error from response protobuf
|
||||
*
|
||||
* Response structure (from cursor-grpc/server_full.proto):
|
||||
*
|
||||
* message StreamUnifiedChatResponseWithTools {
|
||||
* oneof response {
|
||||
* ClientSideToolV2Call client_side_tool_v2_call = 1;
|
||||
* StreamUnifiedChatResponse stream_unified_chat_response = 2;
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* message StreamUnifiedChatResponse {
|
||||
* string text = 1; // <-- THE TEXT WE NEED
|
||||
* }
|
||||
*
|
||||
* @param {Uint8Array} payload - Decoded protobuf payload
|
||||
* @returns {{text: string|null, error: string|null}} - Extracted content
|
||||
*/
|
||||
export function extractTextFromResponse(payload) {
|
||||
try {
|
||||
const fields = decodeMessage(payload);
|
||||
|
||||
// Field 2 = StreamUnifiedChatResponse (contains the text)
|
||||
if (fields.has(2)) {
|
||||
for (const { wireType, value } of fields.get(2)) {
|
||||
if (wireType === 2) {
|
||||
// Decode nested StreamUnifiedChatResponse
|
||||
try {
|
||||
const nested = decodeMessage(value);
|
||||
|
||||
// Field 1 = text (string)
|
||||
if (nested.has(1)) {
|
||||
for (const { wireType: nwt, value: nv } of nested.get(1)) {
|
||||
if (nwt === 2) {
|
||||
try {
|
||||
const text = new TextDecoder().decode(nv);
|
||||
// Return any non-empty text
|
||||
if (text && text.length > 0) {
|
||||
return { text, error: null };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Field 1 could be ClientSideToolV2Call (skip for now)
|
||||
// Field 3 could be ConversationSummary (skip for now)
|
||||
|
||||
return { text: null, error: null };
|
||||
} catch {
|
||||
return { text: null, error: null };
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
// Encoding
|
||||
encodeVarint,
|
||||
encodeField,
|
||||
encodeMessage,
|
||||
buildChatRequest,
|
||||
wrapConnectRPCFrame,
|
||||
generateCursorBody,
|
||||
|
||||
// Decoding
|
||||
decodeVarint,
|
||||
decodeField,
|
||||
decodeMessage,
|
||||
parseConnectRPCFrame,
|
||||
extractTextFromResponse
|
||||
};
|
||||
Reference in New Issue
Block a user