opencode-translate 1.0.6 → 2.0.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 (45) hide show
  1. package/README.md +77 -6
  2. package/dist/index.js +873 -0
  3. package/index.d.ts +5 -0
  4. package/package.json +15 -16
  5. package/src/activation/chat-message.ts +0 -189
  6. package/src/activation/index.ts +0 -38
  7. package/src/activation/logging.ts +0 -11
  8. package/src/activation/messages-transform.ts +0 -44
  9. package/src/activation/metadata.ts +0 -41
  10. package/src/activation/parts.ts +0 -46
  11. package/src/activation/question-hooks.ts +0 -126
  12. package/src/activation/state.ts +0 -97
  13. package/src/activation/text-complete.ts +0 -50
  14. package/src/activation/trigger.ts +0 -57
  15. package/src/activation/types.ts +0 -47
  16. package/src/activation.ts +0 -1
  17. package/src/anthropic-oauth.ts +0 -148
  18. package/src/auth/codex-request.ts +0 -108
  19. package/src/auth/codex-response.ts +0 -78
  20. package/src/auth/codex-shared.ts +0 -3
  21. package/src/auth/headers.ts +0 -18
  22. package/src/auth/index.ts +0 -177
  23. package/src/auth/oauth-fetch.ts +0 -100
  24. package/src/auth/refresh.ts +0 -102
  25. package/src/auth/retry.ts +0 -70
  26. package/src/auth/store.ts +0 -98
  27. package/src/auth/types.ts +0 -27
  28. package/src/auth.ts +0 -1
  29. package/src/constants/errors.ts +0 -24
  30. package/src/constants/guards.ts +0 -33
  31. package/src/constants/options.ts +0 -55
  32. package/src/constants/plugin.ts +0 -9
  33. package/src/constants/types.ts +0 -159
  34. package/src/constants.ts +0 -5
  35. package/src/formatting.ts +0 -157
  36. package/src/index.ts +0 -7
  37. package/src/labels.ts +0 -3
  38. package/src/prompts.ts +0 -123
  39. package/src/question-tool.ts +0 -234
  40. package/src/translator/index.ts +0 -172
  41. package/src/translator/part-id.ts +0 -43
  42. package/src/translator/provider.ts +0 -411
  43. package/src/translator/retry.ts +0 -62
  44. package/src/translator/types.ts +0 -24
  45. package/src/translator.ts +0 -1
