pi-provider-cursor-ask 0.1.0
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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/README.zh-CN.md +87 -0
- package/UPSTREAM_CHANGELOG.md +368 -0
- package/UPSTREAM_SOURCE.md +23 -0
- package/dist/index.js +54 -0
- package/package.json +97 -0
- package/src/auth/cli-credentials.ts +275 -0
- package/src/auth/consent.ts +25 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/oauth.ts +282 -0
- package/src/auth/refresh-guard.ts +93 -0
- package/src/client/bridge.ts +673 -0
- package/src/client/cursor-wire.ts +213 -0
- package/src/client/h2-unary.ts +142 -0
- package/src/client/index.ts +18 -0
- package/src/config/index.ts +69 -0
- package/src/diagnostics/diagnostics.ts +116 -0
- package/src/diagnostics/index.ts +1 -0
- package/src/extension/auth.ts +99 -0
- package/src/extension/commands.ts +163 -0
- package/src/extension/compaction-guard.ts +86 -0
- package/src/extension/debug-hooks.ts +359 -0
- package/src/extension/index.ts +8 -0
- package/src/extension/provider.ts +277 -0
- package/src/extension/quota-adapter.ts +175 -0
- package/src/extension/report-dashboard.ts +133 -0
- package/src/identity.ts +16 -0
- package/src/index.ts +186 -0
- package/src/models/ask-catalog.ts +384 -0
- package/src/models/catalog.json +1163 -0
- package/src/models/cost.ts +126 -0
- package/src/models/index.ts +6 -0
- package/src/models/limits.ts +36 -0
- package/src/models/parameterized.ts +416 -0
- package/src/models/processing.ts +313 -0
- package/src/proto/agent_pb.ts +14577 -0
- package/src/stream/bridge-session.ts +215 -0
- package/src/stream/client-transcript.ts +51 -0
- package/src/stream/config.ts +5 -0
- package/src/stream/context-normalize.ts +308 -0
- package/src/stream/context-usage.ts +168 -0
- package/src/stream/debug-log.ts +316 -0
- package/src/stream/drift.ts +122 -0
- package/src/stream/images.ts +201 -0
- package/src/stream/index.ts +68 -0
- package/src/stream/interaction-query.ts +369 -0
- package/src/stream/message-parsing.ts +402 -0
- package/src/stream/model-cache.ts +100 -0
- package/src/stream/model-discovery.ts +242 -0
- package/src/stream/model-routing.ts +100 -0
- package/src/stream/native-core.ts +2121 -0
- package/src/stream/pi-adapter.ts +414 -0
- package/src/stream/protocol.ts +63 -0
- package/src/stream/recovery.ts +494 -0
- package/src/stream/request-build.ts +668 -0
- package/src/stream/root-prompt.ts +184 -0
- package/src/stream/run-journal.ts +474 -0
- package/src/stream/run-usage.ts +107 -0
- package/src/stream/server-messages.ts +777 -0
- package/src/stream/session-state.ts +499 -0
- package/src/stream/stream-writer.ts +211 -0
- package/src/stream/thinking-filter.ts +63 -0
- package/src/stream/tool-schema.ts +185 -0
- package/src/stream/transport-errors.ts +150 -0
- package/src/stream/tuning.ts +250 -0
- package/src/stream/types.ts +330 -0
- package/src/types/enums.ts +103 -0
- package/src/types/index.ts +4 -0
- package/src/usage.ts +262 -0
- package/src/utils/cache-dir.ts +39 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/security.ts +68 -0
- package/src/utils/util.ts +43 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns Pi's OpenAI-shaped message list into the turn structure Cursor expects.
|
|
3
|
+
*
|
|
4
|
+
* Cursor's agent protocol is turn-oriented (user text + assistant steps + tool
|
|
5
|
+
* results), not a flat message array, so this module regroups messages into
|
|
6
|
+
* `ParsedTurn`s, reattaches tool-result images to the call that produced them,
|
|
7
|
+
* and folds context-mode side-channel messages into the system prompt.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
frameContextModeSideChannel as frameContextModeSideChannelImpl,
|
|
11
|
+
isContextModeSideChannelText as isContextModeSideChannelTextImpl,
|
|
12
|
+
normalizeMessagesForCursor as normalizeMessagesForCursorImpl,
|
|
13
|
+
systemPromptHasSessionMemory as systemPromptHasSessionMemoryImpl,
|
|
14
|
+
type OpenAIMessage as NormalizedOpenAIMessage,
|
|
15
|
+
} from "./context-normalize.js";
|
|
16
|
+
import { debugLog } from "./debug-log.js";
|
|
17
|
+
import {
|
|
18
|
+
decodeBase64Image,
|
|
19
|
+
mergeImages,
|
|
20
|
+
parseImageDataUrl,
|
|
21
|
+
type ImageDecodeOptions,
|
|
22
|
+
} from "./images.js";
|
|
23
|
+
import { stripInFlightResults as stripInFlightResultsImpl } from "./recovery.js";
|
|
24
|
+
import type {
|
|
25
|
+
CursorToolResultImagePayload,
|
|
26
|
+
OpenAIMessage,
|
|
27
|
+
ParsedImageContent,
|
|
28
|
+
ParsedMessages,
|
|
29
|
+
ParsedToolCallStep,
|
|
30
|
+
ParsedToolResult,
|
|
31
|
+
ParsedTurn,
|
|
32
|
+
ParsedTurnStep,
|
|
33
|
+
ToolResultInfo,
|
|
34
|
+
} from "./types.js";
|
|
35
|
+
|
|
36
|
+
export function textContent(content: OpenAIMessage["content"]): string {
|
|
37
|
+
if (content == null) return "";
|
|
38
|
+
if (typeof content === "string") return content;
|
|
39
|
+
return content
|
|
40
|
+
.filter((p) => p.type === "text" && p.text)
|
|
41
|
+
.map((p) => p.text!)
|
|
42
|
+
.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function contentHasImageParts(content: OpenAIMessage["content"]): boolean {
|
|
46
|
+
return (
|
|
47
|
+
Array.isArray(content) &&
|
|
48
|
+
content.some(
|
|
49
|
+
(part) =>
|
|
50
|
+
(part.type === "image_url" && !!part.image_url?.url) ||
|
|
51
|
+
(part.type === "image" && !!part.data && !!part.mimeType),
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function imageContent(
|
|
57
|
+
content: OpenAIMessage["content"],
|
|
58
|
+
options: ImageDecodeOptions = {},
|
|
59
|
+
): ParsedImageContent[] {
|
|
60
|
+
if (content == null || typeof content === "string") return [];
|
|
61
|
+
const images: ParsedImageContent[] = [];
|
|
62
|
+
for (const part of content) {
|
|
63
|
+
if (part.type === "image_url" && part.image_url?.url) {
|
|
64
|
+
const image = parseImageDataUrl(part.image_url.url, options);
|
|
65
|
+
if (image) images.push(image);
|
|
66
|
+
} else if (part.type === "image" && part.data && part.mimeType) {
|
|
67
|
+
const image = decodeBase64Image(part.data, part.mimeType, options);
|
|
68
|
+
if (image) images.push(image);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return images;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseToolResultImagePayloads(
|
|
75
|
+
payloads: CursorToolResultImagePayload[] | undefined,
|
|
76
|
+
): Map<string, ParsedImageContent[]> {
|
|
77
|
+
const byToolCallId = new Map<string, ParsedImageContent[]>();
|
|
78
|
+
for (const payload of payloads ?? []) {
|
|
79
|
+
if (!payload?.toolCallId || !Array.isArray(payload.images)) continue;
|
|
80
|
+
const images = payload.images
|
|
81
|
+
.map((image) =>
|
|
82
|
+
decodeBase64Image(image.data, image.mimeType, {
|
|
83
|
+
enforceCursorCliLimits: true,
|
|
84
|
+
dropInvalid: true,
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
.filter((image): image is ParsedImageContent => !!image);
|
|
88
|
+
if (images.length === 0) continue;
|
|
89
|
+
byToolCallId.set(
|
|
90
|
+
payload.toolCallId,
|
|
91
|
+
mergeImages(byToolCallId.get(payload.toolCallId), images) ?? [],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return byToolCallId;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isSyntheticToolResultImageMessage(msg: OpenAIMessage): boolean {
|
|
98
|
+
return (
|
|
99
|
+
msg.role === "user" &&
|
|
100
|
+
textContent(msg.content).trim() === "Attached image(s) from tool result:" &&
|
|
101
|
+
contentHasImageParts(msg.content)
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type ToolCallStepWithResult = ParsedToolCallStep & { result: ParsedToolResult };
|
|
106
|
+
|
|
107
|
+
export function isToolCallStepWithResult(step: ParsedTurnStep): step is ToolCallStepWithResult {
|
|
108
|
+
return step.kind === "toolCall" && step.result !== undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function attachSyntheticToolResultImages(
|
|
112
|
+
turn: ParsedTurn,
|
|
113
|
+
images: ParsedImageContent[],
|
|
114
|
+
): void {
|
|
115
|
+
if (images.length === 0) return;
|
|
116
|
+
const resultSteps = turn.steps
|
|
117
|
+
.filter(isToolCallStepWithResult)
|
|
118
|
+
.filter((step) => !step.result.images?.length);
|
|
119
|
+
if (resultSteps.length === 0) return;
|
|
120
|
+
|
|
121
|
+
const imageOnlySteps = resultSteps.filter(
|
|
122
|
+
(step) => step.result.content.trim() === "(see attached image)",
|
|
123
|
+
);
|
|
124
|
+
if (imageOnlySteps.length === images.length) {
|
|
125
|
+
imageOnlySteps.forEach((step, index) => {
|
|
126
|
+
step.result = { ...step.result, content: "", images: [images[index]!] };
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const target = imageOnlySteps.length === 1 ? imageOnlySteps[0]! : resultSteps.at(-1)!;
|
|
132
|
+
target.result = {
|
|
133
|
+
...target.result,
|
|
134
|
+
content: target.result.content.trim() === "(see attached image)" ? "" : target.result.content,
|
|
135
|
+
images: mergeImages(target.result.images, images),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function normalizeToolResultText(
|
|
140
|
+
content: string,
|
|
141
|
+
images: ParsedImageContent[] | undefined,
|
|
142
|
+
): string {
|
|
143
|
+
return images?.length && content.trim() === "(see attached image)" ? "" : content;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function parseToolCallArguments(raw: string): Record<string, unknown> {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(raw);
|
|
149
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
150
|
+
return parsed as Record<string, unknown>;
|
|
151
|
+
}
|
|
152
|
+
return { value: parsed };
|
|
153
|
+
} catch {
|
|
154
|
+
return raw ? { __raw: raw } : {};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function isToolCallStep(step: ParsedTurnStep): step is ParsedToolCallStep {
|
|
159
|
+
return step.kind === "toolCall";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getTurnToolCallResults(turn: ParsedTurn): Map<string, ParsedToolResult> {
|
|
163
|
+
const results = new Map<string, ParsedToolResult>();
|
|
164
|
+
for (const step of turn.steps) {
|
|
165
|
+
if (step.kind === "toolCall" && step.result) results.set(step.toolCallId, step.result);
|
|
166
|
+
}
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function appendAssistantTextToTurn(turn: ParsedTurn, text: string): void {
|
|
171
|
+
if (!text) return;
|
|
172
|
+
const last = turn.steps.at(-1);
|
|
173
|
+
if (last?.kind === "assistantText") {
|
|
174
|
+
last.text += text;
|
|
175
|
+
} else {
|
|
176
|
+
turn.steps.push({ kind: "assistantText", text });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function stripTurnRuntimeState(
|
|
181
|
+
turn: ParsedTurn & {
|
|
182
|
+
toolCallById?: Map<string, ParsedToolCallStep>;
|
|
183
|
+
sawToolResult?: boolean;
|
|
184
|
+
sawAssistantAfterToolResult?: boolean;
|
|
185
|
+
},
|
|
186
|
+
): ParsedTurn {
|
|
187
|
+
return {
|
|
188
|
+
userText: turn.userText,
|
|
189
|
+
steps: turn.steps,
|
|
190
|
+
...(turn.userImages?.length ? { userImages: turn.userImages } : {}),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function clonePlainValue(value: unknown): unknown {
|
|
195
|
+
// Tool-call arguments are JSON-compatible today; this clone keeps object/array
|
|
196
|
+
// structure isolated without trying to preserve arbitrary class instances.
|
|
197
|
+
if (
|
|
198
|
+
value == null ||
|
|
199
|
+
typeof value === "string" ||
|
|
200
|
+
typeof value === "number" ||
|
|
201
|
+
typeof value === "boolean"
|
|
202
|
+
)
|
|
203
|
+
return value;
|
|
204
|
+
if (value instanceof Uint8Array || Buffer.isBuffer(value)) {
|
|
205
|
+
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
206
|
+
return new Uint8Array(bytes);
|
|
207
|
+
}
|
|
208
|
+
if (Array.isArray(value)) return value.map((item) => clonePlainValue(item));
|
|
209
|
+
if (typeof value === "object") {
|
|
210
|
+
return Object.fromEntries(
|
|
211
|
+
Object.entries(value as Record<string, unknown>).map(([key, inner]) => [
|
|
212
|
+
key,
|
|
213
|
+
clonePlainValue(inner),
|
|
214
|
+
]),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function stripInFlightResults(turn: ParsedTurn): ParsedTurn {
|
|
221
|
+
return stripInFlightResultsImpl(turn);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function isContextModeSideChannelText(text: string): boolean {
|
|
225
|
+
return isContextModeSideChannelTextImpl(text);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function frameContextModeSideChannel(text: string): string {
|
|
229
|
+
return frameContextModeSideChannelImpl(text);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function systemPromptHasSessionMemory(systemPrompt: string): boolean {
|
|
233
|
+
return systemPromptHasSessionMemoryImpl(systemPrompt);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function normalizeMessagesForCursor(messages: OpenAIMessage[]): OpenAIMessage[] {
|
|
237
|
+
return normalizeMessagesForCursorImpl(messages as NormalizedOpenAIMessage[]) as OpenAIMessage[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function parseMessages(
|
|
241
|
+
messages: OpenAIMessage[],
|
|
242
|
+
toolResultImagePayloads?: CursorToolResultImagePayload[],
|
|
243
|
+
): ParsedMessages {
|
|
244
|
+
messages = normalizeMessagesForCursor(messages);
|
|
245
|
+
let systemPrompt = "You are a helpful assistant.";
|
|
246
|
+
const turns: ParsedTurn[] = [];
|
|
247
|
+
const toolResultImagesById = parseToolResultImagePayloads(toolResultImagePayloads);
|
|
248
|
+
|
|
249
|
+
debugLog("parse_messages.start", { messages });
|
|
250
|
+
|
|
251
|
+
const systemParts = messages
|
|
252
|
+
.filter((m) => m.role === "system")
|
|
253
|
+
.map((m) => textContent(m.content));
|
|
254
|
+
if (systemParts.length > 0) systemPrompt = systemParts.join("\n");
|
|
255
|
+
|
|
256
|
+
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
257
|
+
// Only the newest user message is one the caller can still fix, so it is the only place an
|
|
258
|
+
// unusable image is worth failing the request over. Everything older is decoded leniently — see
|
|
259
|
+
// ImageDecodeOptions.dropInvalid.
|
|
260
|
+
const liveUserIndex = nonSystem.reduce(
|
|
261
|
+
(last, msg, index) => (msg.role === "user" ? index : last),
|
|
262
|
+
-1,
|
|
263
|
+
);
|
|
264
|
+
const decodeOptions = (index: number): ImageDecodeOptions => ({
|
|
265
|
+
enforceCursorCliLimits: true,
|
|
266
|
+
dropInvalid: index !== liveUserIndex,
|
|
267
|
+
});
|
|
268
|
+
let currentTurn:
|
|
269
|
+
| (ParsedTurn & {
|
|
270
|
+
toolCallById: Map<string, ParsedToolCallStep>;
|
|
271
|
+
sawToolResult: boolean;
|
|
272
|
+
sawAssistantAfterToolResult: boolean;
|
|
273
|
+
})
|
|
274
|
+
| null = null;
|
|
275
|
+
|
|
276
|
+
const finalizeCurrentTurn = () => {
|
|
277
|
+
if (!currentTurn) return;
|
|
278
|
+
turns.push(stripTurnRuntimeState(currentTurn));
|
|
279
|
+
currentTurn = null;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
for (const [index, msg] of nonSystem.entries()) {
|
|
283
|
+
if (currentTurn && isSyntheticToolResultImageMessage(msg)) {
|
|
284
|
+
const hasMetadataImages = currentTurn.steps.some(
|
|
285
|
+
(step) => step.kind === "toolCall" && step.result?.images?.length,
|
|
286
|
+
);
|
|
287
|
+
if (!hasMetadataImages) {
|
|
288
|
+
// Tool output, not something the caller attached — always lenient, even when this
|
|
289
|
+
// synthetic message happens to be the last user-role entry.
|
|
290
|
+
attachSyntheticToolResultImages(
|
|
291
|
+
currentTurn,
|
|
292
|
+
imageContent(msg.content, { enforceCursorCliLimits: true, dropInvalid: true }),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (msg.role === "user") {
|
|
299
|
+
finalizeCurrentTurn();
|
|
300
|
+
const userImages = imageContent(msg.content, decodeOptions(index));
|
|
301
|
+
currentTurn = {
|
|
302
|
+
userText: textContent(msg.content),
|
|
303
|
+
steps: [],
|
|
304
|
+
...(userImages.length > 0 ? { userImages } : {}),
|
|
305
|
+
toolCallById: new Map(),
|
|
306
|
+
sawToolResult: false,
|
|
307
|
+
sawAssistantAfterToolResult: false,
|
|
308
|
+
};
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!currentTurn) continue;
|
|
313
|
+
|
|
314
|
+
if (msg.role === "assistant") {
|
|
315
|
+
if (typeof msg.thinking === "string" && msg.thinking.trim()) {
|
|
316
|
+
if (currentTurn.sawToolResult) currentTurn.sawAssistantAfterToolResult = true;
|
|
317
|
+
currentTurn.steps.push({ kind: "thinking", text: msg.thinking });
|
|
318
|
+
}
|
|
319
|
+
const text = textContent(msg.content);
|
|
320
|
+
if (text) {
|
|
321
|
+
if (currentTurn.sawToolResult) currentTurn.sawAssistantAfterToolResult = true;
|
|
322
|
+
currentTurn.steps.push({ kind: "assistantText", text });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const toolCall of msg.tool_calls ?? []) {
|
|
326
|
+
const step: ParsedToolCallStep = {
|
|
327
|
+
kind: "toolCall",
|
|
328
|
+
toolCallId: toolCall.id,
|
|
329
|
+
toolName: toolCall.function.name,
|
|
330
|
+
arguments: parseToolCallArguments(toolCall.function.arguments),
|
|
331
|
+
};
|
|
332
|
+
currentTurn.steps.push(step);
|
|
333
|
+
currentTurn.toolCallById.set(step.toolCallId, step);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Appended last so it reads as the end of the turn, after any tool calls
|
|
337
|
+
// the turn managed to emit before it was cut short.
|
|
338
|
+
const notice = msg.interrupted_notice?.trim();
|
|
339
|
+
if (notice) {
|
|
340
|
+
const last = currentTurn.steps.at(-1);
|
|
341
|
+
if (last?.kind === "assistantText") last.text = `${last.text}\n\n${notice}`;
|
|
342
|
+
else currentTurn.steps.push({ kind: "assistantText", text: notice });
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (msg.role === "tool") {
|
|
348
|
+
const toolCallId = msg.tool_call_id ?? "";
|
|
349
|
+
const inlineImages = imageContent(msg.content, decodeOptions(index));
|
|
350
|
+
const images = mergeImages(inlineImages, toolResultImagesById.get(toolCallId));
|
|
351
|
+
const content = normalizeToolResultText(textContent(msg.content), images);
|
|
352
|
+
const isError = msg.is_error === true;
|
|
353
|
+
const existing = toolCallId ? currentTurn.toolCallById.get(toolCallId) : undefined;
|
|
354
|
+
if (existing) {
|
|
355
|
+
existing.result = { content, images, isError };
|
|
356
|
+
} else {
|
|
357
|
+
const step: ParsedToolCallStep = {
|
|
358
|
+
kind: "toolCall",
|
|
359
|
+
toolCallId,
|
|
360
|
+
toolName: "",
|
|
361
|
+
arguments: {},
|
|
362
|
+
result: { content, images, isError },
|
|
363
|
+
};
|
|
364
|
+
currentTurn.steps.push(step);
|
|
365
|
+
if (toolCallId) currentTurn.toolCallById.set(toolCallId, step);
|
|
366
|
+
}
|
|
367
|
+
currentTurn.sawToolResult = true;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let userText = "";
|
|
372
|
+
let userImages: ParsedImageContent[] = [];
|
|
373
|
+
let toolResults: ToolResultInfo[] = [];
|
|
374
|
+
let inFlightTurn: ParsedTurn | undefined;
|
|
375
|
+
|
|
376
|
+
if (currentTurn) {
|
|
377
|
+
const toolCallSteps = currentTurn.steps.filter(isToolCallStep);
|
|
378
|
+
const hasAnyToolResults = toolCallSteps.some((step) => step.result);
|
|
379
|
+
const lastStep = currentTurn.steps.at(-1);
|
|
380
|
+
const isToolContinuation = lastStep?.kind === "toolCall";
|
|
381
|
+
|
|
382
|
+
if (currentTurn.steps.length === 0 || isToolContinuation) {
|
|
383
|
+
userText = currentTurn.userText;
|
|
384
|
+
userImages = currentTurn.userImages ?? [];
|
|
385
|
+
if (toolCallSteps.length > 0) inFlightTurn = stripInFlightResults(currentTurn);
|
|
386
|
+
if (hasAnyToolResults) {
|
|
387
|
+
toolResults = toolCallSteps.filter(isToolCallStepWithResult).map((step) => ({
|
|
388
|
+
toolCallId: step.toolCallId,
|
|
389
|
+
content: step.result.content,
|
|
390
|
+
...(step.result.images?.length ? { images: step.result.images } : {}),
|
|
391
|
+
...(step.result.isError ? { isError: true } : {}),
|
|
392
|
+
}));
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
turns.push(stripTurnRuntimeState(currentTurn));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const parsed = { systemPrompt, userText, userImages, turns, toolResults, inFlightTurn };
|
|
400
|
+
debugLog("parse_messages.end", parsed);
|
|
401
|
+
return parsed;
|
|
402
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process cache of the discovered Cursor model catalog.
|
|
3
|
+
*
|
|
4
|
+
* The in-memory TTL cache in ./model-discovery.ts only helps within one pi
|
|
5
|
+
* process; a fresh launch re-paid the full discovery round-trip (~4s, dominated
|
|
6
|
+
* by a 150KB AvailableModels response) before the provider could be registered
|
|
7
|
+
* at all. Persisting the raw discovery output lets startup register a
|
|
8
|
+
* last-known-good catalog synchronously and refresh in the background.
|
|
9
|
+
*
|
|
10
|
+
* The raw shapes are stored rather than pi `ModelConfig` rows because the
|
|
11
|
+
* effort/routing lookups that `streamSimple` depends on are derived from
|
|
12
|
+
* `parameters`, `requestedModelId` and `requiresMaxMode`, none of which survive
|
|
13
|
+
* the conversion to pi's model format.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
import { cacheFilePath } from "../utils/cache-dir.js";
|
|
18
|
+
import type { CursorParameterizedModel } from "../client/cursor-wire.js";
|
|
19
|
+
import type { CursorModel } from "./model-discovery.js";
|
|
20
|
+
|
|
21
|
+
const CACHE_FILE = "model-catalog.json";
|
|
22
|
+
const CACHE_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
/** Discard a persisted catalog older than this rather than serving something ancient. */
|
|
25
|
+
export const CATALOG_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export interface CachedCatalog {
|
|
28
|
+
version: number;
|
|
29
|
+
/** Hash of the token the catalog was discovered with, for staleness reporting. */
|
|
30
|
+
tokenHash: string;
|
|
31
|
+
savedAt: number;
|
|
32
|
+
rawModels: CursorModel[];
|
|
33
|
+
parameterizedModels: CursorParameterizedModel[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let memoized: CachedCatalog | null | undefined;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the persisted catalog. Synchronous by design: extension activation must
|
|
40
|
+
* be able to register models without awaiting anything.
|
|
41
|
+
*/
|
|
42
|
+
export function readCachedCatalog(): CachedCatalog | undefined {
|
|
43
|
+
if (memoized !== undefined) return memoized ?? undefined;
|
|
44
|
+
memoized = null;
|
|
45
|
+
const path = cacheFilePath(CACHE_FILE);
|
|
46
|
+
if (!path) return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<CachedCatalog>;
|
|
49
|
+
if (
|
|
50
|
+
parsed?.version !== CACHE_VERSION ||
|
|
51
|
+
typeof parsed.savedAt !== "number" ||
|
|
52
|
+
!Array.isArray(parsed.rawModels) ||
|
|
53
|
+
!Array.isArray(parsed.parameterizedModels)
|
|
54
|
+
) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
if (Date.now() - parsed.savedAt > CATALOG_MAX_AGE_MS) return undefined;
|
|
58
|
+
if (parsed.rawModels.length === 0 && parsed.parameterizedModels.length === 0) return undefined;
|
|
59
|
+
memoized = {
|
|
60
|
+
version: CACHE_VERSION,
|
|
61
|
+
tokenHash: typeof parsed.tokenHash === "string" ? parsed.tokenHash : "",
|
|
62
|
+
savedAt: parsed.savedAt,
|
|
63
|
+
rawModels: parsed.rawModels as CursorModel[],
|
|
64
|
+
parameterizedModels: parsed.parameterizedModels as CursorParameterizedModel[],
|
|
65
|
+
};
|
|
66
|
+
return memoized;
|
|
67
|
+
} catch {
|
|
68
|
+
// Missing or corrupt cache falls back to the bundled catalog.
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Persist a freshly discovered catalog. Never throws. */
|
|
74
|
+
export function writeCachedCatalog(entry: {
|
|
75
|
+
tokenHash: string;
|
|
76
|
+
rawModels: CursorModel[];
|
|
77
|
+
parameterizedModels: CursorParameterizedModel[];
|
|
78
|
+
}): void {
|
|
79
|
+
if (entry.rawModels.length === 0 && entry.parameterizedModels.length === 0) return;
|
|
80
|
+
const catalog: CachedCatalog = {
|
|
81
|
+
version: CACHE_VERSION,
|
|
82
|
+
tokenHash: entry.tokenHash,
|
|
83
|
+
savedAt: Date.now(),
|
|
84
|
+
rawModels: entry.rawModels,
|
|
85
|
+
parameterizedModels: entry.parameterizedModels,
|
|
86
|
+
};
|
|
87
|
+
memoized = catalog;
|
|
88
|
+
const path = cacheFilePath(CACHE_FILE);
|
|
89
|
+
if (!path) return;
|
|
90
|
+
try {
|
|
91
|
+
writeFileSync(path, JSON.stringify(catalog), { mode: 0o600 });
|
|
92
|
+
} catch {
|
|
93
|
+
// Best effort: an unwritable cache only costs the next launch a refresh.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Test helper: drop in-memory state so the next read hits disk again. */
|
|
98
|
+
export function resetCatalogCacheForTests(): void {
|
|
99
|
+
memoized = undefined;
|
|
100
|
+
}
|