cursor-opencode-provider 0.4.3 → 0.6.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.
Files changed (71) hide show
  1. package/README.md +33 -23
  2. package/dist/context/agents.d.ts +1 -1
  3. package/dist/context/agents.js +1 -1
  4. package/dist/context/auth-store.js +2 -2
  5. package/dist/context/build.d.ts +11 -1
  6. package/dist/context/build.js +76 -25
  7. package/dist/context/frozen.d.ts +29 -0
  8. package/dist/context/frozen.js +168 -0
  9. package/dist/context/index.d.ts +3 -2
  10. package/dist/context/index.js +3 -2
  11. package/dist/context/paths.d.ts +34 -53
  12. package/dist/context/paths.js +54 -118
  13. package/dist/context/rules.d.ts +1 -0
  14. package/dist/context/rules.js +2 -2
  15. package/dist/image-save-tool.d.ts +19 -0
  16. package/dist/image-save-tool.js +25 -0
  17. package/dist/image-save.d.ts +58 -0
  18. package/dist/image-save.js +189 -0
  19. package/dist/image-staging.d.ts +59 -0
  20. package/dist/image-staging.js +91 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/language-model.d.ts +13 -11
  23. package/dist/language-model.js +957 -150
  24. package/dist/plan-execution-kickoff.d.ts +55 -0
  25. package/dist/plan-execution-kickoff.js +162 -0
  26. package/dist/plugin-opencode2.js +52 -3
  27. package/dist/plugin-v2.d.ts +11 -9
  28. package/dist/plugin-v2.js +6 -13
  29. package/dist/plugin.d.ts +9 -0
  30. package/dist/plugin.js +61 -5
  31. package/dist/pricing-data.d.ts +16 -4
  32. package/dist/pricing-data.js +16 -4
  33. package/dist/protocol/ask-question.d.ts +133 -0
  34. package/dist/protocol/ask-question.js +320 -0
  35. package/dist/protocol/blob-reachability.d.ts +15 -0
  36. package/dist/protocol/blob-reachability.js +140 -0
  37. package/dist/protocol/blob-store.d.ts +37 -0
  38. package/dist/protocol/blob-store.js +115 -0
  39. package/dist/protocol/conversation-bind.d.ts +13 -0
  40. package/dist/protocol/conversation-bind.js +27 -1
  41. package/dist/protocol/conversation-persistence.d.ts +32 -0
  42. package/dist/protocol/conversation-persistence.js +504 -0
  43. package/dist/protocol/conversation-state.d.ts +16 -0
  44. package/dist/protocol/conversation-state.js +62 -0
  45. package/dist/protocol/create-plan.d.ts +141 -0
  46. package/dist/protocol/create-plan.js +373 -0
  47. package/dist/protocol/generate-image.d.ts +54 -0
  48. package/dist/protocol/generate-image.js +101 -0
  49. package/dist/protocol/interactions.d.ts +110 -8
  50. package/dist/protocol/interactions.js +295 -35
  51. package/dist/protocol/kv.d.ts +1 -0
  52. package/dist/protocol/kv.js +2 -0
  53. package/dist/protocol/messages.d.ts +2 -0
  54. package/dist/protocol/messages.js +148 -8
  55. package/dist/protocol/request.d.ts +2 -0
  56. package/dist/protocol/request.js +2 -0
  57. package/dist/protocol/switch-mode.d.ts +178 -0
  58. package/dist/protocol/switch-mode.js +417 -0
  59. package/dist/protocol/token-details.d.ts +33 -0
  60. package/dist/protocol/token-details.js +155 -0
  61. package/dist/protocol/tool-call-bridge.js +10 -5
  62. package/dist/protocol/tools.d.ts +44 -11
  63. package/dist/protocol/tools.js +171 -41
  64. package/dist/session.d.ts +39 -11
  65. package/dist/shared.d.ts +3 -0
  66. package/dist/shared.js +3 -0
  67. package/dist/usage.d.ts +42 -37
  68. package/dist/usage.js +201 -70
  69. package/dist/web-search-tool.d.ts +22 -26
  70. package/dist/web-search-tool.js +25 -15
  71. package/package.json +7 -4
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Cursor-native AskQuestion ⇄ OpenCode `question` translation.
3
+ *
4
+ * Cursor's AskQuestion is not a tool the client executes — the server raises an
5
+ * `InteractionQuery` (`ask_question_interaction_query`, field 3) and blocks the
6
+ * Run until the client answers with an `AskQuestionResult`. OpenCode has an
7
+ * equivalent host tool (`question`), so the provider translates rather than
8
+ * refusing: refusing made models narrate "the AskQuestion tool is unavailable"
9
+ * and fall back to prose instead of ever calling the advertised host tool.
10
+ *
11
+ * Every rule below mirrors Cursor CLI, decompiled at
12
+ * `~/Projects/cursor-mock-server/cursor/cli{,-local}`:
13
+ *
14
+ * - `src/utils/interaction-utils.ts` — `BU` (freeform option id), `Q7` (display
15
+ * options), `iX` (selection → success), `N$` (rejection + default reason).
16
+ * - `chunk-7076/dist/ui.js` (`askQuestionInteractionQuery` case) and
17
+ * `subagent/subagent-prompt-handler.js` (`requestAskQuestion`) — the
18
+ * sync/async split and the exact rejection reasons.
19
+ *
20
+ * Keep the reason strings byte-identical to the CLI's: they reach the model as
21
+ * tool feedback, and Cursor's server-side prompting is tuned against them.
22
+ */
23
+ /** Cursor's synthetic "type your own answer" option id (interaction-utils `BU`). */
24
+ export declare const FREEFORM_OPTION_ID = "__freeform_other__";
25
+ /**
26
+ * `PendingExec.resultField` marking a held-open AskQuestion. It is not an
27
+ * `ExecClientMessage` field: continuation dispatches on it to write an
28
+ * InteractionResponse or a ConversationAction instead of an exec result.
29
+ */
30
+ export declare const ASK_QUESTION_RESULT_FIELD = "ask_question_interaction_response";
31
+ /** Default rejection reason when the user skips (interaction-utils `N$`). */
32
+ export declare const SKIPPED_REASON = "Questions skipped by user";
33
+ /** Rejection used when the host dismisses the prompt (CLI `resolveAskQuestionPrompt`). */
34
+ export declare const DISMISSED_REASON = "Ask-question prompt dismissed";
35
+ export declare const MISSING_QUERY_REASON = "Missing ask-question query";
36
+ export declare const MISSING_ARGS_REASON = "Missing ask-question arguments";
37
+ export type CursorAskQuestionOption = {
38
+ id: string;
39
+ label: string;
40
+ };
41
+ export type CursorAskQuestionItem = {
42
+ id: string;
43
+ prompt: string;
44
+ options: CursorAskQuestionOption[];
45
+ allowMultiple: boolean;
46
+ };
47
+ export type CursorAskQuestionArgs = {
48
+ title: string;
49
+ questions: CursorAskQuestionItem[];
50
+ runAsync: boolean;
51
+ };
52
+ export type DecodedAskQuestionQuery = {
53
+ args: CursorAskQuestionArgs;
54
+ /**
55
+ * The exact `AskQuestionArgs` sub-message bytes. Async completion echoes
56
+ * `original_args` verbatim so fields this schema does not model survive.
57
+ */
58
+ rawArgs: Uint8Array;
59
+ toolCallId: string;
60
+ };
61
+ /** `AskQuestionResult` oneof, shaped for `encodeMessage`. */
62
+ export type AskQuestionResultMessage = Record<string, unknown>;
63
+ /**
64
+ * Decode an `ask_question_interaction_query` body. Returns undefined when the
65
+ * body carries no usable question set — the caller then rejects with the CLI's
66
+ * own "missing" reason rather than bridging an empty prompt to the host.
67
+ */
68
+ export declare function decodeAskQuestionQuery(queryBytes: Uint8Array): DecodedAskQuestionQuery | undefined;
69
+ /**
70
+ * Cursor CLI drops a trailing catch-all option before rendering, because it
71
+ * always appends its own freeform "Other" row (interaction-utils `Q7`).
72
+ * OpenCode's `question` tool does the same thing via `custom` (default true,
73
+ * and not settable from the tool schema), so the strip is required here too or
74
+ * the user sees two "Other" entries for one question.
75
+ */
76
+ export declare function isCatchAllOptionLabel(label: string): boolean;
77
+ /** Options as Cursor would display them: trailing catch-all removed. */
78
+ export declare function displayOptions(question: CursorAskQuestionItem): CursorAskQuestionOption[];
79
+ export type OpencodeQuestionInput = {
80
+ questions: Array<{
81
+ question: string;
82
+ header: string;
83
+ options: Array<{
84
+ label: string;
85
+ description: string;
86
+ }>;
87
+ multiple?: boolean;
88
+ }>;
89
+ };
90
+ /**
91
+ * Build the OpenCode `question` tool input. `Question.Prompt` requires
92
+ * `question`, `header`, and `options[{label, description}]`; Cursor options
93
+ * carry no description, so it stays empty rather than being invented.
94
+ */
95
+ export declare function askQuestionToolInput(args: CursorAskQuestionArgs): OpencodeQuestionInput;
96
+ /** `AskQuestionResult{rejected}` — interaction-utils `N$`. */
97
+ export declare function rejectedResult(reason?: string): AskQuestionResultMessage;
98
+ /** `AskQuestionResult{async}` — the immediate reply for `run_async` queries. */
99
+ export declare function asyncResult(): AskQuestionResultMessage;
100
+ /**
101
+ * Pull each question's answer text out of OpenCode's `question` tool output.
102
+ *
103
+ * The tool returns prose, not structured data (`metadata.answers` does not
104
+ * cross the AI SDK boundary):
105
+ *
106
+ * User has answered your questions: "<q1>"="<a, b>", "<q2>"="Unanswered". You
107
+ * can now continue with the user's answers in mind.
108
+ *
109
+ * The provider authored the question strings, so each answer is located by its
110
+ * own `"<question>"="` anchor scanning forward — robust against commas, quotes
111
+ * and `"="` inside question or answer text, and against duplicate questions.
112
+ * Returns undefined for a question whose anchor is absent, which is treated as
113
+ * unanswered rather than guessed at.
114
+ */
115
+ export declare function parseAnswerSegments(questions: readonly CursorAskQuestionItem[], output: string): Array<string | undefined>;
116
+ /**
117
+ * Map one question's answer labels back onto Cursor option ids.
118
+ *
119
+ * OpenCode returns labels, so a label that matches an option we sent becomes a
120
+ * `selected_option_id`; anything else is the user's custom answer and becomes
121
+ * `freeform_text`. Cursor CLI's `iX` substitutes the literal "Other" when the
122
+ * freeform row was chosen without text, so an empty custom answer does too.
123
+ */
124
+ export declare function answerForQuestion(question: CursorAskQuestionItem, segment: string | undefined): Record<string, unknown>;
125
+ /**
126
+ * Translate an OpenCode `question` tool result into an `AskQuestionResult`.
127
+ *
128
+ * A tool error means the host dismissed or failed the prompt — OpenCode's
129
+ * `Question.RejectedError` is the dismissal path — so it maps to the CLI's
130
+ * rejection rather than an empty success, which the model would read as "the
131
+ * user answered nothing".
132
+ */
133
+ export declare function askQuestionResultFromToolOutput(args: CursorAskQuestionArgs, output: string, isError: boolean): AskQuestionResultMessage;
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Cursor-native AskQuestion ⇄ OpenCode `question` translation.
3
+ *
4
+ * Cursor's AskQuestion is not a tool the client executes — the server raises an
5
+ * `InteractionQuery` (`ask_question_interaction_query`, field 3) and blocks the
6
+ * Run until the client answers with an `AskQuestionResult`. OpenCode has an
7
+ * equivalent host tool (`question`), so the provider translates rather than
8
+ * refusing: refusing made models narrate "the AskQuestion tool is unavailable"
9
+ * and fall back to prose instead of ever calling the advertised host tool.
10
+ *
11
+ * Every rule below mirrors Cursor CLI, decompiled at
12
+ * `~/Projects/cursor-mock-server/cursor/cli{,-local}`:
13
+ *
14
+ * - `src/utils/interaction-utils.ts` — `BU` (freeform option id), `Q7` (display
15
+ * options), `iX` (selection → success), `N$` (rejection + default reason).
16
+ * - `chunk-7076/dist/ui.js` (`askQuestionInteractionQuery` case) and
17
+ * `subagent/subagent-prompt-handler.js` (`requestAskQuestion`) — the
18
+ * sync/async split and the exact rejection reasons.
19
+ *
20
+ * Keep the reason strings byte-identical to the CLI's: they reach the model as
21
+ * tool feedback, and Cursor's server-side prompting is tuned against them.
22
+ */
23
+ import { decodeMessageSparse } from "./messages.js";
24
+ /** Cursor's synthetic "type your own answer" option id (interaction-utils `BU`). */
25
+ export const FREEFORM_OPTION_ID = "__freeform_other__";
26
+ /**
27
+ * `PendingExec.resultField` marking a held-open AskQuestion. It is not an
28
+ * `ExecClientMessage` field: continuation dispatches on it to write an
29
+ * InteractionResponse or a ConversationAction instead of an exec result.
30
+ */
31
+ export const ASK_QUESTION_RESULT_FIELD = "ask_question_interaction_response";
32
+ /** Default rejection reason when the user skips (interaction-utils `N$`). */
33
+ export const SKIPPED_REASON = "Questions skipped by user";
34
+ /** Rejection used when the host dismisses the prompt (CLI `resolveAskQuestionPrompt`). */
35
+ export const DISMISSED_REASON = "Ask-question prompt dismissed";
36
+ export const MISSING_QUERY_REASON = "Missing ask-question query";
37
+ export const MISSING_ARGS_REASON = "Missing ask-question arguments";
38
+ /** OpenCode's `question` tool caps its short header label at 30 chars. */
39
+ const HEADER_MAX_LENGTH = 30;
40
+ function asRecord(value) {
41
+ return value && typeof value === "object" && !Array.isArray(value)
42
+ ? value
43
+ : undefined;
44
+ }
45
+ function str(value) {
46
+ return typeof value === "string" ? value : "";
47
+ }
48
+ /**
49
+ * Extract the raw bytes of `AskQuestionInteractionQuery.args` (#1) without
50
+ * decoding. protobufjs hands back a decoded copy that would silently drop
51
+ * unknown fields on re-encode; the async completion action must echo the
52
+ * server's own bytes.
53
+ */
54
+ function extractArgsBytes(queryBytes) {
55
+ let offset = 0;
56
+ while (offset < queryBytes.length) {
57
+ let key = 0;
58
+ let shift = 0;
59
+ while (offset < queryBytes.length) {
60
+ const byte = queryBytes[offset++];
61
+ key |= (byte & 0x7f) << shift;
62
+ if ((byte & 0x80) === 0)
63
+ break;
64
+ shift += 7;
65
+ if (shift > 28)
66
+ return undefined;
67
+ }
68
+ const fieldNumber = key >>> 3;
69
+ const wireType = key & 7;
70
+ if (wireType === 2) {
71
+ let length = 0;
72
+ let lengthShift = 0;
73
+ while (offset < queryBytes.length) {
74
+ const byte = queryBytes[offset++];
75
+ length |= (byte & 0x7f) << lengthShift;
76
+ if ((byte & 0x80) === 0)
77
+ break;
78
+ lengthShift += 7;
79
+ if (lengthShift > 28)
80
+ return undefined;
81
+ }
82
+ if (offset + length > queryBytes.length)
83
+ return undefined;
84
+ if (fieldNumber === 1)
85
+ return queryBytes.subarray(offset, offset + length);
86
+ offset += length;
87
+ }
88
+ else if (wireType === 0) {
89
+ while (offset < queryBytes.length && (queryBytes[offset++] & 0x80) !== 0) {
90
+ // skip varint continuation bytes
91
+ }
92
+ }
93
+ else if (wireType === 5) {
94
+ offset += 4;
95
+ }
96
+ else if (wireType === 1) {
97
+ offset += 8;
98
+ }
99
+ else {
100
+ return undefined;
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+ /**
106
+ * Decode an `ask_question_interaction_query` body. Returns undefined when the
107
+ * body carries no usable question set — the caller then rejects with the CLI's
108
+ * own "missing" reason rather than bridging an empty prompt to the host.
109
+ */
110
+ export function decodeAskQuestionQuery(queryBytes) {
111
+ let decoded;
112
+ try {
113
+ decoded = decodeMessageSparse("AskQuestionInteractionQuery", queryBytes);
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ const rawArgs = extractArgsBytes(queryBytes);
119
+ const argsRecord = asRecord(decoded.args);
120
+ if (!argsRecord || !rawArgs)
121
+ return undefined;
122
+ const questions = Array.isArray(argsRecord.questions)
123
+ ? argsRecord.questions.flatMap((raw) => {
124
+ const item = asRecord(raw);
125
+ if (!item)
126
+ return [];
127
+ const prompt = str(item.prompt);
128
+ if (!prompt)
129
+ return [];
130
+ const options = Array.isArray(item.options)
131
+ ? item.options.flatMap((rawOption) => {
132
+ const option = asRecord(rawOption);
133
+ if (!option)
134
+ return [];
135
+ const label = str(option.label);
136
+ if (!label)
137
+ return [];
138
+ return [{ id: str(option.id), label }];
139
+ })
140
+ : [];
141
+ return [{
142
+ id: str(item.id),
143
+ prompt,
144
+ options,
145
+ allowMultiple: item.allow_multiple === true,
146
+ }];
147
+ })
148
+ : [];
149
+ if (questions.length === 0)
150
+ return undefined;
151
+ return {
152
+ args: {
153
+ title: str(argsRecord.title),
154
+ questions,
155
+ runAsync: argsRecord.run_async === true,
156
+ },
157
+ rawArgs,
158
+ toolCallId: str(decoded.tool_call_id),
159
+ };
160
+ }
161
+ /**
162
+ * Cursor CLI drops a trailing catch-all option before rendering, because it
163
+ * always appends its own freeform "Other" row (interaction-utils `Q7`).
164
+ * OpenCode's `question` tool does the same thing via `custom` (default true,
165
+ * and not settable from the tool schema), so the strip is required here too or
166
+ * the user sees two "Other" entries for one question.
167
+ */
168
+ export function isCatchAllOptionLabel(label) {
169
+ const normalized = label.toLowerCase().trim();
170
+ return (normalized === "other"
171
+ || normalized === "something else"
172
+ || normalized.startsWith("other:")
173
+ || normalized.startsWith("other -")
174
+ || normalized.startsWith("other (")
175
+ || normalized.startsWith("something else:")
176
+ || normalized.startsWith("something else -")
177
+ || normalized.startsWith("something else ("));
178
+ }
179
+ /** Options as Cursor would display them: trailing catch-all removed. */
180
+ export function displayOptions(question) {
181
+ const last = question.options.at(-1);
182
+ if (last && isCatchAllOptionLabel(last.label))
183
+ return question.options.slice(0, -1);
184
+ return question.options.slice();
185
+ }
186
+ function header(title) {
187
+ const trimmed = title.trim();
188
+ if (!trimmed)
189
+ return "Question";
190
+ return trimmed.length > HEADER_MAX_LENGTH
191
+ ? trimmed.slice(0, HEADER_MAX_LENGTH - 1).trimEnd() + "…"
192
+ : trimmed;
193
+ }
194
+ /**
195
+ * Build the OpenCode `question` tool input. `Question.Prompt` requires
196
+ * `question`, `header`, and `options[{label, description}]`; Cursor options
197
+ * carry no description, so it stays empty rather than being invented.
198
+ */
199
+ export function askQuestionToolInput(args) {
200
+ return {
201
+ questions: args.questions.map((question) => ({
202
+ question: question.prompt,
203
+ header: header(args.title),
204
+ options: displayOptions(question).map((option) => ({
205
+ label: option.label,
206
+ description: "",
207
+ })),
208
+ ...(question.allowMultiple ? { multiple: true } : {}),
209
+ })),
210
+ };
211
+ }
212
+ /** `AskQuestionResult{rejected}` — interaction-utils `N$`. */
213
+ export function rejectedResult(reason) {
214
+ return { rejected: { reason: reason ?? SKIPPED_REASON } };
215
+ }
216
+ /** `AskQuestionResult{async}` — the immediate reply for `run_async` queries. */
217
+ export function asyncResult() {
218
+ return { async: {} };
219
+ }
220
+ /**
221
+ * Pull each question's answer text out of OpenCode's `question` tool output.
222
+ *
223
+ * The tool returns prose, not structured data (`metadata.answers` does not
224
+ * cross the AI SDK boundary):
225
+ *
226
+ * User has answered your questions: "<q1>"="<a, b>", "<q2>"="Unanswered". You
227
+ * can now continue with the user's answers in mind.
228
+ *
229
+ * The provider authored the question strings, so each answer is located by its
230
+ * own `"<question>"="` anchor scanning forward — robust against commas, quotes
231
+ * and `"="` inside question or answer text, and against duplicate questions.
232
+ * Returns undefined for a question whose anchor is absent, which is treated as
233
+ * unanswered rather than guessed at.
234
+ */
235
+ export function parseAnswerSegments(questions, output) {
236
+ const answers = [];
237
+ let cursor = 0;
238
+ for (const question of questions) {
239
+ const anchor = `"${question.prompt}"="`;
240
+ const start = output.indexOf(anchor, cursor);
241
+ if (start < 0) {
242
+ answers.push(undefined);
243
+ continue;
244
+ }
245
+ const valueStart = start + anchor.length;
246
+ // The value ends at the last quote before the next anchor (or the trailing
247
+ // `. You can now continue…`). Searching backwards from the next anchor
248
+ // keeps quoted text inside an answer intact.
249
+ const nextAnchorStart = questions
250
+ .map((other) => output.indexOf(`"${other.prompt}"="`, valueStart))
251
+ .filter((index) => index > valueStart)
252
+ .reduce((min, index) => (min < 0 || index < min ? index : min), -1);
253
+ const searchEnd = nextAnchorStart >= 0 ? nextAnchorStart : output.length;
254
+ const valueEnd = output.lastIndexOf('"', searchEnd - 1);
255
+ if (valueEnd < valueStart) {
256
+ answers.push(undefined);
257
+ continue;
258
+ }
259
+ answers.push(output.slice(valueStart, valueEnd));
260
+ cursor = valueEnd;
261
+ }
262
+ return answers;
263
+ }
264
+ /** OpenCode's placeholder for a question the user left blank. */
265
+ const UNANSWERED = "Unanswered";
266
+ /**
267
+ * Map one question's answer labels back onto Cursor option ids.
268
+ *
269
+ * OpenCode returns labels, so a label that matches an option we sent becomes a
270
+ * `selected_option_id`; anything else is the user's custom answer and becomes
271
+ * `freeform_text`. Cursor CLI's `iX` substitutes the literal "Other" when the
272
+ * freeform row was chosen without text, so an empty custom answer does too.
273
+ */
274
+ export function answerForQuestion(question, segment) {
275
+ const answer = { question_id: question.id };
276
+ if (segment === undefined || segment.trim().length === 0 || segment.trim() === UNANSWERED) {
277
+ return answer;
278
+ }
279
+ const byLabel = new Map(displayOptions(question).map((option) => [option.label.trim().toLowerCase(), option.id]));
280
+ const selected = [];
281
+ const custom = [];
282
+ // `multiple: true` answers arrive joined with ", " (question tool `formatted`).
283
+ for (const piece of segment.split(", ")) {
284
+ const label = piece.trim();
285
+ if (!label)
286
+ continue;
287
+ const optionId = byLabel.get(label.toLowerCase());
288
+ if (optionId !== undefined)
289
+ selected.push(optionId);
290
+ else
291
+ custom.push(label);
292
+ }
293
+ if (selected.length > 0)
294
+ answer.selected_option_ids = selected;
295
+ if (custom.length > 0)
296
+ answer.freeform_text = custom.join(", ");
297
+ else if (selected.length === 0)
298
+ answer.freeform_text = "Other";
299
+ return answer;
300
+ }
301
+ /**
302
+ * Translate an OpenCode `question` tool result into an `AskQuestionResult`.
303
+ *
304
+ * A tool error means the host dismissed or failed the prompt — OpenCode's
305
+ * `Question.RejectedError` is the dismissal path — so it maps to the CLI's
306
+ * rejection rather than an empty success, which the model would read as "the
307
+ * user answered nothing".
308
+ */
309
+ export function askQuestionResultFromToolOutput(args, output, isError) {
310
+ if (isError) {
311
+ const reason = output.trim();
312
+ return rejectedResult(reason.length > 0 ? reason : DISMISSED_REASON);
313
+ }
314
+ const segments = parseAnswerSegments(args.questions, output);
315
+ return {
316
+ success: {
317
+ answers: args.questions.map((question, index) => answerForQuestion(question, segments[index])),
318
+ },
319
+ };
320
+ }
@@ -0,0 +1,15 @@
1
+ type ResolveBlob = (id: Uint8Array) => Uint8Array | undefined;
2
+ /**
3
+ * Return the blob ids reachable from Cursor's latest ConversationStateStructure.
4
+ *
5
+ * This mirrors Cursor CLI's conversation-export traversal: turns lead to their
6
+ * user message and steps, shell turns lead to command/output, state-level
7
+ * summaries/todos/prompts are direct refs, and subagent states recurse. Unknown
8
+ * protobuf fields are deliberately ignored for forward compatibility.
9
+ *
10
+ * `resolveBlob` must also resolve Cursor's content-as-id values. Throwing on a
11
+ * missing hash or malformed referenced message makes the caller retain its full
12
+ * snapshot rather than risk publishing an incomplete restart graph.
13
+ */
14
+ export declare function collectReachableConversationBlobIds(checkpoint: Uint8Array, resolveBlob: ResolveBlob): Set<string>;
15
+ export {};
@@ -0,0 +1,140 @@
1
+ import protobuf from "protobufjs";
2
+ const MAX_NESTED_STATE_DEPTH = 64;
3
+ function hex(bytes) {
4
+ let result = "";
5
+ for (let index = 0; index < bytes.length; index++) {
6
+ result += bytes[index].toString(16).padStart(2, "0");
7
+ }
8
+ return result;
9
+ }
10
+ /** Read only length-delimited fields while still validating the complete protobuf. */
11
+ function bytesFields(data) {
12
+ const reader = protobuf.Reader.create(data);
13
+ const fields = [];
14
+ while (reader.pos < reader.len) {
15
+ const tag = reader.uint32();
16
+ const number = tag >>> 3;
17
+ const wireType = tag & 7;
18
+ if (number === 0)
19
+ throw new Error("invalid protobuf field zero");
20
+ if (wireType === 2)
21
+ fields.push({ number, bytes: reader.bytes() });
22
+ else
23
+ reader.skipType(wireType);
24
+ }
25
+ return fields;
26
+ }
27
+ function mapValue(entry) {
28
+ return bytesFields(entry).find((field) => field.number === 2)?.bytes;
29
+ }
30
+ /**
31
+ * Return the blob ids reachable from Cursor's latest ConversationStateStructure.
32
+ *
33
+ * This mirrors Cursor CLI's conversation-export traversal: turns lead to their
34
+ * user message and steps, shell turns lead to command/output, state-level
35
+ * summaries/todos/prompts are direct refs, and subagent states recurse. Unknown
36
+ * protobuf fields are deliberately ignored for forward compatibility.
37
+ *
38
+ * `resolveBlob` must also resolve Cursor's content-as-id values. Throwing on a
39
+ * missing hash or malformed referenced message makes the caller retain its full
40
+ * snapshot rather than risk publishing an incomplete restart graph.
41
+ */
42
+ export function collectReachableConversationBlobIds(checkpoint, resolveBlob) {
43
+ const reachable = new Set();
44
+ const reference = (id) => {
45
+ if (id.length === 0)
46
+ return undefined;
47
+ reachable.add(hex(id));
48
+ const value = resolveBlob(id);
49
+ if (!value)
50
+ throw new Error(`missing referenced blob ${hex(id)}`);
51
+ return value;
52
+ };
53
+ const walkUserMessage = (data) => {
54
+ const selectedContext = bytesFields(data).find((field) => field.number === 3)?.bytes;
55
+ if (!selectedContext)
56
+ return;
57
+ for (const imageField of bytesFields(selectedContext)) {
58
+ if (imageField.number !== 1)
59
+ continue;
60
+ for (const imagePart of bytesFields(imageField.bytes)) {
61
+ if (imagePart.number === 1) {
62
+ reference(imagePart.bytes);
63
+ }
64
+ else if (imagePart.number === 9) {
65
+ const blobId = bytesFields(imagePart.bytes).find((field) => field.number === 1)?.bytes;
66
+ if (blobId)
67
+ reference(blobId);
68
+ }
69
+ }
70
+ }
71
+ };
72
+ const walkTurn = (data) => {
73
+ for (const turnField of bytesFields(data)) {
74
+ if (turnField.number === 1) {
75
+ for (const agentField of bytesFields(turnField.bytes)) {
76
+ if (agentField.number === 1) {
77
+ const userMessage = reference(agentField.bytes);
78
+ if (userMessage)
79
+ walkUserMessage(userMessage);
80
+ }
81
+ else if (agentField.number === 2) {
82
+ reference(agentField.bytes);
83
+ }
84
+ }
85
+ return;
86
+ }
87
+ if (turnField.number === 2) {
88
+ for (const shellField of bytesFields(turnField.bytes)) {
89
+ if (shellField.number === 1 || shellField.number === 2) {
90
+ reference(shellField.bytes);
91
+ }
92
+ }
93
+ return;
94
+ }
95
+ }
96
+ };
97
+ const walkPersistedSubagent = (data, depth) => {
98
+ const nestedState = bytesFields(data).find((field) => field.number === 1)?.bytes;
99
+ if (nestedState)
100
+ walkState(nestedState, depth + 1);
101
+ };
102
+ const walkState = (data, depth) => {
103
+ if (depth > MAX_NESTED_STATE_DEPTH)
104
+ throw new Error("conversation state nesting is too deep");
105
+ for (const field of bytesFields(data)) {
106
+ switch (field.number) {
107
+ case 8: {
108
+ const turn = reference(field.bytes);
109
+ if (turn)
110
+ walkTurn(turn);
111
+ break;
112
+ }
113
+ case 1: // root_prompt_messages_json
114
+ case 3: // todos
115
+ case 6: // summary
116
+ case 7: // plan
117
+ case 13: // summary_archives
118
+ reference(field.bytes);
119
+ break;
120
+ case 16: { // map<string, SubagentPersistedState> subagent_states
121
+ const persisted = mapValue(field.bytes);
122
+ if (persisted)
123
+ walkPersistedSubagent(persisted, depth);
124
+ break;
125
+ }
126
+ case 31: { // map<string, bytes> subagent_state_refs
127
+ const subagentId = mapValue(field.bytes);
128
+ if (!subagentId)
129
+ break;
130
+ const persisted = reference(subagentId);
131
+ if (persisted)
132
+ walkPersistedSubagent(persisted, depth);
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ };
138
+ walkState(checkpoint, 0);
139
+ return reachable;
140
+ }
@@ -6,10 +6,47 @@
6
6
  * fails ("Unexpected token ... is not valid JSON").
7
7
  *
8
8
  * This store is keyed by conversation_id and survives across Run streams.
9
+ * At a successful TurnEnded it is compacted to the graph reachable from the
10
+ * latest checkpoint, matching Cursor CLI's conversation-export traversal.
9
11
  */
10
12
  export declare function setConversationBlob(conversationId: string, blobId: Uint8Array, blobData: Uint8Array): string;
11
13
  export declare function getConversationBlob(conversationId: string, blobId: Uint8Array): Uint8Array | undefined;
12
14
  export declare function conversationBlobCount(conversationId: string): number;
15
+ export type ConversationBlobSnapshot = {
16
+ id: string;
17
+ data: Uint8Array;
18
+ };
19
+ export type ConversationBlobCompaction = {
20
+ blobs: ConversationBlobSnapshot[];
21
+ beforeCount: number;
22
+ beforeBytes: number;
23
+ afterCount: number;
24
+ afterBytes: number;
25
+ compacted: boolean;
26
+ fallbackReason?: string;
27
+ };
28
+ export type ConversationBlobGraphStats = {
29
+ count: number;
30
+ bytes: number;
31
+ complete: boolean;
32
+ fallbackReason?: string;
33
+ };
34
+ /**
35
+ * Measure the checkpoint-reachable blob graph without mutating the store.
36
+ * Incomplete graphs conservatively report the complete retained bucket, since
37
+ * that is what the KV channel may have to serve if the checkpoint is used.
38
+ */
39
+ export declare function inspectConversationBlobGraph(conversationId: string, checkpoint: Uint8Array | undefined): ConversationBlobGraphStats;
40
+ /** Copy all durable blobs so a completed turn can be persisted atomically. */
41
+ export declare function snapshotConversationBlobs(conversationId: string): ConversationBlobSnapshot[];
42
+ /**
43
+ * Keep only blobs reachable from the latest checkpoint and snapshot them.
44
+ * Malformed/unknown state or a missing referenced hash falls back to retaining
45
+ * every blob: cache size must never come at the cost of restart correctness.
46
+ */
47
+ export declare function compactConversationBlobs(conversationId: string, checkpoint: Uint8Array | undefined): ConversationBlobCompaction;
48
+ /** Replace the in-memory blob bucket when restoring a persisted conversation. */
49
+ export declare function restoreConversationBlobs(conversationId: string, blobs: readonly ConversationBlobSnapshot[]): void;
13
50
  /** Drop all blobs for a conversation (compaction conversation reset). */
14
51
  export declare function clearConversationBlobs(conversationId: string): void;
15
52
  /** SHA-256 content hashes are 32 non-text bytes — never echo those as content. */