@@ -1,234 +0,0 @@
1
- // Translation layer for OpenCode's built-in `question` tool.
2
- //
3
- // Flow:
4
- // 1. Agent (main LLM, English-only) invokes the `question` tool with an
5
- // `args.questions[]` payload in English.
6
- // 2. `tool.execute.before` hook translates all question text, headers, and
7
- // option labels + descriptions into the configured `lang` so the question
8
- // prompt renders in the user's language.
9
- // 3. OpenCode publishes `question.asked`; the TUI shows the translated
10
- // dialog and the user picks an option (or types a custom answer).
11
- // 4. `tool.execute.after` hook reverses the substitution using the
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.
16
- //
17
- // A per-callID snapshot is kept so mapping a user-selected translated label
18
- // back to its original English label is deterministic.
19
-
20
- import { unwrapEchoedTextEnvelope } from "./prompts"
21
-
22
- type TextRecord = { question: string; header: string; options: OptionRecord[]; multiple?: boolean; custom?: boolean }
23
- type OptionRecord = { label: string; description: string }
24
-
25
- export interface QuestionArgs {
26
- questions: TextRecord[]
27
- }
28
-
29
- export interface QuestionSnapshot {
30
- original: TextRecord[]
31
- translated: TextRecord[]
32
- userLanguage: string
33
- }
34
-
35
- export interface QuestionToolOutput {
36
- title?: string
37
- output?: string
38
- metadata?: { answers?: readonly (readonly string[])[] } | Record<string, unknown>
39
- }
40
-
41
- export interface RestoreQuestionOutputOptions {
42
- translateCustomAnswers?: (texts: readonly string[]) => Promise<readonly string[]>
43
- onTranslationError?: (error: unknown) => Promise<void> | void
44
- }
45
-
46
- interface TranslatableField {
47
- text: string
48
- set(value: string): void
49
- }
50
-
51
- interface CustomAnswerSlot {
52
- questionIndex: number
53
- answerIndex: number
54
- text: string
55
- }
56
-
57
- function cloneQuestion(q: TextRecord): TextRecord {
58
- return {
59
- question: q.question,
60
- header: q.header,
61
- options: q.options.map((option) => ({ label: option.label, description: option.description })),
62
- ...(q.multiple !== undefined ? { multiple: q.multiple } : {}),
63
- ...(q.custom !== undefined ? { custom: q.custom } : {}),
64
- }
65
- }
66
-
67
- export function snapshotQuestions(args: QuestionArgs): TextRecord[] {
68
- return args.questions.map(cloneQuestion)
69
- }
70
-
71
- export function restoreQuestionArgs(args: QuestionArgs, original: readonly TextRecord[]): void {
72
- args.questions.splice(0, args.questions.length, ...original.map(cloneQuestion))
73
- }
74
-
75
- export function isQuestionArgs(value: unknown): value is QuestionArgs {
76
- if (!value || typeof value !== "object") return false
77
- const questions = (value as Record<string, unknown>).questions
78
- if (!Array.isArray(questions)) return false
79
- for (const q of questions) {
80
- if (!q || typeof q !== "object") return false
81
- const record = q as Record<string, unknown>
82
- if (typeof record.question !== "string") return false
83
- if (typeof record.header !== "string") return false
84
- if (!Array.isArray(record.options)) return false
85
- for (const opt of record.options) {
86
- if (!opt || typeof opt !== "object") return false
87
- const optRecord = opt as Record<string, unknown>
88
- if (typeof optRecord.label !== "string") return false
89
- if (typeof optRecord.description !== "string") return false
90
- }
91
- }
92
- return true
93
- }
94
-
95
- // Translate every display-facing string in one batch, then commit the
96
- // translated clone only after the batch succeeds.
97
- export async function translateQuestionArgs(
98
- args: QuestionArgs,
99
- translate: (texts: readonly string[]) => Promise<readonly string[]>,
100
- ): Promise<void> {
101
- const translatedQuestions = snapshotQuestions(args)
102
- const fields: TranslatableField[] = []
103
-
104
- function addField(text: string, set: (value: string) => void) {
105
- if (text.length === 0) return
106
- fields.push({ text, set })
107
- }
108
-
109
- for (const q of translatedQuestions) {
110
- addField(q.question, (value) => {
111
- q.question = value
112
- })
113
- addField(q.header, (value) => {
114
- q.header = value
115
- })
116
- for (const option of q.options) {
117
- addField(option.label, (value) => {
118
- option.label = value
119
- })
120
- addField(option.description, (value) => {
121
- option.description = value
122
- })
123
- }
124
- }
125
-
126
- if (fields.length === 0) return
127
-
128
- const translated = await translate(fields.map((field) => field.text))
129
- if (translated.length !== fields.length) {
130
- throw new Error(`Question translator returned ${translated.length} translations for ${fields.length} fields`)
131
- }
132
- for (const [index, field] of fields.entries()) {
133
- field.set(unwrapEchoedTextEnvelope(translated[index]))
134
- }
135
- args.questions.splice(0, args.questions.length, ...translatedQuestions)
136
- }
137
-
138
- function restoreOptionLabel(
139
- selectedLabel: string,
140
- translatedOptions: readonly OptionRecord[],
141
- originalOptions: readonly OptionRecord[],
142
- ): string | undefined {
143
- const idx = translatedOptions.findIndex((option) => option.label === selectedLabel)
144
- if (idx < 0) return undefined
145
- return originalOptions[idx]?.label ?? selectedLabel
146
- }
147
-
148
- async function restoreQuestionAnswers(
149
- original: readonly TextRecord[],
150
- translated: readonly TextRecord[],
151
- answers: readonly (readonly string[])[],
152
- options: RestoreQuestionOutputOptions = {},
153
- ): Promise<string[][]> {
154
- const translateCustomAnswers = options.translateCustomAnswers
155
- const customSlots: CustomAnswerSlot[] = []
156
- const restored = original.map((q, questionIndex) => {
157
- const selected = answers[questionIndex] ?? []
158
- const translatedOptions = translated[questionIndex]?.options ?? []
159
- const originalOptions = q.options
160
-
161
- return selected.map((label, answerIndex) => {
162
- const restoredLabel = restoreOptionLabel(label, translatedOptions, originalOptions)
163
- if (restoredLabel !== undefined) return restoredLabel
164
- if (!translateCustomAnswers || label.trim().length === 0) return label
165
-
166
- customSlots.push({ questionIndex, answerIndex, text: label })
167
- return label
168
- })
169
- })
170
-
171
- if (!translateCustomAnswers || customSlots.length === 0) return restored
172
-
173
- try {
174
- const translatedCustomAnswers = await translateCustomAnswers(customSlots.map((slot) => slot.text))
175
- if (translatedCustomAnswers.length !== customSlots.length) {
176
- throw new Error(
177
- `Question custom-answer translator returned ${translatedCustomAnswers.length} translations for ${customSlots.length} answers`,
178
- )
179
- }
180
- for (const [index, slot] of customSlots.entries()) {
181
- restored[slot.questionIndex][slot.answerIndex] = unwrapEchoedTextEnvelope(translatedCustomAnswers[index])
182
- }
183
- } catch (error) {
184
- await options.onTranslationError?.(error)
185
- }
186
-
187
- return restored
188
- }
189
-
190
- function formatRestoredOutput(original: readonly TextRecord[], answers: readonly (readonly string[])[]): string {
191
- const formattedParts = original.map((q, i) => {
192
- const restored = answers[i] ?? []
193
- const rendered = restored.length > 0 ? restored.join(", ") : "Unanswered"
194
- return `"${q.question}"="${rendered}"`
195
- })
196
- const formatted = formattedParts.join(", ")
197
- return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
198
- }
199
-
200
- // Reconstruct the exact output string the question tool would have produced
201
- // if it had been called with the original English args. Mirrors the format
202
- // in `packages/opencode/src/tool/question.ts` (as of opencode 1.14.x).
203
- export async function buildRestoredOutput(
204
- original: readonly TextRecord[],
205
- translated: readonly TextRecord[],
206
- answers: readonly (readonly string[])[],
207
- options: RestoreQuestionOutputOptions = {},
208
- ): Promise<string> {
209
- const restoredAnswers = await restoreQuestionAnswers(original, translated, answers, options)
210
- return formatRestoredOutput(original, restoredAnswers)
211
- }
212
-
213
- function mutableMetadata(output: QuestionToolOutput): Record<string, unknown> {
214
- if (output.metadata && typeof output.metadata === "object" && !Array.isArray(output.metadata)) {
215
- return output.metadata as Record<string, unknown>
216
- }
217
-
218
- const metadata: Record<string, unknown> = {}
219
- output.metadata = metadata
220
- return metadata
221
- }
222
-
223
- export async function restoreQuestionOutput(
224
- output: QuestionToolOutput,
225
- snapshot: QuestionSnapshot,
226
- options: RestoreQuestionOutputOptions = {},
227
- ): Promise<void> {
228
- if (typeof output.output !== "string") return
229
- const answersRaw = (output.metadata as { answers?: readonly (readonly string[])[] } | undefined)?.answers
230
- const answers = Array.isArray(answersRaw) ? answersRaw : []
231
- const restoredAnswers = await restoreQuestionAnswers(snapshot.original, snapshot.translated, answers, options)
232
- output.output = formatRestoredOutput(snapshot.original, restoredAnswers)
233
- mutableMetadata(output).answers = restoredAnswers
234
- }
@@ -1,172 +0,0 @@
1
- import { setTimeout as sleep } from "node:timers/promises"
2
- import { generateText, type LanguageModel } from "ai"
3
- import { createCredentialResolver } from "../auth"
4
- import {
5
- buildAuthUnavailableError,
6
- PLUGIN_NAME,
7
- type PluginClientLike,
8
- type ProviderInfo,
9
- parseTranslatorModel,
10
- type ResolvedTranslateOptions,
11
- } from "../constants"
12
- import {
13
- buildBatchSystemPrompt,
14
- buildBatchUserPrompt,
15
- buildSystemPrompt,
16
- buildUserPrompt,
17
- parseBatchSegments,
18
- unwrapEchoedTextEnvelope,
19
- } from "../prompts"
20
- import { __resetSyntheticPartIDForTest } from "./part-id"
21
- import {
22
- __resetProviderFactoryCacheForTest,
23
- buildVariantProviderOptions,
24
- instantiateModel,
25
- instantiateProvider,
26
- loadFactory,
27
- resolveModelInfo,
28
- supportsTemperature,
29
- } from "./provider"
30
- import { withRetry } from "./retry"
31
- import type { TranslateTextInput, TranslateTextsInput, TranslatorDependencies } from "./types"
32
-
33
- const DEFAULT_TRANSLATE_TIMEOUT_MS = 180_000
34
-
35
- function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
36
- return new Promise<T>((resolve, reject) => {
37
- const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
38
- promise.then(
39
- (value) => {
40
- clearTimeout(timer)
41
- resolve(value)
42
- },
43
- (error) => {
44
- clearTimeout(timer)
45
- reject(error)
46
- },
47
- )
48
- })
49
- }
50
-
51
- function isAuthMessage(error: unknown): boolean {
52
- if (!(error instanceof Error)) return false
53
- return error.message.includes(":AUTH_UNAVAILABLE]") || error.message.includes(":OAUTH_REFRESH_FAILED]")
54
- }
55
-
56
- function modelProviderHint(providerID: string, provider?: ProviderInfo): Error {
57
- return buildAuthUnavailableError(providerID, provider?.env[0] || "the provider's API key env var")
58
- }
59
-
60
- export function __resetTranslatorCachesForTest() {
61
- __resetProviderFactoryCacheForTest()
62
- __resetSyntheticPartIDForTest()
63
- }
64
-
65
- export function createTranslator(
66
- client: PluginClientLike,
67
- options: ResolvedTranslateOptions,
68
- deps: TranslatorDependencies = {},
69
- ) {
70
- const sleepImpl = deps.sleep ?? ((ms: number) => sleep(ms))
71
- const now = deps.now ?? (() => Date.now())
72
- const generateTextImpl = deps.generateTextImpl ?? generateText
73
- const credentialResolver = deps.credentialResolver ?? createCredentialResolver(client)
74
- const timeoutMs = deps.timeoutMs ?? DEFAULT_TRANSLATE_TIMEOUT_MS
75
-
76
- async function generateFromPrompts(system: string, prompt: string): Promise<string> {
77
- const { providerID, modelID } = parseTranslatorModel(options.model)
78
- const credentials = await credentialResolver.resolve(options.model)
79
- const modelInfo = resolveModelInfo(credentials.provider, modelID)
80
- const variantProviderOptions = buildVariantProviderOptions(providerID, modelID, modelInfo, options.variant)
81
- const factory = await loadFactory(providerID, modelInfo)
82
- const provider = instantiateProvider(factory, providerID, credentials, modelInfo)
83
- const providerOptions = { ...(credentials.provider?.options ?? {}), ...(modelInfo.options ?? {}) }
84
- const model = instantiateModel(provider, modelID, providerID, modelInfo, providerOptions) as LanguageModel
85
-
86
- return withRetry(async () => {
87
- try {
88
- const result = await withTimeout(
89
- generateTextImpl({
90
- model,
91
- system,
92
- ...(supportsTemperature(providerID, modelID, modelInfo) ? { temperature: 0 } : {}),
93
- ...(variantProviderOptions ? { providerOptions: variantProviderOptions } : {}),
94
- prompt,
95
- }),
96
- timeoutMs,
97
- "Translator generateText",
98
- )
99
- return result.text
100
- } catch (error) {
101
- if (isAuthMessage(error)) throw error
102
- if (credentials.mode === "default" && credentialResolver.isMissingCredentialError(error)) {
103
- throw modelProviderHint(providerID, credentials.provider)
104
- }
105
- throw error
106
- }
107
- }, sleepImpl)
108
- }
109
-
110
- async function translateText(input: TranslateTextInput): Promise<string> {
111
- if (!input.text) return input.text
112
- if (input.sourceLanguage === input.targetLanguage) return input.text
113
-
114
- const startedAt = now()
115
- const rawTranslated = await generateFromPrompts(buildSystemPrompt(input), buildUserPrompt(input))
116
- const translated = unwrapEchoedTextEnvelope(rawTranslated)
117
-
118
- if (options.verbose) {
119
- await client.app.log({
120
- body: {
121
- service: PLUGIN_NAME,
122
- level: "info",
123
- message: "translated",
124
- extra: {
125
- direction: input.direction,
126
- chars_in: input.text.length,
127
- chars_out: translated.length,
128
- ms: now() - startedAt,
129
- cached: false,
130
- model: options.model,
131
- },
132
- },
133
- })
134
- }
135
-
136
- return translated
137
- }
138
-
139
- async function translateTexts(input: TranslateTextsInput): Promise<string[]> {
140
- if (input.texts.length === 0) return []
141
- if (input.sourceLanguage === input.targetLanguage) return [...input.texts]
142
-
143
- const startedAt = now()
144
- const rawTranslated = await generateFromPrompts(buildBatchSystemPrompt(input), buildBatchUserPrompt(input))
145
- const translated = parseBatchSegments(rawTranslated, input.texts.length).map(unwrapEchoedTextEnvelope)
146
-
147
- if (options.verbose) {
148
- await client.app.log({
149
- body: {
150
- service: PLUGIN_NAME,
151
- level: "info",
152
- message: "translated",
153
- extra: {
154
- direction: input.direction,
155
- chars_in: input.texts.reduce((total, text) => total + text.length, 0),
156
- chars_out: translated.reduce((total, text) => total + text.length, 0),
157
- segments: input.texts.length,
158
- ms: now() - startedAt,
159
- cached: false,
160
- model: options.model,
161
- },
162
- },
163
- })
164
- }
165
-
166
- return translated
167
- }
168
-
169
- return { translateText, translateTexts }
170
- }
171
-
172
- export { __resetSyntheticPartIDForTest, createSyntheticPartID, hashText } from "./part-id"
@@ -1,43 +0,0 @@
1
- import { createHash, randomBytes } from "node:crypto"
2
-
3
- const PART_ID_LENGTH = 26
4
- const PART_ID_PREFIX = "prt"
5
- const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
6
-
7
- let partLastTimestamp = 0
8
- let partCounter = 0
9
-
10
- export function __resetSyntheticPartIDForTest() {
11
- partLastTimestamp = 0
12
- partCounter = 0
13
- }
14
-
15
- function randomBase62(length: number): string {
16
- const bytes = randomBytes(length)
17
- let result = ""
18
- for (let index = 0; index < length; index += 1) {
19
- result += BASE62_CHARS[bytes[index] % BASE62_CHARS.length]
20
- }
21
- return result
22
- }
23
-
24
- export function hashText(text: string): string {
25
- return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16)
26
- }
27
-
28
- export function createSyntheticPartID(): string {
29
- const currentTimestamp = Date.now()
30
- if (currentTimestamp !== partLastTimestamp) {
31
- partLastTimestamp = currentTimestamp
32
- partCounter = 0
33
- }
34
- partCounter += 1
35
-
36
- const encoded = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(partCounter)
37
- const timeBytes = Buffer.alloc(6)
38
- for (let index = 0; index < 6; index += 1) {
39
- timeBytes[index] = Number((encoded >> BigInt(40 - 8 * index)) & BigInt(0xff))
40
- }
41
-
42
- return `${PART_ID_PREFIX}_${timeBytes.toString("hex")}${randomBase62(PART_ID_LENGTH - 12)}`
43
- }