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