@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/session.ts ADDED
@@ -0,0 +1,352 @@
1
+ import type { AgentOptions, Run, SDKAgent, SDKCustomTool, SDKCustomToolResult, TokenUsage } from '@cursor/sdk'
2
+ import type { AssistantMessage, Tool, ToolResultMessage } from '@earendil-works/pi-ai'
3
+
4
+ import { sha, type JsonObject } from './json.js'
5
+ import { selectionFor, type ModelSelection } from './models.js'
6
+ import { cursorApiKey, cursorSdk } from './sdk.js'
7
+ import {
8
+ fingerprint,
9
+ toSdkMessage,
10
+ withPreamble,
11
+ toolResultCustom,
12
+ toolSchema,
13
+ type CursorInput,
14
+ type Transcript
15
+ } from './transcript.js'
16
+
17
+ const MAX_SESSIONS = 6
18
+
19
+ export type SessionKey = {
20
+ systemPrompt: string
21
+ toolsHash: string
22
+ cwd: string
23
+ }
24
+
25
+ export type CursorEvent =
26
+ | { kind: 'text'; text: string }
27
+ | { kind: 'thinking'; text: string }
28
+ | { kind: 'tool'; id: string; name: string; args: JsonObject }
29
+ | { kind: 'grace' }
30
+ | { kind: 'done' }
31
+ | { kind: 'error'; message: string }
32
+
33
+ export type CursorSession = {
34
+ id: string
35
+ key: SessionKey
36
+ systemPrompt: string
37
+ tools: Tool[]
38
+ agent?: SDKAgent
39
+ run?: Run
40
+ queue: CursorEventQueue
41
+ parked: Map<string, (result: SDKCustomToolResult) => void>
42
+ ready: Map<string, SDKCustomToolResult>
43
+ awaiting: Set<string>
44
+ chain: string
45
+ count: number
46
+ usage?: TokenUsage
47
+ toolCounter: number
48
+ promptFallback?: boolean
49
+ promptSent?: boolean
50
+ busy: boolean
51
+ dead: boolean
52
+ lastUsed: number
53
+ abortCleanup?: () => void
54
+ }
55
+
56
+ export type CursorSessionStore = { sessions: Map<string, CursorSession> }
57
+ export type Attached = { session: CursorSession; prefix: number }
58
+
59
+ export class CursorEventQueue {
60
+ private readonly items: CursorEvent[] = []
61
+ private readonly waiters: ((event: CursorEvent | undefined) => void)[] = []
62
+ private closed = false
63
+
64
+ push(event: CursorEvent): void {
65
+ const waiter = this.waiters.shift()
66
+ if (waiter) waiter(event)
67
+ else this.items.push(event)
68
+ }
69
+
70
+ close(): void {
71
+ if (this.closed) return
72
+ this.closed = true
73
+ for (const waiter of this.waiters.splice(0)) waiter(undefined)
74
+ }
75
+
76
+ next(signal?: AbortSignal): Promise<CursorEvent | undefined> {
77
+ const queued = this.items.shift()
78
+ if (queued) return Promise.resolve(queued)
79
+ if (this.closed) return Promise.resolve(undefined)
80
+ return new Promise((resolve, reject) => {
81
+ let settled = false
82
+ const waiter = (event: CursorEvent | undefined) => {
83
+ if (settled) return
84
+ settled = true
85
+ signal?.removeEventListener('abort', onAbort)
86
+ resolve(event)
87
+ }
88
+ const onAbort = () => {
89
+ if (settled) return
90
+ settled = true
91
+ this.waiters.splice(this.waiters.indexOf(waiter), 1)
92
+ reject(new Error('aborted'))
93
+ }
94
+ this.waiters.push(waiter)
95
+ if (signal?.aborted) {
96
+ onAbort()
97
+ return
98
+ }
99
+ signal?.addEventListener('abort', onAbort, { once: true })
100
+ })
101
+ }
102
+ }
103
+
104
+ export function createSessionStore(): CursorSessionStore {
105
+ return { sessions: new Map() }
106
+ }
107
+
108
+ export function allSessions(store: CursorSessionStore): CursorSession[] {
109
+ return [...store.sessions.values()]
110
+ }
111
+
112
+ export function createSession(
113
+ store: CursorSessionStore,
114
+ id: string,
115
+ key: SessionKey,
116
+ systemPrompt: string,
117
+ tools: Tool[],
118
+ transcript: Transcript
119
+ ): CursorSession {
120
+ const session: CursorSession = {
121
+ id,
122
+ key,
123
+ systemPrompt,
124
+ tools,
125
+ queue: new CursorEventQueue(),
126
+ parked: new Map(),
127
+ ready: new Map(),
128
+ awaiting: new Set(),
129
+ chain: transcript.chain[transcript.messages.length] ?? transcript.chain[0] ?? sha('prompt'),
130
+ count: transcript.messages.length,
131
+ toolCounter: 0,
132
+ busy: true,
133
+ dead: false,
134
+ lastUsed: Date.now()
135
+ }
136
+ store.sessions.set(id, session)
137
+ evict(store)
138
+ return session
139
+ }
140
+
141
+ export function attachSession(
142
+ store: CursorSessionStore,
143
+ transcript: Transcript,
144
+ key: SessionKey
145
+ ): Attached | undefined {
146
+ for (const session of store.sessions.values()) {
147
+ if (session.dead || session.busy) continue
148
+ if (!sameKey(session.key, key)) continue
149
+ if (session.count > transcript.messages.length) continue
150
+ if (transcript.chain[session.count] !== session.chain) continue
151
+
152
+ const tail = transcript.messages.slice(session.count)
153
+ if (tail.some((message) => message.role === 'assistant')) continue
154
+
155
+ const results = tail.filter((message) => message.role === 'toolResult')
156
+ const resultIds = new Set(results.map((message) => message.toolCallId))
157
+ if (session.awaiting.size > 0) {
158
+ if (![...session.awaiting].every((id) => resultIds.has(id))) continue
159
+ } else if (resultIds.size > 0) {
160
+ continue
161
+ }
162
+
163
+ session.busy = true
164
+ session.lastUsed = Date.now()
165
+ return { session, prefix: session.count }
166
+ }
167
+ return undefined
168
+ }
169
+
170
+ function sameKey(left: SessionKey, right: SessionKey): boolean {
171
+ return left.systemPrompt === right.systemPrompt && left.toolsHash === right.toolsHash && left.cwd === right.cwd
172
+ }
173
+
174
+ export function updateChain(session: CursorSession, transcript: Transcript, output: AssistantMessage): void {
175
+ const base = transcript.chain[transcript.messages.length] ?? session.chain
176
+ session.chain = sha(`${base}\0${fingerprint(output)}`)
177
+ session.count = transcript.messages.length + 1
178
+ session.lastUsed = Date.now()
179
+ }
180
+
181
+ export async function ensureAgent(session: CursorSession, model: string): Promise<SDKAgent> {
182
+ if (session.agent) return session.agent
183
+ const { Agent } = await cursorSdk()
184
+ const create = (systemPrompt: string | undefined) => {
185
+ const options: AgentOptions = {
186
+ apiKey: cursorApiKey(),
187
+ model: selectionFor(model, undefined),
188
+ tools: ['mcp'],
189
+ local: {
190
+ cwd: session.key.cwd,
191
+ settingSources: [],
192
+ customTools: customTools(session)
193
+ }
194
+ }
195
+ if (systemPrompt !== undefined) options.systemPrompt = systemPrompt
196
+ return Agent.create(options)
197
+ }
198
+ try {
199
+ session.agent = await create(session.promptFallback ? undefined : session.systemPrompt)
200
+ return session.agent
201
+ } catch (error) {
202
+ const message = error instanceof Error ? error.message : String(error)
203
+ if (!session.promptFallback && /system.?prompt/i.test(message)) {
204
+ session.promptFallback = true
205
+ session.agent = await create(undefined)
206
+ return session.agent
207
+ }
208
+ if (/auth|api.?key|login|credential/i.test(message)) {
209
+ throw new Error(`Cursor is not logged in. Run /cursor login, then retry.\n${message}`, { cause: error })
210
+ }
211
+ throw new Error(`Cursor agent could not start: ${message}`, { cause: error })
212
+ }
213
+ }
214
+
215
+ function customTools(session: CursorSession) {
216
+ const tools: Record<string, SDKCustomTool> = {}
217
+ for (const tool of session.tools) {
218
+ tools[tool.name] = {
219
+ description: tool.description,
220
+ inputSchema: toolSchema(tool),
221
+ execute: (args, context) => {
222
+ const id = context.toolCallId ?? `pi_${++session.toolCounter}`
223
+ const ready = session.ready.get(id)
224
+ if (ready) {
225
+ session.ready.delete(id)
226
+ return ready
227
+ }
228
+ const value: JsonObject = JSON.parse(JSON.stringify(args))
229
+ return new Promise<SDKCustomToolResult>((resolve) => {
230
+ session.parked.set(id, resolve)
231
+ session.queue.push({ kind: 'tool', id, name: tool.name, args: value })
232
+ })
233
+ }
234
+ }
235
+ }
236
+ return tools
237
+ }
238
+
239
+ export async function startRun(session: CursorSession, input: CursorInput, selection: ModelSelection): Promise<void> {
240
+ const agent = await ensureAgent(session, selection.id)
241
+ const payload = session.promptFallback && !session.promptSent ? withPreamble(input, session.systemPrompt) : input
242
+ if (session.promptFallback) session.promptSent = true
243
+ session.usage = undefined
244
+ let run: Run
245
+ try {
246
+ run = await agent.send(toSdkMessage(payload), {
247
+ model: selection,
248
+ onDelta: ({ update }) => {
249
+ if (update.type === 'text-delta') session.queue.push({ kind: 'text', text: update.text })
250
+ else if (update.type === 'thinking-delta') session.queue.push({ kind: 'thinking', text: update.text })
251
+ else if (update.type === 'turn-ended' && update.usage) {
252
+ const usage = update.usage
253
+ session.usage = {
254
+ ...usage,
255
+ totalTokens:
256
+ usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
257
+ }
258
+ }
259
+ }
260
+ })
261
+ } catch (error) {
262
+ const message = error instanceof Error ? error.message : String(error)
263
+ if (!session.promptFallback && /system.?prompt/i.test(message)) {
264
+ session.promptFallback = true
265
+ session.agent?.close()
266
+ session.agent = undefined
267
+ await startRun(session, input, selection)
268
+ return
269
+ }
270
+ throw error
271
+ }
272
+ session.run = run
273
+ void run
274
+ .wait()
275
+ .then((result) => {
276
+ if (!session.usage && result.usage) session.usage = result.usage
277
+ session.queue.push({ kind: 'done' })
278
+ })
279
+ .catch((error: Error) => {
280
+ session.queue.push({ kind: 'error', message: error.message || String(error) })
281
+ })
282
+ }
283
+
284
+ export function deliverToolResult(session: CursorSession, message: ToolResultMessage): void {
285
+ const result = toolResultCustom(message)
286
+ const resolve = session.parked.get(message.toolCallId)
287
+ if (resolve) {
288
+ session.parked.delete(message.toolCallId)
289
+ resolve(result)
290
+ return
291
+ }
292
+ session.ready.set(message.toolCallId, result)
293
+ }
294
+
295
+ export function failMissingResults(session: CursorSession, seen: Set<string>): void {
296
+ for (const id of session.awaiting) {
297
+ if (seen.has(id)) continue
298
+ const result: SDKCustomToolResult = {
299
+ content: [{ type: 'text', text: 'Pi did not return a result for this tool call.' }],
300
+ isError: true
301
+ }
302
+ const resolve = session.parked.get(id)
303
+ if (resolve) {
304
+ session.parked.delete(id)
305
+ resolve(result)
306
+ } else {
307
+ session.ready.set(id, result)
308
+ }
309
+ }
310
+ }
311
+
312
+ export function failParked(session: CursorSession, reason: string): void {
313
+ for (const resolve of session.parked.values()) {
314
+ resolve({ content: [{ type: 'text', text: reason }], isError: true })
315
+ }
316
+ session.parked.clear()
317
+ session.ready.clear()
318
+ }
319
+
320
+ export function dropSession(store: CursorSessionStore, session: CursorSession): void {
321
+ session.dead = true
322
+ for (const [id, candidate] of store.sessions) if (candidate === session) store.sessions.delete(id)
323
+ session.abortCleanup?.()
324
+ session.abortCleanup = undefined
325
+ failParked(session, 'Pi closed this Cursor session.')
326
+ session.queue.close()
327
+ void session.run?.cancel().catch(() => undefined)
328
+ session.agent?.close()
329
+ }
330
+
331
+ export function dropSessions(store: CursorSessionStore): void {
332
+ for (const session of store.sessions.values()) dropSession(store, session)
333
+ }
334
+
335
+ export function sweep(store: CursorSessionStore, idleMs: number): void {
336
+ const now = Date.now()
337
+ for (const session of store.sessions.values()) {
338
+ if (session.busy || session.awaiting.size > 0) continue
339
+ if (now - session.lastUsed > idleMs) dropSession(store, session)
340
+ }
341
+ }
342
+
343
+ function evict(store: CursorSessionStore): void {
344
+ if (store.sessions.size <= MAX_SESSIONS) return
345
+ const idle = [...store.sessions.values()]
346
+ .filter((session) => !session.busy && session.awaiting.size === 0 && session.parked.size === 0)
347
+ .toSorted((left, right) => left.lastUsed - right.lastUsed)
348
+ while (store.sessions.size > MAX_SESSIONS && idle.length > 0) {
349
+ const session = idle.shift()
350
+ if (session) dropSession(store, session)
351
+ }
352
+ }
@@ -0,0 +1,212 @@
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 images: CursorImage[] = []
185
+ for (const message of messages) {
186
+ if (message.role !== 'user' || !Array.isArray(message.content)) continue
187
+ for (const block of message.content) {
188
+ if (block.type !== 'image') continue
189
+ images.push(image(block))
190
+ }
191
+ }
192
+ if (images.length > 0) parts.push(`The replay includes ${images.length} image(s) from earlier user messages.`)
193
+ return { text: parts.join('\n\n'), images }
194
+ }
195
+
196
+ export function withPreamble(input: CursorInput, prompt: string): CursorInput {
197
+ if (prompt.trim().length === 0) return input
198
+ return { text: `<system>\n${prompt.trim()}\n</system>\n\n${input.text}`, images: input.images }
199
+ }
200
+
201
+ export function toSdkMessage(input: CursorInput): string | SDKUserMessage {
202
+ if (input.images.length === 0) return input.text
203
+ return { text: input.text, images: input.images }
204
+ }
205
+
206
+ export function systemPreamble(cwd: string): string {
207
+ return [
208
+ '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.',
209
+ '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.',
210
+ `Working directory: ${cwd}`
211
+ ].join('\n')
212
+ }