@tduyng/pi-cursor 0.0.0-stage → 0.0.1
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 +87 -2
- package/package.json +42 -4
- package/src/index.ts +165 -0
- package/src/json.ts +74 -0
- package/src/sdk.ts +14 -0
- package/src/session.ts +352 -0
- package/src/transcript.ts +212 -0
- package/src/turn.ts +336 -0
package/src/turn.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
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 { selectionFor } from './models.js'
|
|
15
|
+
import {
|
|
16
|
+
attachSession,
|
|
17
|
+
createSession,
|
|
18
|
+
createSessionStore,
|
|
19
|
+
deliverToolResult,
|
|
20
|
+
dropSession,
|
|
21
|
+
failMissingResults,
|
|
22
|
+
startRun,
|
|
23
|
+
updateChain,
|
|
24
|
+
type Attached,
|
|
25
|
+
type CursorSession,
|
|
26
|
+
type CursorEvent,
|
|
27
|
+
type CursorSessionStore,
|
|
28
|
+
type SessionKey
|
|
29
|
+
} from './session.js'
|
|
30
|
+
import { readTranscript, replayInput, systemPreamble, userInput, type CursorContext } from './transcript.js'
|
|
31
|
+
|
|
32
|
+
export type CursorRuntime = { sessions: CursorSessionStore }
|
|
33
|
+
|
|
34
|
+
export function createRuntime(): CursorRuntime {
|
|
35
|
+
return { sessions: createSessionStore() }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TOOL_GRACE_MS = 250
|
|
39
|
+
|
|
40
|
+
type TurnReason = 'stop' | 'length' | 'toolUse'
|
|
41
|
+
type StepResult = { ok: true; reason: TurnReason } | { ok: false; error: string } | 'aborted'
|
|
42
|
+
|
|
43
|
+
function emptyUsage(): Usage {
|
|
44
|
+
return {
|
|
45
|
+
input: 0,
|
|
46
|
+
output: 0,
|
|
47
|
+
cacheRead: 0,
|
|
48
|
+
cacheWrite: 0,
|
|
49
|
+
totalTokens: 0,
|
|
50
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function streamCursor(
|
|
55
|
+
runtime: CursorRuntime,
|
|
56
|
+
model: Model<Api>,
|
|
57
|
+
context: CursorContext,
|
|
58
|
+
options?: SimpleStreamOptions
|
|
59
|
+
): AssistantMessageEventStream {
|
|
60
|
+
const stream = createAssistantMessageEventStream()
|
|
61
|
+
void runTurn(runtime, stream, model, context, options)
|
|
62
|
+
return stream
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runTurn(
|
|
66
|
+
runtime: CursorRuntime,
|
|
67
|
+
stream: AssistantMessageEventStream,
|
|
68
|
+
model: Model<Api>,
|
|
69
|
+
context: CursorContext,
|
|
70
|
+
options?: SimpleStreamOptions
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const output: AssistantMessage = {
|
|
73
|
+
role: 'assistant',
|
|
74
|
+
content: [],
|
|
75
|
+
api: model.api,
|
|
76
|
+
provider: model.provider,
|
|
77
|
+
model: model.id,
|
|
78
|
+
usage: emptyUsage(),
|
|
79
|
+
stopReason: 'stop',
|
|
80
|
+
timestamp: Date.now()
|
|
81
|
+
}
|
|
82
|
+
let session: CursorSession | undefined
|
|
83
|
+
|
|
84
|
+
const fail = (reason: 'error' | 'aborted', message: string): void => {
|
|
85
|
+
output.stopReason = reason
|
|
86
|
+
output.errorMessage = message
|
|
87
|
+
stream.push({ type: 'error', reason, error: output })
|
|
88
|
+
stream.end()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (options?.signal?.aborted) {
|
|
93
|
+
fail('aborted', 'Request aborted before dispatch')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (options?.maxTokens === 1) {
|
|
97
|
+
output.stopReason = 'length'
|
|
98
|
+
stream.push({ type: 'start', partial: output })
|
|
99
|
+
stream.push({ type: 'done', reason: 'length', message: output })
|
|
100
|
+
stream.end()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const transcript = readTranscript(context)
|
|
105
|
+
const last = transcript.messages[transcript.messages.length - 1]
|
|
106
|
+
if (!last || last.role === 'assistant') {
|
|
107
|
+
throw new Error('the transcript must end with a user message or tool results')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cwd = process.cwd()
|
|
111
|
+
const systemPrompt = [transcript.systemPrompt.trim(), systemPreamble(cwd)]
|
|
112
|
+
.filter((part) => part.length > 0)
|
|
113
|
+
.join('\n\n')
|
|
114
|
+
const key: SessionKey = { systemPrompt, toolsHash: transcript.toolsHash, cwd }
|
|
115
|
+
const sessionId = options?.sessionId ?? sha(systemPrompt)
|
|
116
|
+
const selection = selectionFor(model.id, options?.reasoning)
|
|
117
|
+
let attached: Attached | undefined = attachSession(runtime.sessions, transcript, key)
|
|
118
|
+
if (attached?.session.dead) {
|
|
119
|
+
dropSession(runtime.sessions, attached.session)
|
|
120
|
+
attached = undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
stream.push({ type: 'start', partial: output })
|
|
124
|
+
|
|
125
|
+
if (attached) {
|
|
126
|
+
const active = attached.session
|
|
127
|
+
session = active
|
|
128
|
+
active.abortCleanup?.()
|
|
129
|
+
active.abortCleanup = undefined
|
|
130
|
+
const tail = transcript.messages.slice(attached.prefix)
|
|
131
|
+
const results = tail.filter((message): message is ToolResultMessage => message.role === 'toolResult')
|
|
132
|
+
const rest = tail.filter((message) => message.role !== 'toolResult')
|
|
133
|
+
|
|
134
|
+
if (active.awaiting.size > 0) {
|
|
135
|
+
const seen = new Set<string>()
|
|
136
|
+
for (const result of results) {
|
|
137
|
+
if (!active.awaiting.has(result.toolCallId)) continue
|
|
138
|
+
deliverToolResult(active, result)
|
|
139
|
+
seen.add(result.toolCallId)
|
|
140
|
+
}
|
|
141
|
+
failMissingResults(active, seen)
|
|
142
|
+
active.awaiting.clear()
|
|
143
|
+
} else {
|
|
144
|
+
const input = userInput(rest)
|
|
145
|
+
if (input.text.length === 0 && input.images.length === 0) {
|
|
146
|
+
throw new Error('the transcript has no new user message')
|
|
147
|
+
}
|
|
148
|
+
await startRun(active, input, selection)
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
const previous = runtime.sessions.sessions.get(sessionId)
|
|
152
|
+
if (previous?.busy) throw new Error('this Pi session already has a request in flight')
|
|
153
|
+
if (previous) dropSession(runtime.sessions, previous)
|
|
154
|
+
const created = createSession(runtime.sessions, sessionId, key, systemPrompt, transcript.tools, transcript)
|
|
155
|
+
session = created
|
|
156
|
+
await startRun(created, replayInput(transcript.messages), selection)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const active = session
|
|
160
|
+
const step = await readStep(active, stream, output, options)
|
|
161
|
+
if (step === 'aborted') {
|
|
162
|
+
dropSession(runtime.sessions, active)
|
|
163
|
+
fail('aborted', 'Request aborted')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (!step.ok) {
|
|
167
|
+
dropSession(runtime.sessions, active)
|
|
168
|
+
fail('error', step.error)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
applyUsage(output, active)
|
|
173
|
+
updateChain(active, transcript, output)
|
|
174
|
+
const callIds = new Set<string>()
|
|
175
|
+
for (const block of output.content) if (block.type === 'toolCall') callIds.add(block.id)
|
|
176
|
+
active.awaiting = step.reason === 'toolUse' ? callIds : new Set()
|
|
177
|
+
if (step.reason === 'toolUse') {
|
|
178
|
+
watchAbort(runtime.sessions, active, options?.signal)
|
|
179
|
+
} else {
|
|
180
|
+
active.abortCleanup?.()
|
|
181
|
+
active.abortCleanup = undefined
|
|
182
|
+
active.run = undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
stream.push({ type: 'done', reason: step.reason, message: output })
|
|
186
|
+
stream.end()
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (session) dropSession(runtime.sessions, session)
|
|
189
|
+
fail(options?.signal?.aborted ? 'aborted' : 'error', error instanceof Error ? error.message : String(error))
|
|
190
|
+
} finally {
|
|
191
|
+
if (session) {
|
|
192
|
+
session.busy = false
|
|
193
|
+
session.lastUsed = Date.now()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function applyUsage(output: AssistantMessage, session: CursorSession): void {
|
|
199
|
+
const usage = session.usage
|
|
200
|
+
if (!usage) return
|
|
201
|
+
output.usage.input = usage.inputTokens
|
|
202
|
+
output.usage.output = usage.outputTokens
|
|
203
|
+
output.usage.cacheRead = usage.cacheReadTokens
|
|
204
|
+
output.usage.cacheWrite = usage.cacheWriteTokens
|
|
205
|
+
output.usage.totalTokens =
|
|
206
|
+
usage.totalTokens || usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
|
207
|
+
if (usage.reasoningTokens !== undefined) output.usage.reasoning = usage.reasoningTokens
|
|
208
|
+
output.usage.cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function watchAbort(store: CursorSessionStore, session: CursorSession, signal: AbortSignal | undefined): void {
|
|
212
|
+
if (!signal) return
|
|
213
|
+
const onAbort = () => dropSession(store, session)
|
|
214
|
+
if (signal.aborted) {
|
|
215
|
+
onAbort()
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
219
|
+
session.abortCleanup = () => signal.removeEventListener('abort', onAbort)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function readStep(
|
|
223
|
+
session: CursorSession,
|
|
224
|
+
stream: AssistantMessageEventStream,
|
|
225
|
+
output: AssistantMessage,
|
|
226
|
+
options?: SimpleStreamOptions
|
|
227
|
+
): Promise<StepResult> {
|
|
228
|
+
const blocks = output.content
|
|
229
|
+
let textIndex: number | undefined
|
|
230
|
+
let thinkingIndex: number | undefined
|
|
231
|
+
const pending: ToolCall[] = []
|
|
232
|
+
let grace: ReturnType<typeof setTimeout> | undefined
|
|
233
|
+
|
|
234
|
+
const closeText = (): void => {
|
|
235
|
+
if (textIndex === undefined) return
|
|
236
|
+
const block = blocks[textIndex]
|
|
237
|
+
if (block?.type === 'text') {
|
|
238
|
+
stream.push({ type: 'text_end', contentIndex: textIndex, content: block.text, partial: output })
|
|
239
|
+
}
|
|
240
|
+
textIndex = undefined
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const closeThinking = (): void => {
|
|
244
|
+
if (thinkingIndex === undefined) return
|
|
245
|
+
const block = blocks[thinkingIndex]
|
|
246
|
+
if (block?.type === 'thinking') {
|
|
247
|
+
stream.push({
|
|
248
|
+
type: 'thinking_end',
|
|
249
|
+
contentIndex: thinkingIndex,
|
|
250
|
+
content: block.thinking,
|
|
251
|
+
partial: output
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
thinkingIndex = undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const closeBlocks = (): void => {
|
|
258
|
+
closeText()
|
|
259
|
+
closeThinking()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
for (;;) {
|
|
264
|
+
let event: CursorEvent | undefined
|
|
265
|
+
try {
|
|
266
|
+
event = await session.queue.next(options?.signal)
|
|
267
|
+
} catch {
|
|
268
|
+
return 'aborted'
|
|
269
|
+
}
|
|
270
|
+
if (!event) return { ok: false, error: 'the Cursor run ended without a result' }
|
|
271
|
+
|
|
272
|
+
if (event.kind === 'text') {
|
|
273
|
+
closeThinking()
|
|
274
|
+
if (textIndex === undefined) {
|
|
275
|
+
blocks.push({ type: 'text', text: '' })
|
|
276
|
+
textIndex = blocks.length - 1
|
|
277
|
+
stream.push({ type: 'text_start', contentIndex: textIndex, partial: output })
|
|
278
|
+
}
|
|
279
|
+
const block = blocks[textIndex]
|
|
280
|
+
if (block?.type !== 'text') continue
|
|
281
|
+
block.text += event.text
|
|
282
|
+
stream.push({ type: 'text_delta', contentIndex: textIndex, delta: event.text, partial: output })
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (event.kind === 'thinking') {
|
|
287
|
+
closeText()
|
|
288
|
+
if (thinkingIndex === undefined) {
|
|
289
|
+
blocks.push({ type: 'thinking', thinking: '', thinkingSignature: '' })
|
|
290
|
+
thinkingIndex = blocks.length - 1
|
|
291
|
+
stream.push({ type: 'thinking_start', contentIndex: thinkingIndex, partial: output })
|
|
292
|
+
}
|
|
293
|
+
const block = blocks[thinkingIndex]
|
|
294
|
+
if (block?.type !== 'thinking') continue
|
|
295
|
+
block.thinking += event.text
|
|
296
|
+
stream.push({
|
|
297
|
+
type: 'thinking_delta',
|
|
298
|
+
contentIndex: thinkingIndex,
|
|
299
|
+
delta: event.text,
|
|
300
|
+
partial: output
|
|
301
|
+
})
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (event.kind === 'tool') {
|
|
306
|
+
closeBlocks()
|
|
307
|
+
blocks.push({ type: 'toolCall', id: event.id, name: event.name, arguments: event.args })
|
|
308
|
+
const index = blocks.length - 1
|
|
309
|
+
const toolCall = blocks[index]
|
|
310
|
+
if (toolCall?.type !== 'toolCall') continue
|
|
311
|
+
pending.push(toolCall)
|
|
312
|
+
stream.push({ type: 'toolcall_start', contentIndex: index, partial: output })
|
|
313
|
+
stream.push({ type: 'toolcall_end', contentIndex: index, toolCall, partial: output })
|
|
314
|
+
if (grace) clearTimeout(grace)
|
|
315
|
+
grace = setTimeout(() => session.queue.push({ kind: 'grace' }), TOOL_GRACE_MS)
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (event.kind === 'grace') {
|
|
320
|
+
if (pending.length > 0) return { ok: true, reason: 'toolUse' }
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (event.kind === 'error') {
|
|
325
|
+
closeBlocks()
|
|
326
|
+
return event.message === 'aborted' ? 'aborted' : { ok: false, error: event.message }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
closeBlocks()
|
|
330
|
+
if (pending.length > 0) return { ok: true, reason: 'toolUse' }
|
|
331
|
+
return { ok: true, reason: 'stop' }
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
if (grace) clearTimeout(grace)
|
|
335
|
+
}
|
|
336
|
+
}
|