@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/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
+ }