opencode-translate 0.3.6 → 1.0.1
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 +7 -2
- package/package.json +1 -1
- package/src/activation/question-hooks.ts +10 -9
- package/src/question-tool.ts +43 -12
package/README.md
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
# opencode-translate
|
|
2
2
|
|
|
3
|
+
> Use opencode in your native language. LLM always hears English.
|
|
4
|
+
|
|
3
5
|

|
|
4
6
|
|
|
7
|
+
## Demo
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
5
11
|
## Why
|
|
6
12
|
|
|
7
13
|
LLMs are worse in non-English. Benchmarks confirm it on every frontier model.
|
|
8
14
|
|
|
9
15
|
- **Claude Opus 4.7 / GPT-5.5 / Gemini 3.1 Pro**: identical coding tasks scored **5/5 in English** but dropped to **0–1/5 in Arabic and Korean** ([LILT, 2025](https://lilt.com/blog/multilingual-ai-coding-gap-non-english-developers)).
|
|
10
16
|
- **Anthropic's own numbers**: Japanese 96.9%, Korean 96.6%, Yoruba 80.3% — English is always the baseline ([Anthropic docs](https://platform.claude.com/docs/en/build-with-claude/multilingual-support)).
|
|
11
|
-
- **Translation Barrier Hypothesis** (JHU, IJCNLP 2025): LLMs reason in an English-dominant latent space, then translate at the last layers — every translation step is a source of error.
|
|
12
17
|
- **Token tax**: Korean ~1.25×, Japanese ~1.25×, Arabic ~3× more tokens per equivalent content. Higher cost, smaller effective context.
|
|
13
18
|
|
|
14
19
|
This plugin lets you write in your language while the model works in English — best of both worlds.
|
|
@@ -29,7 +34,7 @@ Add to `~/.config/opencode/opencode.jsonc`:
|
|
|
29
34
|
{
|
|
30
35
|
"plugin": [
|
|
31
36
|
["opencode-translate", {
|
|
32
|
-
"model": "
|
|
37
|
+
"model": "openai/gpt-5.4-mini", // model to use for translation
|
|
33
38
|
"lang": "Korean" // language you speak
|
|
34
39
|
}]
|
|
35
40
|
]
|
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isQuestionArgs,
|
|
5
5
|
type QuestionSnapshot,
|
|
6
6
|
type QuestionToolOutput,
|
|
7
|
+
restoreQuestionArgs,
|
|
7
8
|
restoreQuestionOutput,
|
|
8
9
|
snapshotQuestions,
|
|
9
10
|
translateQuestionArgs,
|
|
@@ -58,7 +59,11 @@ export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
questionSnapshots.set(input.callID, {
|
|
62
|
+
questionSnapshots.set(input.callID, {
|
|
63
|
+
original,
|
|
64
|
+
translated: snapshotQuestions(args),
|
|
65
|
+
userLanguage: activeState.translate_user_lang,
|
|
66
|
+
})
|
|
62
67
|
pruneQuestionSnapshots()
|
|
63
68
|
} catch (error) {
|
|
64
69
|
await logError(ctx.client, error)
|
|
@@ -73,10 +78,9 @@ export function createToolExecuteAfterHook(ctx: HookContext): NonNullable<Hooks[
|
|
|
73
78
|
const snapshot = questionSnapshots.get(input.callID)
|
|
74
79
|
if (!snapshot) return
|
|
75
80
|
questionSnapshots.delete(input.callID)
|
|
81
|
+
if (isQuestionArgs(input.args)) restoreQuestionArgs(input.args, snapshot.original)
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
const activeState = resolved.state
|
|
79
|
-
if (!activeState || activeState.translate_user_lang === LLM_LANGUAGE) {
|
|
83
|
+
if (snapshot.userLanguage === LLM_LANGUAGE) {
|
|
80
84
|
await restoreQuestionOutput(output as QuestionToolOutput, snapshot)
|
|
81
85
|
return
|
|
82
86
|
}
|
|
@@ -85,15 +89,12 @@ export function createToolExecuteAfterHook(ctx: HookContext): NonNullable<Hooks[
|
|
|
85
89
|
translateCustomAnswer: (text: string) =>
|
|
86
90
|
ctx.translator.translateText({
|
|
87
91
|
text,
|
|
88
|
-
sourceLanguage:
|
|
92
|
+
sourceLanguage: snapshot.userLanguage,
|
|
89
93
|
targetLanguage: LLM_LANGUAGE,
|
|
90
94
|
direction: "inbound",
|
|
91
95
|
}),
|
|
92
96
|
onTranslationError: async (error) => {
|
|
93
|
-
await logError(
|
|
94
|
-
ctx.client,
|
|
95
|
-
buildInboundTranslationError(activeState.translate_user_lang, normalizeReason(error)),
|
|
96
|
-
)
|
|
97
|
+
await logError(ctx.client, buildInboundTranslationError(snapshot.userLanguage, normalizeReason(error)))
|
|
97
98
|
},
|
|
98
99
|
})
|
|
99
100
|
} catch (error) {
|
package/src/question-tool.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface QuestionArgs {
|
|
|
29
29
|
export interface QuestionSnapshot {
|
|
30
30
|
original: TextRecord[]
|
|
31
31
|
translated: TextRecord[]
|
|
32
|
+
userLanguage: string
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export interface QuestionToolOutput {
|
|
@@ -56,6 +57,10 @@ export function snapshotQuestions(args: QuestionArgs): TextRecord[] {
|
|
|
56
57
|
return args.questions.map(cloneQuestion)
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
export function restoreQuestionArgs(args: QuestionArgs, original: readonly TextRecord[]): void {
|
|
61
|
+
args.questions.splice(0, args.questions.length, ...original.map(cloneQuestion))
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
export function isQuestionArgs(value: unknown): value is QuestionArgs {
|
|
60
65
|
if (!value || typeof value !== "object") return false
|
|
61
66
|
const questions = (value as Record<string, unknown>).questions
|
|
@@ -142,31 +147,55 @@ async function restoreLabel(
|
|
|
142
147
|
}
|
|
143
148
|
}
|
|
144
149
|
|
|
145
|
-
|
|
146
|
-
// if it had been called with the original English args. Mirrors the format
|
|
147
|
-
// in `packages/opencode/src/tool/question.ts` (as of opencode 1.14.x).
|
|
148
|
-
export async function buildRestoredOutput(
|
|
150
|
+
async function restoreQuestionAnswers(
|
|
149
151
|
original: readonly TextRecord[],
|
|
150
152
|
translated: readonly TextRecord[],
|
|
151
153
|
answers: readonly (readonly string[])[],
|
|
152
154
|
options: RestoreQuestionOutputOptions = {},
|
|
153
|
-
): Promise<string> {
|
|
154
|
-
|
|
155
|
+
): Promise<string[][]> {
|
|
156
|
+
return Promise.all(
|
|
155
157
|
original.map(async (q, i) => {
|
|
156
158
|
const selected = answers[i] ?? []
|
|
157
159
|
const translatedOptions = translated[i]?.options ?? []
|
|
158
160
|
const originalOptions = q.options
|
|
159
|
-
|
|
160
|
-
selected.map((label) => restoreLabel(label, translatedOptions, originalOptions, options)),
|
|
161
|
-
)
|
|
162
|
-
const rendered = restored.length > 0 ? restored.join(", ") : "Unanswered"
|
|
163
|
-
return `"${q.question}"="${rendered}"`
|
|
161
|
+
return Promise.all(selected.map((label) => restoreLabel(label, translatedOptions, originalOptions, options)))
|
|
164
162
|
}),
|
|
165
163
|
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function formatRestoredOutput(original: readonly TextRecord[], answers: readonly (readonly string[])[]): string {
|
|
167
|
+
const formattedParts = original.map((q, i) => {
|
|
168
|
+
const restored = answers[i] ?? []
|
|
169
|
+
const rendered = restored.length > 0 ? restored.join(", ") : "Unanswered"
|
|
170
|
+
return `"${q.question}"="${rendered}"`
|
|
171
|
+
})
|
|
166
172
|
const formatted = formattedParts.join(", ")
|
|
167
173
|
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
// Reconstruct the exact output string the question tool would have produced
|
|
177
|
+
// if it had been called with the original English args. Mirrors the format
|
|
178
|
+
// in `packages/opencode/src/tool/question.ts` (as of opencode 1.14.x).
|
|
179
|
+
export async function buildRestoredOutput(
|
|
180
|
+
original: readonly TextRecord[],
|
|
181
|
+
translated: readonly TextRecord[],
|
|
182
|
+
answers: readonly (readonly string[])[],
|
|
183
|
+
options: RestoreQuestionOutputOptions = {},
|
|
184
|
+
): Promise<string> {
|
|
185
|
+
const restoredAnswers = await restoreQuestionAnswers(original, translated, answers, options)
|
|
186
|
+
return formatRestoredOutput(original, restoredAnswers)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function mutableMetadata(output: QuestionToolOutput): Record<string, unknown> {
|
|
190
|
+
if (output.metadata && typeof output.metadata === "object" && !Array.isArray(output.metadata)) {
|
|
191
|
+
return output.metadata as Record<string, unknown>
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const metadata: Record<string, unknown> = {}
|
|
195
|
+
output.metadata = metadata
|
|
196
|
+
return metadata
|
|
197
|
+
}
|
|
198
|
+
|
|
170
199
|
export async function restoreQuestionOutput(
|
|
171
200
|
output: QuestionToolOutput,
|
|
172
201
|
snapshot: QuestionSnapshot,
|
|
@@ -175,5 +204,7 @@ export async function restoreQuestionOutput(
|
|
|
175
204
|
if (typeof output.output !== "string") return
|
|
176
205
|
const answersRaw = (output.metadata as { answers?: readonly (readonly string[])[] } | undefined)?.answers
|
|
177
206
|
const answers = Array.isArray(answersRaw) ? answersRaw : []
|
|
178
|
-
|
|
207
|
+
const restoredAnswers = await restoreQuestionAnswers(snapshot.original, snapshot.translated, answers, options)
|
|
208
|
+
output.output = formatRestoredOutput(snapshot.original, restoredAnswers)
|
|
209
|
+
mutableMetadata(output).answers = restoredAnswers
|
|
179
210
|
}
|