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