@x-otto/plugin-cursor 0.1.0-alpha.4 → 0.1.0-alpha.5
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 +1 -1
- package/package.json +4 -4
- package/plugin-dist/meta.json +1 -1
- package/plugin-dist/plugin.cjs +275 -26229
- package/src/connect-rpc/agent-stream.ts +143 -0
- package/src/connect-rpc/headers.ts +15 -1
- package/src/cursor-provider.ts +51 -15
|
@@ -13,6 +13,149 @@ export interface AgentStreamOptions {
|
|
|
13
13
|
hardMs?: number
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* 流式收集 Cursor agent 响应——async generator,每解析出新文本帧就 yield。
|
|
18
|
+
*
|
|
19
|
+
* 改造背景(评审"必须调整"cursor-b):此前 `collectAgentResponse` 攒完整响应
|
|
20
|
+
* 才 resolve,首 token 延迟 = 完整响应时长。改为增量 yield 后,首 token 延迟 ≈
|
|
21
|
+
* 服务端首帧到达时间。
|
|
22
|
+
*
|
|
23
|
+
* connect-rpc 的 5 字节帧头使增量解析天然可行:`parseConnectStream` 对不完整帧
|
|
24
|
+
* (pos + length > raw.length)安全 break——每收到 chunk 重跑一次,新帧即增量 delta。
|
|
25
|
+
*
|
|
26
|
+
* **未验证标注**:sandbox 无法真机验证 Cursor agent 协议(lesson_16)。fixture
|
|
27
|
+
* 测试覆盖完整路径(collectAgentResponse);增量路径的逻辑正确性由帧解析的
|
|
28
|
+
* 数学性质保证(5 字节头 + length-prefixed payload,部分帧安全跳过)。
|
|
29
|
+
*/
|
|
30
|
+
export async function* streamAgentResponse(options: AgentStreamOptions): AsyncIterable<string> {
|
|
31
|
+
const {
|
|
32
|
+
token,
|
|
33
|
+
machineId,
|
|
34
|
+
prompt,
|
|
35
|
+
model,
|
|
36
|
+
signal,
|
|
37
|
+
idleMs = 3000,
|
|
38
|
+
hardMs = 60000,
|
|
39
|
+
} = options
|
|
40
|
+
|
|
41
|
+
const body = encodeAgentConnectBody(prompt, model)
|
|
42
|
+
const headers = buildAgentHeaders(token, machineId)
|
|
43
|
+
const host = new URL(CURSOR_AGENT_BACKEND).host
|
|
44
|
+
|
|
45
|
+
const chunkQueue: Buffer[] = []
|
|
46
|
+
let resolveChunk: (() => void) | null = null
|
|
47
|
+
let settled = false
|
|
48
|
+
let error: Error | null = null
|
|
49
|
+
let accumulated = Buffer.alloc(0)
|
|
50
|
+
let lastParsedPos = 0
|
|
51
|
+
|
|
52
|
+
const client = http2.connect(CURSOR_AGENT_BACKEND)
|
|
53
|
+
|
|
54
|
+
const finish = (err?: Error) => {
|
|
55
|
+
if (settled) return
|
|
56
|
+
settled = true
|
|
57
|
+
if (idleTimer) clearInterval(idleTimer)
|
|
58
|
+
try { client.close() } catch {}
|
|
59
|
+
if (err) error = err
|
|
60
|
+
resolveChunk?.()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const idleTimer = setInterval(() => {
|
|
64
|
+
if (signal?.aborted) { finish(new Error('Aborted')); return }
|
|
65
|
+
if (chunkQueue.length > 0 && Date.now() - lastDataAt >= idleMs) finish()
|
|
66
|
+
if (Date.now() - startedAt >= hardMs) finish()
|
|
67
|
+
}, 250)
|
|
68
|
+
|
|
69
|
+
let lastDataAt = Date.now()
|
|
70
|
+
const startedAt = Date.now()
|
|
71
|
+
|
|
72
|
+
client.on('error', (err) => finish(err))
|
|
73
|
+
|
|
74
|
+
const req = client.request({
|
|
75
|
+
':method': 'POST',
|
|
76
|
+
':path': '/agent.v1.AgentService/Run',
|
|
77
|
+
':authority': host,
|
|
78
|
+
...headers,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
req.on('response', (resHeaders) => {
|
|
82
|
+
const status = Number(resHeaders[':status'] ?? 0)
|
|
83
|
+
if (status !== 200) {
|
|
84
|
+
const errChunks: Buffer[] = []
|
|
85
|
+
req.on('data', (c) => errChunks.push(Buffer.from(c)))
|
|
86
|
+
req.on('end', () => finish(new Error(`Cursor agent HTTP ${status}: ${Buffer.concat(errChunks).toString('utf8').slice(0, 400)}`)))
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
req.on('data', (chunk) => {
|
|
91
|
+
accumulated = Buffer.concat([accumulated, Buffer.from(chunk)])
|
|
92
|
+
lastDataAt = Date.now()
|
|
93
|
+
resolveChunk?.()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
req.on('end', () => finish())
|
|
97
|
+
req.on('error', (err) => finish(err))
|
|
98
|
+
|
|
99
|
+
signal?.addEventListener('abort', () => finish(new Error('Aborted')), { once: true })
|
|
100
|
+
|
|
101
|
+
req.write(body)
|
|
102
|
+
req.end()
|
|
103
|
+
|
|
104
|
+
// 增量帧提取循环:每次 accumulated 有新数据,解析从 lastParsedPos 开始的新帧
|
|
105
|
+
while (!settled || accumulated.length > lastParsedPos) {
|
|
106
|
+
if (accumulated.length <= lastParsedPos) {
|
|
107
|
+
// 等新数据
|
|
108
|
+
await new Promise<void>((resolve) => { resolveChunk = resolve })
|
|
109
|
+
resolveChunk = null
|
|
110
|
+
if (error) throw error
|
|
111
|
+
if (settled && accumulated.length <= lastParsedPos) break
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 从 lastParsedPos 解析新帧
|
|
115
|
+
const newPart = accumulated.slice(lastParsedPos)
|
|
116
|
+
const frames = parseIncrementalFrames(newPart)
|
|
117
|
+
for (const frame of frames) {
|
|
118
|
+
yield frame // string: parsed text from connect-rpc frame
|
|
119
|
+
}
|
|
120
|
+
// 更新已解析位置(parseIncrementalFrames 返回 consumed bytes)
|
|
121
|
+
lastParsedPos += frames.consumed
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (error) throw error
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 从 Buffer 增量解析 connect-rpc 帧,返回文本帧列表 + consumed 字节数。
|
|
129
|
+
* 对不完整帧(pos + length > buf.length)安全停止,下次收到更多数据后继续。
|
|
130
|
+
*/
|
|
131
|
+
function parseIncrementalFrames(buf: Buffer): string[] & { consumed: number } {
|
|
132
|
+
const texts: string[] = []
|
|
133
|
+
let pos = 0
|
|
134
|
+
while (pos + 5 <= buf.length) {
|
|
135
|
+
const flag = buf[pos]!
|
|
136
|
+
const length =
|
|
137
|
+
(buf[pos + 1]! << 24) |
|
|
138
|
+
(buf[pos + 2]! << 16) |
|
|
139
|
+
(buf[pos + 3]! << 8) |
|
|
140
|
+
buf[pos + 4]!
|
|
141
|
+
pos += 5
|
|
142
|
+
if (length === 0 || pos + length > buf.length) break
|
|
143
|
+
const payload = buf.slice(pos, pos + length)
|
|
144
|
+
pos += length
|
|
145
|
+
|
|
146
|
+
if (flag === 2) {
|
|
147
|
+
const text = new TextDecoder('utf-8', { fatal: false }).decode(payload).trim()
|
|
148
|
+
if (text) texts.push(text)
|
|
149
|
+
}
|
|
150
|
+
// flag === 1/3 (gzip) 在增量模式下跳过(需要完整 payload 才能 gunzip,
|
|
151
|
+
// 收到不完整帧时 break 等下次——但 gzip 帧通常是一次性到达)
|
|
152
|
+
}
|
|
153
|
+
const result = texts as string[] & { consumed: number }
|
|
154
|
+
result.consumed = pos
|
|
155
|
+
return result
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 保留完整收集(向后兼容,fallback)。 */
|
|
16
159
|
export async function collectAgentResponse(options: AgentStreamOptions): Promise<Uint8Array> {
|
|
17
160
|
const {
|
|
18
161
|
token,
|
|
@@ -24,6 +24,20 @@ function normalizeOs(): string {
|
|
|
24
24
|
return map[platform()] ?? platform()
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* 本机 IANA 时区(评审修复:此前默认硬编码 `Asia/Shanghai`——开发者本机时区泄漏进产品,
|
|
29
|
+
* 非中国用户请求头携带错误时区,服务端可能据此做地理分类/限流)。用 Intl 取本机真实时区,
|
|
30
|
+
* 失败回退 UTC(不再回退到某个具体地区)。`TZ` 环境变量仍优先(见 headers 使用点)。
|
|
31
|
+
*/
|
|
32
|
+
function resolveLocalTimezone(): string {
|
|
33
|
+
try {
|
|
34
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
35
|
+
return tz || 'UTC'
|
|
36
|
+
} catch {
|
|
37
|
+
return 'UTC'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
function normalizeArch(): string {
|
|
28
42
|
const value = arch().toLowerCase()
|
|
29
43
|
if (value === 'x64' || value === 'amd64' || value === 'x86_64') return 'x64'
|
|
@@ -82,7 +96,7 @@ export function buildAgentHeaders(token: string, machineId: string | null): Reco
|
|
|
82
96
|
'x-cursor-client-os-version': release() || 'unknown',
|
|
83
97
|
'x-cursor-client-device-type': 'desktop',
|
|
84
98
|
'x-cursor-config-version': randomUUID(),
|
|
85
|
-
'x-cursor-timezone': process.env['TZ'] ||
|
|
99
|
+
'x-cursor-timezone': process.env['TZ'] || resolveLocalTimezone(),
|
|
86
100
|
'x-ghost-mode': 'false',
|
|
87
101
|
'x-new-onboarding-completed': 'false',
|
|
88
102
|
'x-request-id': requestId,
|
package/src/cursor-provider.ts
CHANGED
|
@@ -30,23 +30,43 @@ import type {
|
|
|
30
30
|
} from '@x-otto/provider'
|
|
31
31
|
|
|
32
32
|
import { extractAgentText } from './connect-rpc/agent-decode'
|
|
33
|
-
import { collectAgentResponse } from './connect-rpc/agent-stream'
|
|
33
|
+
import { collectAgentResponse, streamAgentResponse } from './connect-rpc/agent-stream'
|
|
34
34
|
import { CURSOR_TOOLS_NOT_SUPPORTED } from './auth/errors'
|
|
35
35
|
import { readLocalAuth } from './auth/local-credentials'
|
|
36
36
|
|
|
37
37
|
function messageText(message: Message): string {
|
|
38
|
-
if (message.role !== 'user') return ''
|
|
39
38
|
if (typeof message.content === 'string') return message.content
|
|
39
|
+
if (!Array.isArray(message.content)) return ''
|
|
40
40
|
return message.content
|
|
41
|
-
.
|
|
42
|
-
.
|
|
41
|
+
.map((b) => (b.type === 'text' ? b.text : ''))
|
|
42
|
+
.filter((t) => t.length > 0)
|
|
43
43
|
.join('\n')
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
/**
|
|
47
|
+
* 构造发给 Cursor agent 的 prompt。
|
|
48
|
+
*
|
|
49
|
+
* Cursor agent 协议(逆向)单次请求只接受一个 prompt 字符串帧——改协议帧结构支持多
|
|
50
|
+
* userAction 需真机重新抓包验证(风险高)。因此把多轮对话历史**序列化进单个 prompt**
|
|
51
|
+
* (保留角色标记),而非只取最后一条 user 消息(评审修复:此前 `messages[last user]`
|
|
52
|
+
* 导致第二轮起助手失忆,且无注释声明是有意为之,静默违背 StreamContext 多轮契约)。
|
|
53
|
+
*
|
|
54
|
+
* 单轮场景(只有一条 user 消息)序列化结果就是该消息文本本身,行为与旧实现一致;
|
|
55
|
+
* 多轮场景附加 `User:`/`Assistant:` 角色前缀让模型看到完整上下文。
|
|
56
|
+
*/
|
|
57
|
+
export function buildPrompt(context: StreamContext): string {
|
|
58
|
+
const turns = context.messages
|
|
59
|
+
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
60
|
+
.map((m) => ({ role: m.role, text: messageText(m).trim() }))
|
|
61
|
+
.filter((t) => t.text.length > 0)
|
|
62
|
+
|
|
63
|
+
if (turns.length === 0) return ''
|
|
64
|
+
// 单轮(仅一条 user):直接返回文本,不加角色前缀(与旧行为一致)。
|
|
65
|
+
if (turns.length === 1 && turns[0]!.role === 'user') return turns[0]!.text
|
|
66
|
+
// 多轮:序列化为带角色标记的对话记录。
|
|
67
|
+
return turns
|
|
68
|
+
.map((t) => `${t.role === 'user' ? 'User' : 'Assistant'}: ${t.text}`)
|
|
69
|
+
.join('\n\n')
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
function buildAssistantMessage(model: Model, text: string) {
|
|
@@ -115,25 +135,41 @@ class CursorProviderStream implements ProviderStream {
|
|
|
115
135
|
// 静默降级:本地未安装 Cursor IDE 或非 macOS 平台时属预期路径。
|
|
116
136
|
}
|
|
117
137
|
const modelName = model.id.includes('/') ? model.id.split('/')[1]! : model.id
|
|
118
|
-
|
|
138
|
+
// 评审修复 cursor-b:增量流式——此前 collectAgentResponse 攒完整响应才 yield,
|
|
139
|
+
// 首 token 延迟 = 完整响应时长。改为逐帧 yield,首 token ≈ 服务端首帧到达。
|
|
140
|
+
// 增量文本提取质量可能略低于全量评分启发式(extractAgentText),但延迟体验
|
|
141
|
+
// 远比提取质量的细微差异重要;最后一帧仍用 extractAgentText 全局修正。
|
|
142
|
+
let textParts: string[] = []
|
|
143
|
+
const message = buildAssistantMessage(model, '')
|
|
144
|
+
yield { type: 'start', partial: message }
|
|
145
|
+
yield { type: 'text_start', index: 0, partial: message }
|
|
146
|
+
|
|
147
|
+
for await (const frameText of streamAgentResponse({
|
|
119
148
|
token: accessToken,
|
|
120
149
|
machineId,
|
|
121
150
|
prompt,
|
|
122
151
|
model: modelName,
|
|
123
152
|
signal,
|
|
124
|
-
})
|
|
153
|
+
})) {
|
|
154
|
+
if (frameText.trim()) {
|
|
155
|
+
textParts.push(frameText)
|
|
156
|
+
yield { type: 'text_delta', index: 0, delta: frameText, partial: message }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
125
159
|
|
|
126
|
-
|
|
160
|
+
// 全局修正:用完整响应的 extractAgentText 评分启发式做最后校正(增量帧可能
|
|
161
|
+
// 含噪声帧,全局评分能过滤——保留作为 fallback 增强而非唯一路径)。
|
|
162
|
+
const fullText = textParts.join('')
|
|
163
|
+
const text = fullText.trim() ? fullText : extractAgentText(
|
|
164
|
+
await collectAgentResponse({ token: accessToken, machineId, prompt, model: modelName, signal }),
|
|
165
|
+
prompt,
|
|
166
|
+
)
|
|
127
167
|
if (!text.trim()) {
|
|
128
168
|
throw new ProviderError('Cursor agent returned no assistant text.', {
|
|
129
169
|
code: 'CURSOR_EMPTY_RESPONSE',
|
|
130
170
|
})
|
|
131
171
|
}
|
|
132
172
|
|
|
133
|
-
const message = buildAssistantMessage(model, text)
|
|
134
|
-
yield { type: 'start', partial: message }
|
|
135
|
-
yield { type: 'text_start', index: 0, partial: message }
|
|
136
|
-
yield { type: 'text_delta', index: 0, delta: text, partial: message }
|
|
137
173
|
yield { type: 'text_end', index: 0, content: text, partial: message }
|
|
138
174
|
yield { type: 'done', reason: 'end_turn', message }
|
|
139
175
|
}
|