opencode-translate 0.1.1 → 0.1.3

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 (40) hide show
  1. package/README.md +1 -1
  2. package/package.json +6 -3
  3. package/src/activation/chat-message.ts +164 -0
  4. package/src/activation/index.ts +38 -0
  5. package/src/activation/logging.ts +11 -0
  6. package/src/activation/messages-transform.ts +38 -0
  7. package/src/activation/metadata.ts +42 -0
  8. package/src/activation/parts.ts +53 -0
  9. package/src/activation/question-hooks.ts +95 -0
  10. package/src/activation/state.ts +98 -0
  11. package/src/activation/text-complete.ts +51 -0
  12. package/src/activation/trigger.ts +57 -0
  13. package/src/activation/types.ts +41 -0
  14. package/src/activation.ts +1 -607
  15. package/src/anthropic-oauth.ts +3 -3
  16. package/src/auth/codex-request.ts +108 -0
  17. package/src/auth/codex-response.ts +78 -0
  18. package/src/auth/codex-shared.ts +3 -0
  19. package/src/auth/headers.ts +18 -0
  20. package/src/auth/index.ts +153 -0
  21. package/src/auth/oauth-fetch.ts +100 -0
  22. package/src/auth/refresh.ts +102 -0
  23. package/src/auth/retry.ts +70 -0
  24. package/src/auth/store.ts +45 -0
  25. package/src/auth/types.ts +27 -0
  26. package/src/auth.ts +1 -725
  27. package/src/constants/errors.ts +24 -0
  28. package/src/constants/guards.ts +34 -0
  29. package/src/constants/options.ts +37 -0
  30. package/src/constants/plugin.ts +10 -0
  31. package/src/constants/types.ts +147 -0
  32. package/src/constants.ts +5 -261
  33. package/src/labels.ts +0 -2
  34. package/src/question-tool.ts +45 -22
  35. package/src/translator/index.ts +125 -0
  36. package/src/translator/part-id.ts +43 -0
  37. package/src/translator/provider.ts +81 -0
  38. package/src/translator/retry.ts +62 -0
  39. package/src/translator/types.ts +17 -0
  40. package/src/translator.ts +1 -326
