@x-otto/plugin-cursor 0.1.0-alpha.0 → 0.1.0-alpha.10

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.
@@ -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'] || 'Asia/Shanghai',
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,
@@ -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
- .filter((b) => b.type === 'text')
42
- .map((b) => b.text)
41
+ .map((b) => (b.type === 'text' ? b.text : ''))
42
+ .filter((t) => t.length > 0)
43
43
  .join('\n')
44
44
  }
45
45
 
46
- function buildPrompt(context: StreamContext): string {
47
- const userMessages = context.messages.filter((m) => m.role === 'user')
48
- const last = userMessages[userMessages.length - 1]
49
- return last ? messageText(last) : ''
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
- const raw = await collectAgentResponse({
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
- const text = extractAgentText(raw, prompt)
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
  }