@tduyng/pi-cursor 0.0.0-stage → 0.0.2
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/LICENSE +178 -0
- package/README.md +104 -2
- package/package.json +42 -4
- package/src/catalog.ts +69 -0
- package/src/index.ts +195 -0
- package/src/json.ts +74 -0
- package/src/models.ts +198 -0
- package/src/resume.ts +81 -0
- package/src/sdk.ts +14 -0
- package/src/session.ts +461 -0
- package/src/state.ts +43 -0
- package/src/transcript.ts +210 -0
- package/src/turn.ts +389 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCurrentTools,
|
|
3
|
+
getInitialSystemMessage,
|
|
4
|
+
getSystemMessageText,
|
|
5
|
+
renderSystemMessageUpdate,
|
|
6
|
+
toToolDeclaration,
|
|
7
|
+
type AssistantMessage,
|
|
8
|
+
type ImageContent,
|
|
9
|
+
type Message,
|
|
10
|
+
type SystemMessage,
|
|
11
|
+
type TextContent,
|
|
12
|
+
type Tool,
|
|
13
|
+
type ToolResultMessage,
|
|
14
|
+
type UserMessage
|
|
15
|
+
} from '@earendil-works/pi-ai'
|
|
16
|
+
import type { SDKCustomToolContent, SDKCustomToolResult, SDKJsonValue, SDKUserMessage } from '@cursor/sdk'
|
|
17
|
+
|
|
18
|
+
import { sha, stableStringify, type JsonValue } from './json.js'
|
|
19
|
+
|
|
20
|
+
export type CursorContext = { messages: Message[] }
|
|
21
|
+
export type TurnMessage = UserMessage | AssistantMessage | ToolResultMessage | SystemMessage
|
|
22
|
+
export type CursorImage = { data: string; mimeType: string }
|
|
23
|
+
export type CursorInput = { text: string; images: CursorImage[] }
|
|
24
|
+
|
|
25
|
+
export type Transcript = {
|
|
26
|
+
systemPrompt: string
|
|
27
|
+
tools: Tool[]
|
|
28
|
+
toolsHash: string
|
|
29
|
+
messages: TurnMessage[]
|
|
30
|
+
chain: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readTranscript(context: CursorContext): Transcript {
|
|
34
|
+
const leading = getInitialSystemMessage(context.messages)
|
|
35
|
+
const systemPrompt = leading ? getSystemMessageText(leading) : ''
|
|
36
|
+
const tools = getCurrentTools(context.messages).map(toToolDeclaration)
|
|
37
|
+
const messages: TurnMessage[] = []
|
|
38
|
+
const dropped = new Set<string>()
|
|
39
|
+
|
|
40
|
+
for (const message of context.messages) {
|
|
41
|
+
if (message === leading) continue
|
|
42
|
+
if (message.role === 'assistant' && (message.stopReason === 'error' || message.stopReason === 'aborted')) {
|
|
43
|
+
for (const block of message.content) if (block.type === 'toolCall') dropped.add(block.id)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (message.role === 'toolResult' && dropped.has(message.toolCallId)) continue
|
|
47
|
+
messages.push(message)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const chain = [sha(`prompt\0${systemPrompt}`)]
|
|
51
|
+
for (const message of messages) chain.push(sha(`${chain[chain.length - 1]}\0${fingerprint(message)}`))
|
|
52
|
+
return { systemPrompt, tools, toolsHash: toolsHash(tools), messages, chain }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function fingerprint(message: TurnMessage): string {
|
|
56
|
+
switch (message.role) {
|
|
57
|
+
case 'user':
|
|
58
|
+
return stableStringify(['u', normalContent(message.content)])
|
|
59
|
+
case 'toolResult':
|
|
60
|
+
return stableStringify(['r', message.toolCallId, normalContent(message.content), message.isError])
|
|
61
|
+
case 'system':
|
|
62
|
+
return stableStringify(['s', renderSystemMessageUpdate(message)])
|
|
63
|
+
case 'assistant': {
|
|
64
|
+
const blocks: JsonValue[] = []
|
|
65
|
+
for (const block of message.content) {
|
|
66
|
+
if (block.type === 'text') {
|
|
67
|
+
if (block.text.length > 0) blocks.push(['t', block.text])
|
|
68
|
+
} else if (block.type === 'thinking') {
|
|
69
|
+
blocks.push(['k', block.thinking, block.thinkingSignature ?? ''])
|
|
70
|
+
} else {
|
|
71
|
+
const args: JsonValue = JSON.parse(JSON.stringify(block.arguments))
|
|
72
|
+
blocks.push(['c', block.id, block.name, args])
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return stableStringify(['a', blocks])
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalContent(content: string | (TextContent | ImageContent)[]): JsonValue {
|
|
81
|
+
if (!Array.isArray(content)) return [['t', content]]
|
|
82
|
+
return content.map((block) => (block.type === 'text' ? ['t', block.text] : ['i', block.mimeType, sha(block.data)]))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function toolsHash(tools: Tool[]): string {
|
|
86
|
+
return sha(
|
|
87
|
+
JSON.stringify(
|
|
88
|
+
tools.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }))
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function toolSchema(tool: Tool): Record<string, SDKJsonValue> {
|
|
94
|
+
const schema: Record<string, SDKJsonValue> = JSON.parse(JSON.stringify(tool.parameters))
|
|
95
|
+
if (schema['type'] === undefined) schema['type'] = 'object'
|
|
96
|
+
if (schema['required'] === undefined) schema['required'] = []
|
|
97
|
+
return schema
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function toolResultCustom(message: ToolResultMessage): SDKCustomToolResult {
|
|
101
|
+
const content: SDKCustomToolContent[] = message.content.map((block) =>
|
|
102
|
+
block.type === 'text'
|
|
103
|
+
? { type: 'text', text: block.text }
|
|
104
|
+
: { type: 'image', data: block.data, mimeType: block.mimeType }
|
|
105
|
+
)
|
|
106
|
+
return {
|
|
107
|
+
content: content.length > 0 ? content : [{ type: 'text', text: '(no output)' }],
|
|
108
|
+
isError: message.isError
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function image(block: ImageContent): CursorImage {
|
|
113
|
+
return { data: block.data, mimeType: block.mimeType }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function messageText(content: string | (TextContent | ImageContent)[]): string {
|
|
117
|
+
if (!Array.isArray(content)) return content
|
|
118
|
+
return content
|
|
119
|
+
.map((block) => (block.type === 'text' ? block.text : `[image: ${block.mimeType}]`))
|
|
120
|
+
.join('\n')
|
|
121
|
+
.trim()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function userInput(messages: TurnMessage[]): CursorInput {
|
|
125
|
+
const parts: string[] = []
|
|
126
|
+
const images: CursorImage[] = []
|
|
127
|
+
for (const message of messages) {
|
|
128
|
+
if (message.role === 'user') {
|
|
129
|
+
if (!Array.isArray(message.content)) {
|
|
130
|
+
if (message.content.length > 0) parts.push(message.content)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
for (const block of message.content) {
|
|
134
|
+
if (block.type === 'text') {
|
|
135
|
+
if (block.text.length > 0) parts.push(block.text)
|
|
136
|
+
} else {
|
|
137
|
+
images.push(image(block))
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (message.role === 'system') {
|
|
143
|
+
const text = renderSystemMessageUpdate(message).trim()
|
|
144
|
+
if (text) parts.push(`<system-reminder>\n${text}\n</system-reminder>`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { text: parts.join('\n\n'), images }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function replayInput(messages: TurnMessage[]): CursorInput {
|
|
151
|
+
if (messages.length === 1 && messages[0]?.role === 'user') return userInput(messages)
|
|
152
|
+
|
|
153
|
+
const parts: string[] = [
|
|
154
|
+
'This is the existing Pi conversation, replayed because the transport restarted. Treat it as prior turns, not as a new request. Continue after the final entry.'
|
|
155
|
+
]
|
|
156
|
+
for (const message of messages) {
|
|
157
|
+
if (message.role === 'user') {
|
|
158
|
+
const text = messageText(message.content)
|
|
159
|
+
if (text) parts.push(`USER:\n${text}`)
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (message.role === 'toolResult') {
|
|
163
|
+
const text = messageText(message.content)
|
|
164
|
+
const status = message.isError ? 'failed' : 'ok'
|
|
165
|
+
parts.push(`TOOL RESULT ${message.toolName} (${status}):\n${text}`)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (message.role === 'system') {
|
|
169
|
+
const text = renderSystemMessageUpdate(message).trim()
|
|
170
|
+
if (text) parts.push(`SYSTEM UPDATE:\n${text}`)
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
const rendered = message.content
|
|
174
|
+
.map((block) => {
|
|
175
|
+
if (block.type === 'text') return block.text
|
|
176
|
+
if (block.type === 'thinking') return ''
|
|
177
|
+
return `CALLED ${block.name}(${JSON.stringify(block.arguments)})`
|
|
178
|
+
})
|
|
179
|
+
.filter((line) => line.length > 0)
|
|
180
|
+
.join('\n')
|
|
181
|
+
if (rendered) parts.push(`ASSISTANT:\n${rendered}`)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const pending = messages.findLast((message) => message.role === 'user')
|
|
185
|
+
const images: CursorImage[] = []
|
|
186
|
+
if (pending && Array.isArray(pending.content)) {
|
|
187
|
+
for (const block of pending.content) {
|
|
188
|
+
if (block.type === 'image') images.push(image(block))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { text: parts.join('\n\n'), images }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function withPreamble(input: CursorInput, prompt: string): CursorInput {
|
|
195
|
+
if (prompt.trim().length === 0) return input
|
|
196
|
+
return { text: `<system>\n${prompt.trim()}\n</system>\n\n${input.text}`, images: input.images }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function toSdkMessage(input: CursorInput): string | SDKUserMessage {
|
|
200
|
+
if (input.images.length === 0) return input.text
|
|
201
|
+
return { text: input.text, images: input.images }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function systemPreamble(cwd: string): string {
|
|
205
|
+
return [
|
|
206
|
+
'Your tools are Pi tools, exposed to you as custom tools. Call them by their declared names. Cursor built-in tools are disabled, and no shell, read, edit, write, grep, glob, ls or task tool exists.',
|
|
207
|
+
'Pi owns the conversation, approvals, file edits, shell, and compaction. Every tool call runs in Pi. Do not claim work you have not done with a Pi tool.',
|
|
208
|
+
`Working directory: ${cwd}`
|
|
209
|
+
].join('\n')
|
|
210
|
+
}
|
package/src/turn.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAssistantMessageEventStream,
|
|
3
|
+
type Api,
|
|
4
|
+
type AssistantMessage,
|
|
5
|
+
type AssistantMessageEventStream,
|
|
6
|
+
type Model,
|
|
7
|
+
type SimpleStreamOptions,
|
|
8
|
+
type ToolCall,
|
|
9
|
+
type ToolResultMessage,
|
|
10
|
+
type Usage
|
|
11
|
+
} from '@earendil-works/pi-ai'
|
|
12
|
+
|
|
13
|
+
import { sha } from './json.js'
|
|
14
|
+
import { fastMode } from './state.js'
|
|
15
|
+
import { dropRecord, readRecord, resumeTail, writeRecord } from './resume.js'
|
|
16
|
+
import { selectionFor } from './models.js'
|
|
17
|
+
import {
|
|
18
|
+
attachSession,
|
|
19
|
+
createSession,
|
|
20
|
+
createSessionStore,
|
|
21
|
+
deliverToolResult,
|
|
22
|
+
dropSession,
|
|
23
|
+
failMissingResults,
|
|
24
|
+
resumeSession,
|
|
25
|
+
startRun,
|
|
26
|
+
updateChain,
|
|
27
|
+
usageDelta,
|
|
28
|
+
type Attached,
|
|
29
|
+
type CursorSession,
|
|
30
|
+
type CursorEvent,
|
|
31
|
+
type CursorSessionStore,
|
|
32
|
+
type SessionKey
|
|
33
|
+
} from './session.js'
|
|
34
|
+
import { readTranscript, replayInput, systemPreamble, userInput, type CursorContext } from './transcript.js'
|
|
35
|
+
|
|
36
|
+
export type CursorRuntime = { sessions: CursorSessionStore; fast: boolean }
|
|
37
|
+
|
|
38
|
+
export function createRuntime(): CursorRuntime {
|
|
39
|
+
return { sessions: createSessionStore(), fast: fastMode() }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TOOL_GRACE_MS = 250
|
|
43
|
+
|
|
44
|
+
type TurnReason = 'stop' | 'length' | 'toolUse'
|
|
45
|
+
type StepResult = { ok: true; reason: TurnReason } | { ok: false; error: string } | 'aborted'
|
|
46
|
+
|
|
47
|
+
function emptyUsage(): Usage {
|
|
48
|
+
return {
|
|
49
|
+
input: 0,
|
|
50
|
+
output: 0,
|
|
51
|
+
cacheRead: 0,
|
|
52
|
+
cacheWrite: 0,
|
|
53
|
+
totalTokens: 0,
|
|
54
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function streamCursor(
|
|
59
|
+
runtime: CursorRuntime,
|
|
60
|
+
model: Model<Api>,
|
|
61
|
+
context: CursorContext,
|
|
62
|
+
options?: SimpleStreamOptions
|
|
63
|
+
): AssistantMessageEventStream {
|
|
64
|
+
const stream = createAssistantMessageEventStream()
|
|
65
|
+
void runTurn(runtime, stream, model, context, options)
|
|
66
|
+
return stream
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runTurn(
|
|
70
|
+
runtime: CursorRuntime,
|
|
71
|
+
stream: AssistantMessageEventStream,
|
|
72
|
+
model: Model<Api>,
|
|
73
|
+
context: CursorContext,
|
|
74
|
+
options?: SimpleStreamOptions
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const output: AssistantMessage = {
|
|
77
|
+
role: 'assistant',
|
|
78
|
+
content: [],
|
|
79
|
+
api: model.api,
|
|
80
|
+
provider: model.provider,
|
|
81
|
+
model: model.id,
|
|
82
|
+
usage: emptyUsage(),
|
|
83
|
+
stopReason: 'stop',
|
|
84
|
+
timestamp: Date.now()
|
|
85
|
+
}
|
|
86
|
+
let session: CursorSession | undefined
|
|
87
|
+
|
|
88
|
+
const fail = (reason: 'error' | 'aborted', message: string): void => {
|
|
89
|
+
output.stopReason = reason
|
|
90
|
+
output.errorMessage = message
|
|
91
|
+
stream.push({ type: 'error', reason, error: output })
|
|
92
|
+
stream.end()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
if (options?.signal?.aborted) {
|
|
97
|
+
fail('aborted', 'Request aborted before dispatch')
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
if (options?.maxTokens === 1) {
|
|
101
|
+
output.stopReason = 'length'
|
|
102
|
+
stream.push({ type: 'start', partial: output })
|
|
103
|
+
stream.push({ type: 'done', reason: 'length', message: output })
|
|
104
|
+
stream.end()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const transcript = readTranscript(context)
|
|
109
|
+
const last = transcript.messages[transcript.messages.length - 1]
|
|
110
|
+
if (!last || last.role === 'assistant') {
|
|
111
|
+
throw new Error('the transcript must end with a user message or tool results')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const cwd = process.cwd()
|
|
115
|
+
const systemPrompt = [transcript.systemPrompt.trim(), systemPreamble(cwd)]
|
|
116
|
+
.filter((part) => part.length > 0)
|
|
117
|
+
.join('\n\n')
|
|
118
|
+
const key: SessionKey = { systemPrompt, toolsHash: transcript.toolsHash, cwd }
|
|
119
|
+
const sessionId = options?.sessionId ?? sha(systemPrompt)
|
|
120
|
+
const selection = selectionFor(model.id, options?.reasoning, runtime.fast)
|
|
121
|
+
let attached: Attached | undefined = attachSession(runtime.sessions, transcript, key)
|
|
122
|
+
if (attached?.session.dead) {
|
|
123
|
+
dropSession(runtime.sessions, attached.session)
|
|
124
|
+
attached = undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
stream.push({ type: 'start', partial: output })
|
|
128
|
+
|
|
129
|
+
if (attached) {
|
|
130
|
+
const active = attached.session
|
|
131
|
+
session = active
|
|
132
|
+
active.abortCleanup?.()
|
|
133
|
+
active.abortCleanup = undefined
|
|
134
|
+
const tail = transcript.messages.slice(attached.prefix)
|
|
135
|
+
const results = tail.filter((message): message is ToolResultMessage => message.role === 'toolResult')
|
|
136
|
+
const rest = tail.filter((message) => message.role !== 'toolResult')
|
|
137
|
+
|
|
138
|
+
if (active.awaiting.size > 0) {
|
|
139
|
+
const seen = new Set<string>()
|
|
140
|
+
for (const result of results) {
|
|
141
|
+
if (!active.awaiting.has(result.toolCallId)) continue
|
|
142
|
+
deliverToolResult(active, result)
|
|
143
|
+
seen.add(result.toolCallId)
|
|
144
|
+
}
|
|
145
|
+
failMissingResults(active, seen)
|
|
146
|
+
active.awaiting.clear()
|
|
147
|
+
const steering = userInput(rest)
|
|
148
|
+
if (steering.text.length > 0 && active.run?.steer) {
|
|
149
|
+
const note =
|
|
150
|
+
steering.images.length > 0
|
|
151
|
+
? `${steering.text}\n[${steering.images.length} image(s) could not be injected mid-turn]`
|
|
152
|
+
: steering.text
|
|
153
|
+
await active.run.steer(note)
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
const input = userInput(rest)
|
|
157
|
+
if (input.text.length === 0 && input.images.length === 0) {
|
|
158
|
+
throw new Error('the transcript has no new user message')
|
|
159
|
+
}
|
|
160
|
+
await startRun(active, input, selection)
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
const previous = runtime.sessions.sessions.get(sessionId)
|
|
164
|
+
if (previous?.busy) throw new Error('this Pi session already has a request in flight')
|
|
165
|
+
if (previous) dropSession(runtime.sessions, previous)
|
|
166
|
+
const record = options?.sessionId ? readRecord(options.sessionId) : undefined
|
|
167
|
+
const tail = record ? resumeTail(record, transcript, key) : undefined
|
|
168
|
+
const resumed =
|
|
169
|
+
record && tail
|
|
170
|
+
? await resumeSession(
|
|
171
|
+
runtime.sessions,
|
|
172
|
+
sessionId,
|
|
173
|
+
key,
|
|
174
|
+
systemPrompt,
|
|
175
|
+
transcript.tools,
|
|
176
|
+
transcript,
|
|
177
|
+
model.id,
|
|
178
|
+
record
|
|
179
|
+
)
|
|
180
|
+
: undefined
|
|
181
|
+
if (resumed && tail) {
|
|
182
|
+
session = resumed
|
|
183
|
+
await startRun(resumed, userInput(tail), selection)
|
|
184
|
+
} else {
|
|
185
|
+
if (record && options?.sessionId) dropRecord(options.sessionId)
|
|
186
|
+
const created = createSession(
|
|
187
|
+
runtime.sessions,
|
|
188
|
+
sessionId,
|
|
189
|
+
key,
|
|
190
|
+
systemPrompt,
|
|
191
|
+
transcript.tools,
|
|
192
|
+
transcript
|
|
193
|
+
)
|
|
194
|
+
session = created
|
|
195
|
+
await startRun(created, replayInput(transcript.messages), selection)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const active = session
|
|
200
|
+
const step = await readStep(active, stream, output, options)
|
|
201
|
+
if (step === 'aborted') {
|
|
202
|
+
dropSession(runtime.sessions, active)
|
|
203
|
+
fail('aborted', 'Request aborted')
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (!step.ok) {
|
|
207
|
+
dropSession(runtime.sessions, active)
|
|
208
|
+
fail('error', step.error)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
applyUsage(output, active)
|
|
213
|
+
updateChain(active, transcript, output)
|
|
214
|
+
const callIds = new Set<string>()
|
|
215
|
+
for (const block of output.content) if (block.type === 'toolCall') callIds.add(block.id)
|
|
216
|
+
active.awaiting = step.reason === 'toolUse' ? callIds : new Set()
|
|
217
|
+
if (step.reason === 'toolUse') {
|
|
218
|
+
watchAbort(runtime.sessions, active, options?.signal)
|
|
219
|
+
} else {
|
|
220
|
+
active.abortCleanup?.()
|
|
221
|
+
active.abortCleanup = undefined
|
|
222
|
+
active.run = undefined
|
|
223
|
+
const agentId = active.agent?.agentId
|
|
224
|
+
if (options?.sessionId && agentId) {
|
|
225
|
+
writeRecord({
|
|
226
|
+
sessionId: options.sessionId,
|
|
227
|
+
agentId,
|
|
228
|
+
cwd: key.cwd,
|
|
229
|
+
toolsHash: key.toolsHash,
|
|
230
|
+
count: active.count,
|
|
231
|
+
chain: active.chain,
|
|
232
|
+
at: Date.now()
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
stream.push({ type: 'done', reason: step.reason, message: output })
|
|
238
|
+
stream.end()
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (session) dropSession(runtime.sessions, session)
|
|
241
|
+
fail(options?.signal?.aborted ? 'aborted' : 'error', error instanceof Error ? error.message : String(error))
|
|
242
|
+
} finally {
|
|
243
|
+
if (session) {
|
|
244
|
+
session.busy = false
|
|
245
|
+
session.lastUsed = Date.now()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function applyUsage(output: AssistantMessage, session: CursorSession): void {
|
|
251
|
+
const delta = usageDelta(session.usage, session.reported)
|
|
252
|
+
if (!delta) return
|
|
253
|
+
session.reported = session.usage ? { ...session.usage } : undefined
|
|
254
|
+
output.usage.input = delta.inputTokens
|
|
255
|
+
output.usage.output = delta.outputTokens
|
|
256
|
+
output.usage.cacheRead = delta.cacheReadTokens
|
|
257
|
+
output.usage.cacheWrite = delta.cacheWriteTokens
|
|
258
|
+
output.usage.totalTokens =
|
|
259
|
+
delta.inputTokens + delta.outputTokens + delta.cacheReadTokens + delta.cacheWriteTokens
|
|
260
|
+
if (delta.reasoningTokens !== undefined) output.usage.reasoning = delta.reasoningTokens
|
|
261
|
+
output.usage.cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function watchAbort(store: CursorSessionStore, session: CursorSession, signal: AbortSignal | undefined): void {
|
|
265
|
+
if (!signal) return
|
|
266
|
+
const onAbort = () => dropSession(store, session)
|
|
267
|
+
if (signal.aborted) {
|
|
268
|
+
onAbort()
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
272
|
+
session.abortCleanup = () => signal.removeEventListener('abort', onAbort)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function readStep(
|
|
276
|
+
session: CursorSession,
|
|
277
|
+
stream: AssistantMessageEventStream,
|
|
278
|
+
output: AssistantMessage,
|
|
279
|
+
options?: SimpleStreamOptions
|
|
280
|
+
): Promise<StepResult> {
|
|
281
|
+
const blocks = output.content
|
|
282
|
+
let textIndex: number | undefined
|
|
283
|
+
let thinkingIndex: number | undefined
|
|
284
|
+
const pending: ToolCall[] = []
|
|
285
|
+
let grace: ReturnType<typeof setTimeout> | undefined
|
|
286
|
+
|
|
287
|
+
const closeText = (): void => {
|
|
288
|
+
if (textIndex === undefined) return
|
|
289
|
+
const block = blocks[textIndex]
|
|
290
|
+
if (block?.type === 'text') {
|
|
291
|
+
stream.push({ type: 'text_end', contentIndex: textIndex, content: block.text, partial: output })
|
|
292
|
+
}
|
|
293
|
+
textIndex = undefined
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const closeThinking = (): void => {
|
|
297
|
+
if (thinkingIndex === undefined) return
|
|
298
|
+
const block = blocks[thinkingIndex]
|
|
299
|
+
if (block?.type === 'thinking') {
|
|
300
|
+
stream.push({
|
|
301
|
+
type: 'thinking_end',
|
|
302
|
+
contentIndex: thinkingIndex,
|
|
303
|
+
content: block.thinking,
|
|
304
|
+
partial: output
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
thinkingIndex = undefined
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const closeBlocks = (): void => {
|
|
311
|
+
closeText()
|
|
312
|
+
closeThinking()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
for (;;) {
|
|
317
|
+
let event: CursorEvent | undefined
|
|
318
|
+
try {
|
|
319
|
+
event = await session.queue.next(options?.signal)
|
|
320
|
+
} catch {
|
|
321
|
+
return 'aborted'
|
|
322
|
+
}
|
|
323
|
+
if (!event) return { ok: false, error: 'the Cursor run ended without a result' }
|
|
324
|
+
|
|
325
|
+
if (event.kind === 'text') {
|
|
326
|
+
closeThinking()
|
|
327
|
+
if (textIndex === undefined) {
|
|
328
|
+
blocks.push({ type: 'text', text: '' })
|
|
329
|
+
textIndex = blocks.length - 1
|
|
330
|
+
stream.push({ type: 'text_start', contentIndex: textIndex, partial: output })
|
|
331
|
+
}
|
|
332
|
+
const block = blocks[textIndex]
|
|
333
|
+
if (block?.type !== 'text') continue
|
|
334
|
+
block.text += event.text
|
|
335
|
+
stream.push({ type: 'text_delta', contentIndex: textIndex, delta: event.text, partial: output })
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (event.kind === 'thinking') {
|
|
340
|
+
closeText()
|
|
341
|
+
if (thinkingIndex === undefined) {
|
|
342
|
+
blocks.push({ type: 'thinking', thinking: '', thinkingSignature: '' })
|
|
343
|
+
thinkingIndex = blocks.length - 1
|
|
344
|
+
stream.push({ type: 'thinking_start', contentIndex: thinkingIndex, partial: output })
|
|
345
|
+
}
|
|
346
|
+
const block = blocks[thinkingIndex]
|
|
347
|
+
if (block?.type !== 'thinking') continue
|
|
348
|
+
block.thinking += event.text
|
|
349
|
+
stream.push({
|
|
350
|
+
type: 'thinking_delta',
|
|
351
|
+
contentIndex: thinkingIndex,
|
|
352
|
+
delta: event.text,
|
|
353
|
+
partial: output
|
|
354
|
+
})
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (event.kind === 'tool') {
|
|
359
|
+
closeBlocks()
|
|
360
|
+
blocks.push({ type: 'toolCall', id: event.id, name: event.name, arguments: event.args })
|
|
361
|
+
const index = blocks.length - 1
|
|
362
|
+
const toolCall = blocks[index]
|
|
363
|
+
if (toolCall?.type !== 'toolCall') continue
|
|
364
|
+
pending.push(toolCall)
|
|
365
|
+
stream.push({ type: 'toolcall_start', contentIndex: index, partial: output })
|
|
366
|
+
stream.push({ type: 'toolcall_end', contentIndex: index, toolCall, partial: output })
|
|
367
|
+
if (grace) clearTimeout(grace)
|
|
368
|
+
grace = setTimeout(() => session.queue.push({ kind: 'grace' }), TOOL_GRACE_MS)
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (event.kind === 'grace') {
|
|
373
|
+
if (pending.length > 0) return { ok: true, reason: 'toolUse' }
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (event.kind === 'error') {
|
|
378
|
+
closeBlocks()
|
|
379
|
+
return event.message === 'aborted' ? 'aborted' : { ok: false, error: event.message }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
closeBlocks()
|
|
383
|
+
if (pending.length > 0) return { ok: true, reason: 'toolUse' }
|
|
384
|
+
return { ok: true, reason: 'stop' }
|
|
385
|
+
}
|
|
386
|
+
} finally {
|
|
387
|
+
if (grace) clearTimeout(grace)
|
|
388
|
+
}
|
|
389
|
+
}
|