@x-otto/plugin-cursor 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/otto-plugin.json +27 -0
- package/package.json +30 -0
- package/plugin-dist/meta.json +1 -0
- package/plugin-dist/plugin.cjs +53237 -0
- package/plugin.ts +15 -0
- package/src/auth/cursor-oauth.ts +100 -0
- package/src/auth/errors.ts +36 -0
- package/src/auth/local-credentials.ts +74 -0
- package/src/connect-rpc/agent-decode.ts +270 -0
- package/src/connect-rpc/agent-request.ts +53 -0
- package/src/connect-rpc/agent-stream.ts +101 -0
- package/src/connect-rpc/headers.ts +94 -0
- package/src/connect-rpc/protobuf.ts +46 -0
- package/src/cursor-provider.ts +144 -0
package/plugin.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { definePlugin } from '@x-otto/plugin'
|
|
2
|
+
|
|
3
|
+
import { CursorOAuthProvider } from './src/auth/cursor-oauth'
|
|
4
|
+
import { createCursorProvider } from './src/cursor-provider'
|
|
5
|
+
|
|
6
|
+
export default definePlugin((ctx) => ({
|
|
7
|
+
oauthProviderFactories: {
|
|
8
|
+
cursor: () => new CursorOAuthProvider(),
|
|
9
|
+
},
|
|
10
|
+
providerFactories: {
|
|
11
|
+
// 凭据解析统一走宿主 AuthStore(ctx.resolveProviderAuth),对齐 plugin-anthropic/
|
|
12
|
+
// plugin-github-copilot 的既有模式——不在插件内自行解析 auth.json(终局 review 修复)。
|
|
13
|
+
cursor: () => createCursorProvider(() => ctx.resolveProviderAuth('cursor').then((a) => a ?? undefined)),
|
|
14
|
+
},
|
|
15
|
+
}))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
OAuthCredentials,
|
|
3
|
+
OAuthLoginOptions,
|
|
4
|
+
OAuthProvider,
|
|
5
|
+
OAuthRefreshTokenOptions,
|
|
6
|
+
} from '@x-otto/provider'
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CURSOR_TOKEN_EXPIRED,
|
|
10
|
+
CursorAuthError,
|
|
11
|
+
cursorNotLoggedIn,
|
|
12
|
+
} from './errors'
|
|
13
|
+
import { normalizeAccessToken, readLocalAuth } from './local-credentials'
|
|
14
|
+
|
|
15
|
+
const CURSOR_API_BASE = process.env['CURSOR_API_BASE'] ?? 'https://api2.cursor.sh'
|
|
16
|
+
const CURSOR_AUTH_CLIENT_ID =
|
|
17
|
+
process.env['CURSOR_AUTH_CLIENT_ID'] ?? 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB'
|
|
18
|
+
const DEFAULT_ACCESS_TTL_MS = 55 * 60 * 1000
|
|
19
|
+
|
|
20
|
+
type RefreshResponse = {
|
|
21
|
+
access_token?: string
|
|
22
|
+
refresh_token?: string
|
|
23
|
+
expires_in?: number
|
|
24
|
+
error?: string
|
|
25
|
+
error_description?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class CursorOAuthProvider implements OAuthProvider {
|
|
29
|
+
readonly id = 'cursor'
|
|
30
|
+
readonly name = 'Cursor'
|
|
31
|
+
readonly subscriptionScoped = true
|
|
32
|
+
|
|
33
|
+
async login(options: OAuthLoginOptions): Promise<OAuthCredentials> {
|
|
34
|
+
options.onProgress?.('Reading Cursor credentials from local installation (macOS)...')
|
|
35
|
+
const local = readLocalAuth()
|
|
36
|
+
if (!local.accessToken) {
|
|
37
|
+
throw cursorNotLoggedIn()
|
|
38
|
+
}
|
|
39
|
+
const access = normalizeAccessToken(local.accessToken)
|
|
40
|
+
const refresh = local.refreshToken ? normalizeAccessToken(local.refreshToken) : ''
|
|
41
|
+
options.onProgress?.(
|
|
42
|
+
local.email
|
|
43
|
+
? `Imported Cursor session for ${local.email}.`
|
|
44
|
+
: 'Imported Cursor session from local storage.',
|
|
45
|
+
)
|
|
46
|
+
return {
|
|
47
|
+
access,
|
|
48
|
+
refresh,
|
|
49
|
+
expires: Date.now() + DEFAULT_ACCESS_TTL_MS,
|
|
50
|
+
machineId: local.machineId ?? undefined,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async refreshToken(options: OAuthRefreshTokenOptions): Promise<OAuthCredentials> {
|
|
55
|
+
const refreshToken = options.refresh?.trim()
|
|
56
|
+
if (!refreshToken) {
|
|
57
|
+
throw new CursorAuthError(
|
|
58
|
+
'Cursor refresh token missing; run `otto auth login cursor` again.',
|
|
59
|
+
CURSOR_TOKEN_EXPIRED,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const response = await fetch(`${CURSOR_API_BASE}/oauth/token`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
grant_type: 'refresh_token',
|
|
68
|
+
client_id: CURSOR_AUTH_CLIENT_ID,
|
|
69
|
+
refresh_token: refreshToken,
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
let data: RefreshResponse
|
|
74
|
+
try {
|
|
75
|
+
data = (await response.json()) as RefreshResponse
|
|
76
|
+
} catch {
|
|
77
|
+
throw new CursorAuthError(
|
|
78
|
+
`Cursor token refresh failed: HTTP ${response.status}`,
|
|
79
|
+
CURSOR_TOKEN_EXPIRED,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!response.ok || !data.access_token) {
|
|
84
|
+
const detail = data.error_description ?? data.error ?? `HTTP ${response.status}`
|
|
85
|
+
throw new CursorAuthError(`Cursor token refresh failed: ${detail}`, CURSOR_TOKEN_EXPIRED)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const expiresIn = typeof data.expires_in === 'number' ? data.expires_in : 3600
|
|
89
|
+
return {
|
|
90
|
+
access: data.access_token,
|
|
91
|
+
refresh: data.refresh_token ?? refreshToken,
|
|
92
|
+
expires: Date.now() + expiresIn * 1000,
|
|
93
|
+
machineId: typeof options.machineId === 'string' ? options.machineId : undefined,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getAccessKey(credentials: OAuthCredentials): string {
|
|
98
|
+
return credentials.access
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const CURSOR_NOT_INSTALLED = 'CURSOR_NOT_INSTALLED'
|
|
2
|
+
export const CURSOR_UNSUPPORTED_PLATFORM = 'CURSOR_UNSUPPORTED_PLATFORM'
|
|
3
|
+
export const CURSOR_NOT_LOGGED_IN = 'CURSOR_NOT_LOGGED_IN'
|
|
4
|
+
export const CURSOR_TOKEN_EXPIRED = 'CURSOR_TOKEN_EXPIRED'
|
|
5
|
+
export const CURSOR_TOOLS_NOT_SUPPORTED = 'CURSOR_TOOLS_NOT_SUPPORTED'
|
|
6
|
+
|
|
7
|
+
export class CursorAuthError extends Error {
|
|
8
|
+
readonly code: string
|
|
9
|
+
|
|
10
|
+
constructor(message: string, code: string) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = 'CursorAuthError'
|
|
13
|
+
this.code = code
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function cursorNotInstalled(path: string): CursorAuthError {
|
|
18
|
+
return new CursorAuthError(
|
|
19
|
+
`Cursor storage not found at ${path}. Install Cursor and sign in first.`,
|
|
20
|
+
CURSOR_NOT_INSTALLED,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function cursorUnsupportedPlatform(platform: string): CursorAuthError {
|
|
25
|
+
return new CursorAuthError(
|
|
26
|
+
`Cursor local credential import is not supported on ${platform} yet (macOS only in M131-1).`,
|
|
27
|
+
CURSOR_UNSUPPORTED_PLATFORM,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function cursorNotLoggedIn(): CursorAuthError {
|
|
32
|
+
return new CursorAuthError(
|
|
33
|
+
'No Cursor access token in local storage. Open Cursor, sign in, then retry `otto auth login cursor`.',
|
|
34
|
+
CURSOR_NOT_LOGGED_IN,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { platform } from 'node:process'
|
|
4
|
+
|
|
5
|
+
import { cursorNotInstalled, cursorUnsupportedPlatform } from './errors'
|
|
6
|
+
|
|
7
|
+
export interface CursorLocalAuth {
|
|
8
|
+
accessToken: string | null
|
|
9
|
+
refreshToken: string | null
|
|
10
|
+
machineId: string | null
|
|
11
|
+
email: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function defaultDbPath(): string {
|
|
15
|
+
if (platform === 'darwin') {
|
|
16
|
+
return `${homedir()}/Library/Application Support/Cursor/User/globalStorage/state.vscdb`
|
|
17
|
+
}
|
|
18
|
+
throw cursorUnsupportedPlatform(platform)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseStorageRows(output: string): Record<string, string> {
|
|
22
|
+
const out: Record<string, string> = {}
|
|
23
|
+
for (const line of output.split('\n')) {
|
|
24
|
+
if (!line.trim()) continue
|
|
25
|
+
const tab = line.indexOf('|')
|
|
26
|
+
if (tab < 0) continue
|
|
27
|
+
const key = line.slice(0, tab)
|
|
28
|
+
let value = line.slice(tab + 1)
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(value) as unknown
|
|
31
|
+
if (typeof parsed === 'string') {
|
|
32
|
+
value = parsed
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// keep raw string
|
|
36
|
+
}
|
|
37
|
+
out[key] = value
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read Cursor auth tokens from macOS SQLite storage (RFC-130 M131-1). */
|
|
43
|
+
export function readLocalAuth(dbPath?: string): CursorLocalAuth {
|
|
44
|
+
const path = dbPath ?? defaultDbPath()
|
|
45
|
+
let output: string
|
|
46
|
+
try {
|
|
47
|
+
output = execFileSync(
|
|
48
|
+
'sqlite3',
|
|
49
|
+
['-separator', '|', path, 'SELECT key, value FROM ItemTable'],
|
|
50
|
+
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 },
|
|
51
|
+
)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
54
|
+
if (code === 'ENOENT') {
|
|
55
|
+
throw cursorNotInstalled(path)
|
|
56
|
+
}
|
|
57
|
+
throw err
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const storage = parseStorageRows(output)
|
|
61
|
+
return {
|
|
62
|
+
accessToken: storage['cursorAuth/accessToken'] ?? null,
|
|
63
|
+
refreshToken: storage['cursorAuth/refreshToken'] ?? null,
|
|
64
|
+
machineId: storage['storage.serviceMachineId'] ?? null,
|
|
65
|
+
email: storage['cursorAuth/cachedEmail'] ?? null,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Strip `user_01...::` prefix used by some Cursor token formats. */
|
|
70
|
+
export function normalizeAccessToken(token: string): string {
|
|
71
|
+
const trimmed = token.trim()
|
|
72
|
+
const sep = trimmed.indexOf('::')
|
|
73
|
+
return sep >= 0 ? trimmed.slice(sep + 2) : trimmed
|
|
74
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-decode.ts —— Cursor connect-rpc agent 响应流的启发式解码器(RFC-130)。
|
|
3
|
+
*
|
|
4
|
+
* **protobuf 字段来源说明**(终局 review endgame-2026-07-23 补齐):Cursor 的 agent 响应协议
|
|
5
|
+
* 未公开 .proto schema,本文件对 wire 格式的理解全部来自对 cursor-agent CLI 网络抓包的逆向
|
|
6
|
+
* 工程,非官方文档。关键假设:
|
|
7
|
+
* - `decodeMessage()` 是标准 protobuf wire format 通用解码(varint tag/length-delimited),
|
|
8
|
+
* 这部分是协议无关的通用规则,不依赖具体 schema。
|
|
9
|
+
* - `extractTextChunks()` 里 `fieldNum >= 1 && fieldNum <= 10` 是启发式范围,非已知的
|
|
10
|
+
* "text 字段固定编号"——含义是"响应体顶层消息的前 10 个 length-delimited 字段都有可能
|
|
11
|
+
* 携带 prose 文本,逐个尝试 UTF-8 解码 + `isPlausibleAssistantText()` 过滤噪音"。这不是
|
|
12
|
+
* "字段 3 是标题、字段 7 是正文"这类精确 schema 知识,而是"广撒网 + 启发式过滤"策略。
|
|
13
|
+
* - `parseConnectStream()` 的 5 字节 length-prefix + flag(1=gzip / 2=plain text / 3=gzip+text)
|
|
14
|
+
* 是 Connect-RPC streaming 协议的标准 framing(非 Cursor 私有),可信度高于上面的字段编号猜测。
|
|
15
|
+
* - 若未来 Cursor 升级协议导致解码失效(如新增字段类型、文本编码变化),应重新抓包核实,
|
|
16
|
+
* 而非假设本文件的字段编号有官方依据。
|
|
17
|
+
*/
|
|
18
|
+
import { gunzipSync } from 'node:zlib'
|
|
19
|
+
|
|
20
|
+
const TEXT_PART_RE = /"type":"text","text":"((?:\\.|[^"\\])*)"/g
|
|
21
|
+
|
|
22
|
+
function unescapeJsonString(raw: string): string {
|
|
23
|
+
return JSON.parse(`"${raw}"`) as string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function extractAgentTextParts(raw: Uint8Array, userPrompt: string): string[] {
|
|
27
|
+
const text = new TextDecoder('utf-8', { fatal: false }).decode(raw)
|
|
28
|
+
const promptLower = userPrompt.toLowerCase().trim()
|
|
29
|
+
const parts: string[] = []
|
|
30
|
+
for (const match of text.matchAll(TEXT_PART_RE)) {
|
|
31
|
+
const value = unescapeJsonString(match[1] ?? '')
|
|
32
|
+
const trimmed = value.trim()
|
|
33
|
+
if (!trimmed || trimmed.length > 4000) continue
|
|
34
|
+
// 仅过滤与用户输入完全相同的回声,不用 includes(短 prompt 如 "hi" 会误杀 "Hi! ...")
|
|
35
|
+
if (promptLower && trimmed.toLowerCase() === promptLower) continue
|
|
36
|
+
if (trimmed.startsWith('<') || trimmed.includes('user_query')) continue
|
|
37
|
+
parts.push(trimmed)
|
|
38
|
+
}
|
|
39
|
+
return parts
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function pickAssistantText(parts: string[]): string {
|
|
43
|
+
const usable = parts.filter((p) => isPlausibleAssistantText(p) && looksLikeProse(p))
|
|
44
|
+
const pool = usable.length > 0 ? usable : parts
|
|
45
|
+
const pong = pool.find((p) => p.toLowerCase() === 'pong')
|
|
46
|
+
if (pong) return pong
|
|
47
|
+
const short = pool.filter((p) => p.length <= 200).sort((a, b) => a.length - b.length)
|
|
48
|
+
return short[0] ?? pool[pool.length - 1] ?? ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decodeVarint(data: Uint8Array, pos: number): [number, number] {
|
|
52
|
+
let result = 0
|
|
53
|
+
let shift = 0
|
|
54
|
+
while (pos < data.length) {
|
|
55
|
+
const byte = data[pos]!
|
|
56
|
+
result |= (byte & 0x7f) << shift
|
|
57
|
+
pos += 1
|
|
58
|
+
if ((byte & 0x80) === 0) break
|
|
59
|
+
shift += 7
|
|
60
|
+
}
|
|
61
|
+
return [result, pos]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function decodeMessage(data: Uint8Array): Map<number, Array<{ wireType: number; value: Uint8Array | number }>> {
|
|
65
|
+
const fields = new Map<number, Array<{ wireType: number; value: Uint8Array | number }>>()
|
|
66
|
+
let pos = 0
|
|
67
|
+
while (pos < data.length) {
|
|
68
|
+
const [tag, nextPos] = decodeVarint(data, pos)
|
|
69
|
+
if (nextPos === pos) break
|
|
70
|
+
pos = nextPos
|
|
71
|
+
const fieldNum = tag >> 3
|
|
72
|
+
const wireType = tag & 0x07
|
|
73
|
+
let value: Uint8Array | number
|
|
74
|
+
if (wireType === 0) {
|
|
75
|
+
;[value, pos] = decodeVarint(data, pos)
|
|
76
|
+
} else if (wireType === 2) {
|
|
77
|
+
const [length, lengthPos] = decodeVarint(data, pos)
|
|
78
|
+
pos = lengthPos
|
|
79
|
+
value = data.slice(pos, pos + length)
|
|
80
|
+
pos += length
|
|
81
|
+
} else {
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
const bucket = fields.get(fieldNum) ?? []
|
|
85
|
+
bucket.push({ wireType, value })
|
|
86
|
+
fields.set(fieldNum, bucket)
|
|
87
|
+
}
|
|
88
|
+
return fields
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Opaque base64/base64url/hex blobs that appear as protobuf string fields but are not prose. */
|
|
92
|
+
function isOpaqueToken(text: string): boolean {
|
|
93
|
+
const trimmed = text.trim()
|
|
94
|
+
if (/^[0-9a-f-]{16,}$/i.test(trimmed)) return true
|
|
95
|
+
// Cursor 3.11+ embeds long base64url checksum/session blobs (≥40 chars, no whitespace)
|
|
96
|
+
if (/^[A-Za-z0-9+/_-]{40,}={0,2}$/.test(trimmed)) return true
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function looksLikeProse(text: string): boolean {
|
|
101
|
+
if (/[\u4e00-\u9fff]/.test(text)) return true
|
|
102
|
+
if (/\s/.test(text) && /\p{L}{2,}/u.test(text)) return true
|
|
103
|
+
if (/[.!?。!?*]/.test(text) && /\p{L}/u.test(text)) return true
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isPlausibleAssistantText(text: string): boolean {
|
|
108
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(text)) return false
|
|
109
|
+
const trimmed = text.trim()
|
|
110
|
+
if (!trimmed || !/\p{L}/u.test(trimmed)) return false
|
|
111
|
+
if (isOpaqueToken(trimmed)) return false
|
|
112
|
+
// Long alphanumeric-only strings without prose signals are tokens, not answers
|
|
113
|
+
if (trimmed.length >= 40 && !looksLikeProse(trimmed)) return false
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function extractTextChunks(data: Uint8Array): string[] {
|
|
118
|
+
const chunks: string[] = []
|
|
119
|
+
const stack: Uint8Array[] = [data]
|
|
120
|
+
while (stack.length > 0) {
|
|
121
|
+
const chunk = stack.pop()!
|
|
122
|
+
let fields: Map<number, Array<{ wireType: number; value: Uint8Array | number }>>
|
|
123
|
+
try {
|
|
124
|
+
fields = decodeMessage(chunk)
|
|
125
|
+
} catch {
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
for (const [fieldNum, values] of fields) {
|
|
129
|
+
for (const { wireType, value } of values) {
|
|
130
|
+
if (wireType !== 2 || !(value instanceof Uint8Array)) continue
|
|
131
|
+
if (fieldNum >= 1 && fieldNum <= 10) {
|
|
132
|
+
if (value.length <= 1 || value.length >= 8000) continue
|
|
133
|
+
try {
|
|
134
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(value)
|
|
135
|
+
const stripped = text.trim()
|
|
136
|
+
if (!stripped) continue
|
|
137
|
+
if (stripped.startsWith('cursor') || stripped.startsWith('{')) continue
|
|
138
|
+
if (stripped.length >= 2 && /\p{L}/u.test(stripped) && isPlausibleAssistantText(text)) {
|
|
139
|
+
chunks.push(text)
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
stack.push(value)
|
|
143
|
+
}
|
|
144
|
+
} else if (value.length > 8) {
|
|
145
|
+
stack.push(value)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return chunks
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseConnectStream(raw: Uint8Array): string[] {
|
|
154
|
+
const texts: string[] = []
|
|
155
|
+
let pos = 0
|
|
156
|
+
while (pos + 5 <= raw.length) {
|
|
157
|
+
const flag = raw[pos]!
|
|
158
|
+
const length =
|
|
159
|
+
(raw[pos + 1]! << 24) |
|
|
160
|
+
(raw[pos + 2]! << 16) |
|
|
161
|
+
(raw[pos + 3]! << 8) |
|
|
162
|
+
raw[pos + 4]!
|
|
163
|
+
pos += 5
|
|
164
|
+
if (length === 0 || pos + length > raw.length) break
|
|
165
|
+
let payload = raw.slice(pos, pos + length)
|
|
166
|
+
pos += length
|
|
167
|
+
|
|
168
|
+
if (flag === 1) {
|
|
169
|
+
try {
|
|
170
|
+
payload = gunzipSync(payload)
|
|
171
|
+
} catch {
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
} else if (flag === 2) {
|
|
175
|
+
const text = new TextDecoder('utf-8', { fatal: false }).decode(payload).trim()
|
|
176
|
+
if (text && isPlausibleAssistantText(text)) texts.push(text)
|
|
177
|
+
continue
|
|
178
|
+
} else if (flag === 3) {
|
|
179
|
+
try {
|
|
180
|
+
payload = gunzipSync(payload)
|
|
181
|
+
const text = new TextDecoder('utf-8', { fatal: false }).decode(payload).trim()
|
|
182
|
+
if (text && isPlausibleAssistantText(text)) texts.push(text)
|
|
183
|
+
} catch {
|
|
184
|
+
// ignore
|
|
185
|
+
}
|
|
186
|
+
continue
|
|
187
|
+
} else if (flag !== 0) {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
texts.push(...extractTextChunks(payload))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const seen = new Set<string>()
|
|
195
|
+
const out: string[] = []
|
|
196
|
+
for (const text of texts) {
|
|
197
|
+
if (seen.has(text)) continue
|
|
198
|
+
seen.add(text)
|
|
199
|
+
out.push(text)
|
|
200
|
+
}
|
|
201
|
+
return out
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function scoreAssistantCandidate(text: string, userPrompt: string, frameIdx: number): number {
|
|
205
|
+
const trimmed = text.trim()
|
|
206
|
+
if (!isPlausibleAssistantText(trimmed)) return Number.NEGATIVE_INFINITY
|
|
207
|
+
const lower = trimmed.toLowerCase()
|
|
208
|
+
const promptLower = userPrompt.toLowerCase().trim()
|
|
209
|
+
const promptWords = promptLower.split(/\s+/).filter((w) => w.length > 3)
|
|
210
|
+
|
|
211
|
+
if (trimmed.length < 2 || trimmed.length > 4000) return Number.NEGATIVE_INFINITY
|
|
212
|
+
if (lower === promptLower || (promptLower && lower.includes(promptLower))) {
|
|
213
|
+
return Number.NEGATIVE_INFINITY
|
|
214
|
+
}
|
|
215
|
+
if (lower.includes('you are an ai coding assistant') || lower.includes('"role":"system"')) {
|
|
216
|
+
return Number.NEGATIVE_INFINITY
|
|
217
|
+
}
|
|
218
|
+
if (isOpaqueToken(trimmed)) return Number.NEGATIVE_INFINITY
|
|
219
|
+
if (lower.includes('update required') || lower.includes('"error"')) {
|
|
220
|
+
return Number.NEGATIVE_INFINITY
|
|
221
|
+
}
|
|
222
|
+
if (trimmed === 'default' || trimmed === 'Ask' || trimmed === 'Agent' || trimmed === 'otto-spike') {
|
|
223
|
+
return Number.NEGATIVE_INFINITY
|
|
224
|
+
}
|
|
225
|
+
if (trimmed === '/context.txt' || trimmed === 'otto') return Number.NEGATIVE_INFINITY
|
|
226
|
+
// Prefer complete prose over streamed single-token deltas / metadata
|
|
227
|
+
if (!looksLikeProse(trimmed)) return Number.NEGATIVE_INFINITY
|
|
228
|
+
|
|
229
|
+
let score = Math.min(trimmed.length, 200) + frameIdx * 10
|
|
230
|
+
if (/[\u4e00-\u9fff]/.test(trimmed)) score += 80
|
|
231
|
+
if (/[.!?。!?]$/.test(trimmed)) score += 30
|
|
232
|
+
if (trimmed.includes(' ')) score += 25
|
|
233
|
+
if (/\*\*/.test(trimmed)) score += 10
|
|
234
|
+
// Thinking/scratch lines often echo the user ask — demote heavily
|
|
235
|
+
if (lower.startsWith('the user is asking') || lower.startsWith('用户要求')) score -= 150
|
|
236
|
+
if (promptWords.length > 0) {
|
|
237
|
+
const matches = promptWords.filter((w) => lower.includes(w)).length
|
|
238
|
+
if (matches / promptWords.length > 0.5) score -= 200
|
|
239
|
+
}
|
|
240
|
+
return score
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function cleanProse(text: string): string {
|
|
244
|
+
// Drop leading protobuf framing leftovers (e.g. ".\n,今天是…")
|
|
245
|
+
const match = text.match(/\p{L}[\s\S]*/u)
|
|
246
|
+
return (match?.[0] ?? text).trim()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function extractFromConnectStream(raw: Uint8Array, userPrompt: string): string {
|
|
250
|
+
const candidates: Array<{ score: number; text: string }> = []
|
|
251
|
+
const streamTexts = parseConnectStream(raw)
|
|
252
|
+
for (const [frameIdx, text] of streamTexts.entries()) {
|
|
253
|
+
const cleaned = cleanProse(text)
|
|
254
|
+
const score = scoreAssistantCandidate(cleaned, userPrompt, frameIdx)
|
|
255
|
+
if (Number.isFinite(score)) {
|
|
256
|
+
candidates.push({ score, text: cleaned })
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (candidates.length === 0) return ''
|
|
261
|
+
candidates.sort((a, b) => b.score - a.score)
|
|
262
|
+
return candidates[0]?.text ?? ''
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Extract assistant text from AgentService/Run response (JSON fragments + protobuf fallback). */
|
|
266
|
+
export function extractAgentText(raw: Uint8Array, userPrompt: string): string {
|
|
267
|
+
const fromJson = pickAssistantText(extractAgentTextParts(raw, userPrompt))
|
|
268
|
+
if (fromJson.trim()) return fromJson
|
|
269
|
+
return extractFromConnectStream(raw, userPrompt)
|
|
270
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { concatBytes, encodeField } from './protobuf'
|
|
4
|
+
|
|
5
|
+
export function encodeAgentRunRequest(prompt: string, model: string): Uint8Array {
|
|
6
|
+
const messageId = randomUUID()
|
|
7
|
+
const conversationId = randomUUID()
|
|
8
|
+
|
|
9
|
+
const userMsg = concatBytes([
|
|
10
|
+
encodeField(1, 2, prompt),
|
|
11
|
+
encodeField(2, 2, messageId),
|
|
12
|
+
encodeField(3, 2, ''),
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
const fileCtx = concatBytes([
|
|
16
|
+
encodeField(1, 2, '/context.txt'),
|
|
17
|
+
encodeField(2, 2, 'otto'),
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
const explicitCtx = encodeField(2, 2, fileCtx)
|
|
21
|
+
const userAction = concatBytes([encodeField(1, 2, userMsg), encodeField(2, 2, explicitCtx)])
|
|
22
|
+
const convAction = encodeField(1, 2, userAction)
|
|
23
|
+
|
|
24
|
+
const display = model.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
|
25
|
+
const modelDetails = concatBytes([
|
|
26
|
+
encodeField(1, 2, model),
|
|
27
|
+
encodeField(3, 2, model),
|
|
28
|
+
encodeField(4, 2, display),
|
|
29
|
+
encodeField(5, 2, display),
|
|
30
|
+
encodeField(7, 0, 0),
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
const runReq = concatBytes([
|
|
34
|
+
encodeField(1, 2, ''),
|
|
35
|
+
encodeField(2, 2, convAction),
|
|
36
|
+
encodeField(3, 2, modelDetails),
|
|
37
|
+
encodeField(4, 2, ''),
|
|
38
|
+
encodeField(5, 2, conversationId),
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
return encodeField(1, 2, runReq)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function encodeAgentConnectBody(prompt: string, model: string): Uint8Array {
|
|
45
|
+
const payload = encodeAgentRunRequest(prompt, model)
|
|
46
|
+
const lengthHex = payload.length.toString(16).padStart(8, '0')
|
|
47
|
+
const lengthBytes = Uint8Array.from(Buffer.from(lengthHex, 'hex'))
|
|
48
|
+
const out = new Uint8Array(1 + lengthBytes.length + payload.length)
|
|
49
|
+
out[0] = 0
|
|
50
|
+
out.set(lengthBytes, 1)
|
|
51
|
+
out.set(payload, 5)
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import http2 from 'node:http2'
|
|
2
|
+
|
|
3
|
+
import { encodeAgentConnectBody } from './agent-request'
|
|
4
|
+
import { buildAgentHeaders, CURSOR_AGENT_BACKEND } from './headers'
|
|
5
|
+
|
|
6
|
+
export interface AgentStreamOptions {
|
|
7
|
+
token: string
|
|
8
|
+
machineId: string | null
|
|
9
|
+
prompt: string
|
|
10
|
+
model: string
|
|
11
|
+
signal?: AbortSignal
|
|
12
|
+
idleMs?: number
|
|
13
|
+
hardMs?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function collectAgentResponse(options: AgentStreamOptions): Promise<Uint8Array> {
|
|
17
|
+
const {
|
|
18
|
+
token,
|
|
19
|
+
machineId,
|
|
20
|
+
prompt,
|
|
21
|
+
model,
|
|
22
|
+
signal,
|
|
23
|
+
idleMs = 3000,
|
|
24
|
+
hardMs = 60000,
|
|
25
|
+
} = options
|
|
26
|
+
|
|
27
|
+
const body = encodeAgentConnectBody(prompt, model)
|
|
28
|
+
const headers = buildAgentHeaders(token, machineId)
|
|
29
|
+
const host = new URL(CURSOR_AGENT_BACKEND).host
|
|
30
|
+
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const chunks: Buffer[] = []
|
|
33
|
+
let lastDataAt = Date.now()
|
|
34
|
+
const startedAt = Date.now()
|
|
35
|
+
let settled = false
|
|
36
|
+
|
|
37
|
+
const client = http2.connect(CURSOR_AGENT_BACKEND)
|
|
38
|
+
const finish = (err?: Error, data?: Buffer) => {
|
|
39
|
+
if (settled) return
|
|
40
|
+
settled = true
|
|
41
|
+
clearInterval(idleTimer)
|
|
42
|
+
client.close()
|
|
43
|
+
if (err) reject(err)
|
|
44
|
+
else resolve(new Uint8Array(data ?? Buffer.concat(chunks)))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const idleTimer = setInterval(() => {
|
|
48
|
+
if (signal?.aborted) {
|
|
49
|
+
finish(new Error('Aborted'))
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (chunks.length > 0 && Date.now() - lastDataAt >= idleMs) {
|
|
53
|
+
finish(undefined, Buffer.concat(chunks))
|
|
54
|
+
}
|
|
55
|
+
if (Date.now() - startedAt >= hardMs) {
|
|
56
|
+
finish(undefined, Buffer.concat(chunks))
|
|
57
|
+
}
|
|
58
|
+
}, 250)
|
|
59
|
+
|
|
60
|
+
client.on('error', (err) => finish(err))
|
|
61
|
+
|
|
62
|
+
const req = client.request({
|
|
63
|
+
':method': 'POST',
|
|
64
|
+
':path': '/agent.v1.AgentService/Run',
|
|
65
|
+
':authority': host,
|
|
66
|
+
...headers,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
req.on('response', (resHeaders) => {
|
|
70
|
+
const status = Number(resHeaders[':status'] ?? 0)
|
|
71
|
+
if (status !== 200) {
|
|
72
|
+
const errChunks: Buffer[] = []
|
|
73
|
+
req.on('data', (c) => errChunks.push(Buffer.from(c)))
|
|
74
|
+
req.on('end', () => {
|
|
75
|
+
finish(
|
|
76
|
+
new Error(
|
|
77
|
+
`Cursor agent HTTP ${status}: ${Buffer.concat(errChunks).toString('utf8').slice(0, 400)}`,
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
req.on('data', (chunk) => {
|
|
85
|
+
chunks.push(Buffer.from(chunk))
|
|
86
|
+
lastDataAt = Date.now()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
req.on('end', () => finish(undefined, Buffer.concat(chunks)))
|
|
90
|
+
req.on('error', (err) => finish(err))
|
|
91
|
+
|
|
92
|
+
signal?.addEventListener(
|
|
93
|
+
'abort',
|
|
94
|
+
() => finish(new Error('Aborted')),
|
|
95
|
+
{ once: true },
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
req.write(body)
|
|
99
|
+
req.end()
|
|
100
|
+
})
|
|
101
|
+
}
|