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.
- package/README.md +33 -23
- package/dist/context/agents.d.ts +1 -1
- package/dist/context/agents.js +1 -1
- package/dist/context/auth-store.js +2 -2
- package/dist/context/build.d.ts +11 -1
- package/dist/context/build.js +76 -25
- package/dist/context/frozen.d.ts +29 -0
- package/dist/context/frozen.js +168 -0
- package/dist/context/index.d.ts +3 -2
- package/dist/context/index.js +3 -2
- package/dist/context/paths.d.ts +34 -53
- package/dist/context/paths.js +54 -118
- package/dist/context/rules.d.ts +1 -0
- package/dist/context/rules.js +2 -2
- package/dist/image-save-tool.d.ts +19 -0
- package/dist/image-save-tool.js +25 -0
- package/dist/image-save.d.ts +58 -0
- package/dist/image-save.js +189 -0
- package/dist/image-staging.d.ts +59 -0
- package/dist/image-staging.js +91 -0
- package/dist/index.d.ts +1 -1
- package/dist/language-model.d.ts +13 -11
- package/dist/language-model.js +957 -150
- package/dist/plan-execution-kickoff.d.ts +55 -0
- package/dist/plan-execution-kickoff.js +162 -0
- package/dist/plugin-opencode2.js +52 -3
- package/dist/plugin-v2.d.ts +11 -9
- package/dist/plugin-v2.js +6 -13
- package/dist/plugin.d.ts +9 -0
- package/dist/plugin.js +61 -5
- package/dist/pricing-data.d.ts +16 -4
- package/dist/pricing-data.js +16 -4
- package/dist/protocol/ask-question.d.ts +133 -0
- package/dist/protocol/ask-question.js +320 -0
- package/dist/protocol/blob-reachability.d.ts +15 -0
- package/dist/protocol/blob-reachability.js +140 -0
- package/dist/protocol/blob-store.d.ts +37 -0
- package/dist/protocol/blob-store.js +115 -0
- package/dist/protocol/conversation-bind.d.ts +13 -0
- package/dist/protocol/conversation-bind.js +27 -1
- package/dist/protocol/conversation-persistence.d.ts +32 -0
- package/dist/protocol/conversation-persistence.js +504 -0
- package/dist/protocol/conversation-state.d.ts +16 -0
- package/dist/protocol/conversation-state.js +62 -0
- package/dist/protocol/create-plan.d.ts +141 -0
- package/dist/protocol/create-plan.js +373 -0
- package/dist/protocol/generate-image.d.ts +54 -0
- package/dist/protocol/generate-image.js +101 -0
- package/dist/protocol/interactions.d.ts +110 -8
- package/dist/protocol/interactions.js +295 -35
- package/dist/protocol/kv.d.ts +1 -0
- package/dist/protocol/kv.js +2 -0
- package/dist/protocol/messages.d.ts +2 -0
- package/dist/protocol/messages.js +148 -8
- package/dist/protocol/request.d.ts +2 -0
- package/dist/protocol/request.js +2 -0
- package/dist/protocol/switch-mode.d.ts +178 -0
- package/dist/protocol/switch-mode.js +417 -0
- package/dist/protocol/token-details.d.ts +33 -0
- package/dist/protocol/token-details.js +155 -0
- package/dist/protocol/tool-call-bridge.js +10 -5
- package/dist/protocol/tools.d.ts +44 -11
- package/dist/protocol/tools.js +171 -41
- package/dist/session.d.ts +39 -11
- package/dist/shared.d.ts +3 -0
- package/dist/shared.js +3 -0
- package/dist/usage.d.ts +42 -37
- package/dist/usage.js +201 -70
- package/dist/web-search-tool.d.ts +22 -26
- package/dist/web-search-tool.js +25 -15
- 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. */
|