@@ -0,0 +1,24 @@
1
+ import { PLUGIN_NAME } from "./plugin"
2
+
3
+ export function normalizeReason(error: unknown): string {
4
+ const raw = error instanceof Error ? error.message : String(error)
5
+ return raw.split(/\r?\n/, 1)[0].trim().slice(0, 200)
6
+ }
7
+
8
+ export function buildInboundTranslationError(sourceLanguage: string, reason: string): Error {
9
+ return new Error(
10
+ `[${PLUGIN_NAME}:INBOUND_TRANSLATION_FAILED] Failed to translate user message from ${sourceLanguage} to en: ${reason}`,
11
+ )
12
+ }
13
+
14
+ export function buildAuthUnavailableError(providerID: string, envVar: string): Error {
15
+ return new Error(
16
+ `[${PLUGIN_NAME}:AUTH_UNAVAILABLE] No credential found for provider "${providerID}". Set ${envVar} in the environment, run "opencode auth login ${providerID}", or set options.apiKey in opencode.json.`,
17
+ )
18
+ }
19
+
20
+ export function buildOAuthRefreshError(providerID: string, reason: string): Error {
21
+ return new Error(
22
+ `[${PLUGIN_NAME}:OAUTH_REFRESH_FAILED] Failed to refresh OAuth token for provider "${providerID}": ${reason}. Re-authenticate with "opencode auth login ${providerID}".`,
23
+ )
24
+ }
@@ -0,0 +1,34 @@
1
+ import { LLM_LANGUAGE, NONCE_PATTERN } from "./plugin"
2
+ import type { SDKResponseLike, TextPartLike, TranslateState } from "./types"
3
+
4
+ function isNonEmptyString(value: unknown): value is string {
5
+ return typeof value === "string" && value.length > 0
6
+ }
7
+
8
+ export function unwrapData<T>(value: T | SDKResponseLike<T>): T {
9
+ if (value && typeof value === "object" && "data" in value && (value as SDKResponseLike<T>).data !== undefined) {
10
+ return (value as SDKResponseLike<T>).data as T
11
+ }
12
+ return value as T
13
+ }
14
+
15
+ export function isTranslateStateRecord(value: unknown): value is TranslateState {
16
+ if (!value || typeof value !== "object") return false
17
+ const record = value as Record<string, unknown>
18
+ return (
19
+ record.translate_enabled === true &&
20
+ record.translate_llm_lang === LLM_LANGUAGE &&
21
+ isNonEmptyString(record.translate_source_lang) &&
22
+ isNonEmptyString(record.translate_display_lang) &&
23
+ isNonEmptyString(record.translate_nonce) &&
24
+ NONCE_PATTERN.test(record.translate_nonce)
25
+ )
26
+ }
27
+
28
+ export function isTextPart(part: TextPartLike): part is TextPartLike & { text: string } {
29
+ return part.type === "text" && typeof part.text === "string"
30
+ }
31
+
32
+ export function isUserAuthoredTextPart(part: TextPartLike): part is TextPartLike & { text: string } {
33
+ return isTextPart(part) && part.synthetic !== true && part.ignored !== true
34
+ }
@@ -0,0 +1,37 @@
1
+ import { AUTH_ENV_FALLBACK, DEFAULT_TRANSLATOR_MODEL, DEFAULT_TRIGGER_KEYWORDS } from "./plugin"
2
+ import type { ProviderInfo, ResolvedTranslateOptions } from "./types"
3
+
4
+ export function resolveOptions(options: Record<string, unknown>): ResolvedTranslateOptions {
5
+ const triggerKeywords = Array.isArray(options.triggerKeywords)
6
+ ? options.triggerKeywords.filter((value): value is string => typeof value === "string" && value.length > 0)
7
+ : DEFAULT_TRIGGER_KEYWORDS
8
+
9
+ return {
10
+ translatorModel:
11
+ typeof options.translatorModel === "string" && options.translatorModel.includes("/")
12
+ ? options.translatorModel
13
+ : DEFAULT_TRANSLATOR_MODEL,
14
+ triggerKeywords: triggerKeywords.length > 0 ? triggerKeywords : [...DEFAULT_TRIGGER_KEYWORDS],
15
+ sourceLanguage:
16
+ typeof options.sourceLanguage === "string" && options.sourceLanguage.trim() ? options.sourceLanguage : "en",
17
+ displayLanguage:
18
+ typeof options.displayLanguage === "string" && options.displayLanguage.trim() ? options.displayLanguage : "en",
19
+ apiKey: typeof options.apiKey === "string" && options.apiKey.length > 0 ? options.apiKey : undefined,
20
+ verbose: options.verbose === true,
21
+ }
22
+ }
23
+
24
+ export function getEnvVarHint(provider: ProviderInfo | undefined): string {
25
+ return provider?.env[0] || AUTH_ENV_FALLBACK
26
+ }
27
+
28
+ export function parseTranslatorModel(model: string): { providerID: string; modelID: string } {
29
+ const slash = model.indexOf("/")
30
+ if (slash < 1 || slash === model.length - 1) {
31
+ return { providerID: "anthropic", modelID: model }
32
+ }
33
+ return {
34
+ providerID: model.slice(0, slash),
35
+ modelID: model.slice(slash + 1),
36
+ }
37
+ }
@@ -0,0 +1,10 @@
1
+ export const PLUGIN_NAME = "opencode-translate"
2
+ export const SPEC_VERSION = 1
3
+ export const LLM_LANGUAGE = "en"
4
+ export const DEFAULT_TRANSLATOR_MODEL = "anthropic/claude-haiku-4-5"
5
+ export const DEFAULT_TRIGGER_KEYWORDS = ["$en"]
6
+ export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
7
+ export const NONCE_PATTERN = /^[0-9a-f]{32}$/
8
+ export const FAILURE_NOTICE = "_Translation unavailable for this segment._"
9
+ export const AUTH_ENV_FALLBACK = "the provider's API key env var"
10
+ export const USER_AGENT = `${PLUGIN_NAME}/0.0.0`
@@ -0,0 +1,147 @@
1
+ import type { LLM_LANGUAGE } from "./plugin"
2
+
3
+ export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
4
+
5
+ type ProviderSource = "env" | "config" | "custom" | "api"
6
+
7
+ export interface ResolvedTranslateOptions {
8
+ translatorModel: string
9
+ triggerKeywords: string[]
10
+ sourceLanguage: string
11
+ displayLanguage: string
12
+ apiKey?: string
13
+ verbose: boolean
14
+ }
15
+
16
+ export interface TranslateState {
17
+ translate_enabled: true
18
+ translate_source_lang: string
19
+ translate_display_lang: string
20
+ translate_llm_lang: typeof LLM_LANGUAGE
21
+ translate_nonce: string
22
+ }
23
+
24
+ export interface StoredTextMetadata extends Record<string, unknown> {
25
+ translate_enabled?: boolean
26
+ translate_source_lang?: string
27
+ translate_display_lang?: string
28
+ translate_llm_lang?: string
29
+ translate_nonce?: string
30
+ translate_role?: string
31
+ translate_spec_version?: number
32
+ translate_source_hash?: string
33
+ translate_en?: string
34
+ translate_part_index?: number
35
+ compaction_continue?: boolean
36
+ }
37
+
38
+ interface SessionLike {
39
+ id: string
40
+ parentID?: string | null
41
+ }
42
+
43
+ interface MessageLike {
44
+ id: string
45
+ sessionID: string
46
+ role: string
47
+ }
48
+
49
+ export interface TextPartLike {
50
+ id: string
51
+ sessionID: string
52
+ messageID: string
53
+ type: string
54
+ text?: string
55
+ synthetic?: boolean
56
+ ignored?: boolean
57
+ metadata?: Record<string, unknown>
58
+ }
59
+
60
+ export interface MessageWithPartsLike {
61
+ info: MessageLike
62
+ parts: TextPartLike[]
63
+ }
64
+
65
+ export interface ProviderInfo {
66
+ id: string
67
+ source: ProviderSource
68
+ env: string[]
69
+ key?: string
70
+ options?: Record<string, unknown>
71
+ models?: Record<string, unknown>
72
+ }
73
+
74
+ interface ProviderListResponseLike {
75
+ all: ProviderInfo[]
76
+ }
77
+
78
+ interface ApiAuthInfo {
79
+ type: "api"
80
+ key: string
81
+ metadata?: Record<string, string>
82
+ }
83
+
84
+ export interface OAuthInfo {
85
+ type: "oauth"
86
+ refresh: string
87
+ access: string
88
+ expires: number
89
+ accountId?: string
90
+ enterpriseUrl?: string
91
+ }
92
+
93
+ interface WellKnownInfo {
94
+ type: "wellknown"
95
+ key: string
96
+ token: string
97
+ }
98
+
99
+ export type AuthInfo = ApiAuthInfo | OAuthInfo | WellKnownInfo
100
+
101
+ export interface SDKResponseLike<T> {
102
+ data?: T
103
+ }
104
+
105
+ export interface PluginClientLike {
106
+ session: {
107
+ get(
108
+ input: (
109
+ | { sessionID: string; directory?: string; workspace?: string }
110
+ | { path: { id: string }; query?: { directory?: string; workspace?: string } }
111
+ ) & { throwOnError?: boolean },
112
+ options?: { throwOnError?: boolean },
113
+ ): Promise<SessionLike | SDKResponseLike<SessionLike>>
114
+ messages(
115
+ input: (
116
+ | { sessionID: string; directory?: string; workspace?: string }
117
+ | { path: { id: string }; query?: { directory?: string; workspace?: string; limit?: number; before?: string } }
118
+ ) & { throwOnError?: boolean },
119
+ options?: { throwOnError?: boolean },
120
+ ): Promise<MessageWithPartsLike[] | SDKResponseLike<MessageWithPartsLike[]>>
121
+ message(
122
+ input: (
123
+ | { sessionID: string; messageID: string; directory?: string; workspace?: string }
124
+ | { path: { id: string; messageID: string }; query?: { directory?: string; workspace?: string } }
125
+ ) & { throwOnError?: boolean },
126
+ options?: { throwOnError?: boolean },
127
+ ): Promise<MessageWithPartsLike | SDKResponseLike<MessageWithPartsLike>>
128
+ }
129
+ provider: {
130
+ list(options?: {
131
+ throwOnError?: boolean
132
+ }): Promise<ProviderListResponseLike | SDKResponseLike<ProviderListResponseLike>>
133
+ }
134
+ auth: {
135
+ set(input: { path: { id: string }; body: AuthInfo }): Promise<unknown>
136
+ }
137
+ app: {
138
+ log(input: {
139
+ body: {
140
+ service: string
141
+ level: string
142
+ message: string
143
+ extra?: Record<string, unknown>
144
+ }
145
+ }): Promise<unknown>
146
+ }
147
+ }
package/src/constants.ts CHANGED
@@ -1,261 +1,5 @@
1
- export const PLUGIN_NAME = "opencode-translate"
2
- export const SPEC_VERSION = 1
3
- export const LLM_LANGUAGE = "en"
4
- export const DEFAULT_TRANSLATOR_MODEL = "anthropic/claude-haiku-4-5"
5
- export const DEFAULT_TRIGGER_KEYWORDS = ["$en"]
6
- export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
7
- export const NONCE_PATTERN = /^[0-9a-f]{32}$/
8
- export const FAILURE_NOTICE = "_Translation unavailable for this segment._"
9
- export const AUTH_ENV_FALLBACK = "the provider's API key env var"
10
- export const USER_AGENT = `${PLUGIN_NAME}/0.0.0`
11
-
12
- export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
13
-
14
- export type ProviderSource = "env" | "config" | "custom" | "api"
15
-
16
- export interface TranslateOptions {
17
- translatorModel?: string
18
- triggerKeywords?: string[]
19
- sourceLanguage?: string
20
- displayLanguage?: string
21
- apiKey?: string
22
- verbose?: boolean
23
- }
24
-
25
- export interface ResolvedTranslateOptions {
26
- translatorModel: string
27
- triggerKeywords: string[]
28
- sourceLanguage: string
29
- displayLanguage: string
30
- apiKey?: string
31
- verbose: boolean
32
- }
33
-
34
- export interface TranslateState {
35
- translate_enabled: true
36
- translate_source_lang: string
37
- translate_display_lang: string
38
- translate_llm_lang: typeof LLM_LANGUAGE
39
- translate_nonce: string
40
- }
41
-
42
- export interface StoredTextMetadata extends Record<string, unknown> {
43
- translate_enabled?: boolean
44
- translate_source_lang?: string
45
- translate_display_lang?: string
46
- translate_llm_lang?: string
47
- translate_nonce?: string
48
- translate_role?: string
49
- translate_spec_version?: number
50
- translate_source_hash?: string
51
- translate_en?: string
52
- translate_part_index?: number
53
- compaction_continue?: boolean
54
- }
55
-
56
- export interface SessionLike {
57
- id: string
58
- parentID?: string | null
59
- }
60
-
61
- export interface MessageLike {
62
- id: string
63
- sessionID: string
64
- role: string
65
- }
66
-
67
- export interface TextPartLike {
68
- id: string
69
- sessionID: string
70
- messageID: string
71
- type: string
72
- text?: string
73
- synthetic?: boolean
74
- ignored?: boolean
75
- metadata?: Record<string, unknown>
76
- }
77
-
78
- export interface MessageWithPartsLike {
79
- info: MessageLike
80
- parts: TextPartLike[]
81
- }
82
-
83
- export interface ProviderInfo {
84
- id: string
85
- source: ProviderSource
86
- env: string[]
87
- key?: string
88
- options?: Record<string, unknown>
89
- models?: Record<string, unknown>
90
- }
91
-
92
- export interface ProviderListResponseLike {
93
- all: ProviderInfo[]
94
- }
95
-
96
- export interface ApiAuthInfo {
97
- type: "api"
98
- key: string
99
- metadata?: Record<string, string>
100
- }
101
-
102
- export interface OAuthInfo {
103
- type: "oauth"
104
- refresh: string
105
- access: string
106
- expires: number
107
- accountId?: string
108
- enterpriseUrl?: string
109
- }
110
-
111
- export interface WellKnownInfo {
112
- type: "wellknown"
113
- key: string
114
- token: string
115
- }
116
-
117
- export type AuthInfo = ApiAuthInfo | OAuthInfo | WellKnownInfo
118
-
119
- export interface SDKResponseLike<T> {
120
- data?: T
121
- }
122
-
123
- export interface PluginClientLike {
124
- session: {
125
- get(
126
- input: (
127
- | { sessionID: string; directory?: string; workspace?: string }
128
- | { path: { id: string }; query?: { directory?: string; workspace?: string } }
129
- ) & { throwOnError?: boolean },
130
- options?: { throwOnError?: boolean },
131
- ): Promise<SessionLike | SDKResponseLike<SessionLike>>
132
- messages(
133
- input: (
134
- | { sessionID: string; directory?: string; workspace?: string }
135
- | { path: { id: string }; query?: { directory?: string; workspace?: string; limit?: number; before?: string } }
136
- ) & { throwOnError?: boolean },
137
- options?: { throwOnError?: boolean },
138
- ): Promise<MessageWithPartsLike[] | SDKResponseLike<MessageWithPartsLike[]>>
139
- message(
140
- input: (
141
- | { sessionID: string; messageID: string; directory?: string; workspace?: string }
142
- | { path: { id: string; messageID: string }; query?: { directory?: string; workspace?: string } }
143
- ) & { throwOnError?: boolean },
144
- options?: { throwOnError?: boolean },
145
- ): Promise<MessageWithPartsLike | SDKResponseLike<MessageWithPartsLike>>
146
- }
147
- provider: {
148
- list(options?: {
149
- throwOnError?: boolean
150
- }): Promise<ProviderListResponseLike | SDKResponseLike<ProviderListResponseLike>>
151
- }
152
- auth: {
153
- set(input: { path: { id: string }; body: AuthInfo }): Promise<unknown>
154
- }
155
- app: {
156
- log(input: {
157
- body: {
158
- service: string
159
- level: string
160
- message: string
161
- extra?: Record<string, unknown>
162
- }
163
- }): Promise<unknown>
164
- }
165
- }
166
-
167
- export interface TranslationPreviewInfo {
168
- english: string
169
- sourceHash: string
170
- eligibleIndex: number
171
- }
172
-
173
- export function resolveOptions(options: Record<string, unknown>): ResolvedTranslateOptions {
174
- const triggerKeywords = Array.isArray(options.triggerKeywords)
175
- ? options.triggerKeywords.filter((value): value is string => typeof value === "string" && value.length > 0)
176
- : DEFAULT_TRIGGER_KEYWORDS
177
-
178
- return {
179
- translatorModel:
180
- typeof options.translatorModel === "string" && options.translatorModel.includes("/")
181
- ? options.translatorModel
182
- : DEFAULT_TRANSLATOR_MODEL,
183
- triggerKeywords: triggerKeywords.length > 0 ? triggerKeywords : [...DEFAULT_TRIGGER_KEYWORDS],
184
- sourceLanguage:
185
- typeof options.sourceLanguage === "string" && options.sourceLanguage.trim() ? options.sourceLanguage : "en",
186
- displayLanguage:
187
- typeof options.displayLanguage === "string" && options.displayLanguage.trim() ? options.displayLanguage : "en",
188
- apiKey: typeof options.apiKey === "string" && options.apiKey.length > 0 ? options.apiKey : undefined,
189
- verbose: options.verbose === true,
190
- }
191
- }
192
-
193
- export function getEnvVarHint(provider: ProviderInfo | undefined): string {
194
- return provider?.env[0] || AUTH_ENV_FALLBACK
195
- }
196
-
197
- export function isNonEmptyString(value: unknown): value is string {
198
- return typeof value === "string" && value.length > 0
199
- }
200
-
201
- export function unwrapData<T>(value: T | SDKResponseLike<T>): T {
202
- if (value && typeof value === "object" && "data" in value && (value as SDKResponseLike<T>).data !== undefined) {
203
- return (value as SDKResponseLike<T>).data as T
204
- }
205
- return value as T
206
- }
207
-
208
- export function normalizeReason(error: unknown): string {
209
- const raw = error instanceof Error ? error.message : String(error)
210
- return raw.split(/\r?\n/, 1)[0].trim().slice(0, 200)
211
- }
212
-
213
- export function buildInboundTranslationError(sourceLanguage: string, reason: string): Error {
214
- return new Error(
215
- `[${PLUGIN_NAME}:INBOUND_TRANSLATION_FAILED] Failed to translate user message from ${sourceLanguage} to en: ${reason}`,
216
- )
217
- }
218
-
219
- export function buildAuthUnavailableError(providerID: string, envVar: string): Error {
220
- return new Error(
221
- `[${PLUGIN_NAME}:AUTH_UNAVAILABLE] No credential found for provider "${providerID}". Set ${envVar} in the environment, run "opencode auth login ${providerID}", or set options.apiKey in opencode.json.`,
222
- )
223
- }
224
-
225
- export function buildOAuthRefreshError(providerID: string, reason: string): Error {
226
- return new Error(
227
- `[${PLUGIN_NAME}:OAUTH_REFRESH_FAILED] Failed to refresh OAuth token for provider "${providerID}": ${reason}. Re-authenticate with "opencode auth login ${providerID}".`,
228
- )
229
- }
230
-
231
- export function isTranslateStateRecord(value: unknown): value is TranslateState {
232
- if (!value || typeof value !== "object") return false
233
- const record = value as Record<string, unknown>
234
- return (
235
- record.translate_enabled === true &&
236
- record.translate_llm_lang === LLM_LANGUAGE &&
237
- isNonEmptyString(record.translate_source_lang) &&
238
- isNonEmptyString(record.translate_display_lang) &&
239
- isNonEmptyString(record.translate_nonce) &&
240
- NONCE_PATTERN.test(record.translate_nonce)
241
- )
242
- }
243
-
244
- export function isTextPart(part: TextPartLike): part is TextPartLike & { text: string } {
245
- return part.type === "text" && typeof part.text === "string"
246
- }
247
-
248
- export function isUserAuthoredTextPart(part: TextPartLike): part is TextPartLike & { text: string } {
249
- return isTextPart(part) && part.synthetic !== true && part.ignored !== true
250
- }
251
-
252
- export function parseTranslatorModel(model: string): { providerID: string; modelID: string } {
253
- const slash = model.indexOf("/")
254
- if (slash < 1 || slash === model.length - 1) {
255
- return { providerID: "anthropic", modelID: model }
256
- }
257
- return {
258
- providerID: model.slice(0, slash),
259
- modelID: model.slice(slash + 1),
260
- }
261
- }
1
+ export * from "./constants/errors"
2
+ export * from "./constants/guards"
3
+ export * from "./constants/options"
4
+ export * from "./constants/plugin"
5
+ export * from "./constants/types"
package/src/labels.ts CHANGED
@@ -13,5 +13,3 @@ const DISPLAY_LANGUAGE_LABELS: Record<string, string> = {
13
13
  export function getDisplayLanguageLabel(displayLanguage: string): string {
14
14
  return DISPLAY_LANGUAGE_LABELS[displayLanguage] ?? `Translation (${displayLanguage})`
15
15
  }
16
-
17
- export { DISPLAY_LANGUAGE_LABELS }
@@ -9,8 +9,10 @@
9
9
  // 3. OpenCode publishes `question.asked`; the TUI shows the translated
10
10
  // dialog and the user picks an option (or types a custom answer).
11
11
  // 4. `tool.execute.after` hook reverses the substitution using the
12
- // snapshot we captured in step 2, so the tool output string delivered
13
- // back to the LLM stays in English.
12
+ // snapshot we captured in step 2. Selected options are restored by
13
+ // label mapping; non-empty custom answers are translated like normal
14
+ // user messages, so the output delivered back to the LLM stays in
15
+ // English.
14
16
  //
15
17
  // A per-callID snapshot is kept so mapping a user-selected translated label
16
18
  // back to its original English label is deterministic.
@@ -35,6 +37,11 @@ export interface QuestionToolOutput {
35
37
  metadata?: { answers?: readonly (readonly string[])[] } | Record<string, unknown>
36
38
  }
37
39
 
40
+ export interface RestoreQuestionOutputOptions {
41
+ translateCustomAnswer?: (text: string) => Promise<string>
42
+ onTranslationError?: (error: unknown) => Promise<void> | void
43
+ }
44
+
38
45
  function cloneQuestion(q: TextRecord): TextRecord {
39
46
  return {
40
47
  question: q.question,
@@ -80,9 +87,8 @@ async function assignTranslation(
80
87
  container[key] = unwrapEchoedTextEnvelope(translated)
81
88
  }
82
89
 
83
- // Translate every display-facing string in `args` in parallel. Returns the
84
- // snapshot of the translated form so the caller can pair it with the
85
- // pre-translation snapshot taken with `snapshotQuestions`.
90
+ // Translate every display-facing string in `args` in parallel. The caller can
91
+ // snapshot the translated form afterward with `snapshotQuestions`.
86
92
  export async function translateQuestionArgs(
87
93
  args: QuestionArgs,
88
94
  translate: (text: string) => Promise<string>,
@@ -101,44 +107,61 @@ export async function translateQuestionArgs(
101
107
  await Promise.all(jobs)
102
108
  }
103
109
 
104
- // Given the user-selected labels (`answers`), find the matching translated
105
- // option and return its original English label. If no match (e.g. a custom
106
- // free-text answer), return the label verbatim so the LLM still sees what
107
- // the user actually typed.
108
- function restoreLabel(
110
+ // Given a user-selected label, find the matching translated option and return
111
+ // its original English label. If no match exists, OpenCode only gives us the
112
+ // raw answer string, so treat non-empty text as a custom answer and translate
113
+ // it through the same source-language -> English path as normal user messages.
114
+ async function restoreLabel(
109
115
  selectedLabel: string,
110
116
  translatedOptions: readonly OptionRecord[],
111
117
  originalOptions: readonly OptionRecord[],
112
- ): string {
118
+ options: RestoreQuestionOutputOptions,
119
+ ): Promise<string> {
113
120
  const idx = translatedOptions.findIndex((option) => option.label === selectedLabel)
114
- if (idx < 0) return selectedLabel
115
- return originalOptions[idx]?.label ?? selectedLabel
121
+ if (idx >= 0) return originalOptions[idx]?.label ?? selectedLabel
122
+ if (!options.translateCustomAnswer || selectedLabel.trim().length === 0) return selectedLabel
123
+
124
+ try {
125
+ const translated = await options.translateCustomAnswer(selectedLabel)
126
+ return unwrapEchoedTextEnvelope(translated)
127
+ } catch (error) {
128
+ await options.onTranslationError?.(error)
129
+ return selectedLabel
130
+ }
116
131
  }
117
132
 
118
133
  // Reconstruct the exact output string the question tool would have produced
119
134
  // if it had been called with the original English args. Mirrors the format
120
135
  // in `packages/opencode/src/tool/question.ts` (as of opencode 1.14.x).
121
- export function buildRestoredOutput(
136
+ export async function buildRestoredOutput(
122
137
  original: readonly TextRecord[],
123
138
  translated: readonly TextRecord[],
124
139
  answers: readonly (readonly string[])[],
125
- ): string {
126
- const formatted = original
127
- .map((q, i) => {
140
+ options: RestoreQuestionOutputOptions = {},
141
+ ): Promise<string> {
142
+ const formattedParts = await Promise.all(
143
+ original.map(async (q, i) => {
128
144
  const selected = answers[i] ?? []
129
145
  const translatedOptions = translated[i]?.options ?? []
130
146
  const originalOptions = q.options
131
- const restored = selected.map((label) => restoreLabel(label, translatedOptions, originalOptions))
147
+ const restored = await Promise.all(
148
+ selected.map((label) => restoreLabel(label, translatedOptions, originalOptions, options)),
149
+ )
132
150
  const rendered = restored.length > 0 ? restored.join(", ") : "Unanswered"
133
151
  return `"${q.question}"="${rendered}"`
134
- })
135
- .join(", ")
152
+ }),
153
+ )
154
+ const formatted = formattedParts.join(", ")
136
155
  return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
137
156
  }
138
157
 
139
- export function restoreQuestionOutput(output: QuestionToolOutput, snapshot: QuestionSnapshot): void {
158
+ export async function restoreQuestionOutput(
159
+ output: QuestionToolOutput,
160
+ snapshot: QuestionSnapshot,
161
+ options: RestoreQuestionOutputOptions = {},
162
+ ): Promise<void> {
140
163
  if (typeof output.output !== "string") return
141
164
  const answersRaw = (output.metadata as { answers?: readonly (readonly string[])[] } | undefined)?.answers
142
165
  const answers = Array.isArray(answersRaw) ? answersRaw : []
143
- output.output = buildRestoredOutput(snapshot.original, snapshot.translated, answers)
166
+ output.output = await buildRestoredOutput(snapshot.original, snapshot.translated, answers, options)
144
167
  }