opencode-translate 0.3.3 → 0.3.5
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 +15 -0
- package/package.json +1 -1
- package/src/activation/messages-transform.ts +7 -1
- package/src/activation/parts.ts +1 -1
- package/src/activation/text-complete.ts +1 -2
- package/src/constants/options.ts +6 -3
- package/src/formatting.ts +108 -36
package/README.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# opencode-translate
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
LLMs are worse in non-English. Benchmarks confirm it on every frontier model.
|
|
8
|
+
|
|
9
|
+
- **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
|
+
- **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
|
+
- **Token tax**: Korean ~1.25×, Japanese ~1.25×, Arabic ~3× more tokens per equivalent content. Higher cost, smaller effective context.
|
|
13
|
+
|
|
14
|
+
This plugin lets you write in your language while the model works in English — best of both worlds.
|
|
15
|
+
|
|
16
|
+
> Full research write-up: [docs/why.en.md](./docs/why.en.md)
|
|
17
|
+
|
|
3
18
|
## Install
|
|
4
19
|
|
|
5
20
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
2
|
import { isTextPart, type MessageWithPartsLike } from "../constants"
|
|
3
3
|
import { extractEnglishHistoryText } from "../formatting"
|
|
4
|
+
import { getDisplayLanguageLabel } from "../labels"
|
|
4
5
|
import { logError } from "./logging"
|
|
5
6
|
import { isTranslatedUserDisplayPart } from "./metadata"
|
|
6
7
|
import { resolveSessionState } from "./state"
|
|
@@ -18,6 +19,11 @@ export function createMessagesTransformHook(ctx: HookContext): MessagesTransform
|
|
|
18
19
|
const activeState = resolved.state
|
|
19
20
|
if (!activeState) return
|
|
20
21
|
|
|
22
|
+
const extractContext = {
|
|
23
|
+
nonce: activeState.translate_nonce,
|
|
24
|
+
label: getDisplayLanguageLabel(activeState.translate_user_lang),
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
for (const message of output.messages as MessageWithPartsLike[]) {
|
|
22
28
|
if (message.info.role === "user") {
|
|
23
29
|
for (const part of message.parts) {
|
|
@@ -28,7 +34,7 @@ export function createMessagesTransformHook(ctx: HookContext): MessagesTransform
|
|
|
28
34
|
|
|
29
35
|
if (message.info.role !== "assistant") continue
|
|
30
36
|
for (const part of message.parts) {
|
|
31
|
-
if (isTextPart(part)) part.text = extractEnglishHistoryText(part.text,
|
|
37
|
+
if (isTextPart(part)) part.text = extractEnglishHistoryText(part.text, extractContext)
|
|
32
38
|
}
|
|
33
39
|
}
|
|
34
40
|
} catch (error) {
|
package/src/activation/parts.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { createSyntheticPartID } from "../translator"
|
|
|
9
9
|
|
|
10
10
|
export function createActivationBannerText(options: ResolvedTranslateOptions): string {
|
|
11
11
|
const { modelID } = parseTranslatorModel(options.model)
|
|
12
|
-
return `✓ Translation mode enabled ·
|
|
12
|
+
return `✓ Translation mode enabled · model: ${modelID} · language: ${options.lang}`
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function createLlmOnlyTextPart(
|
|
@@ -38,10 +38,9 @@ export function createTextCompleteHook(ctx: HookContext): TextCompleteHook {
|
|
|
38
38
|
output.text,
|
|
39
39
|
getDisplayLanguageLabel(activeState.translate_user_lang),
|
|
40
40
|
translated,
|
|
41
|
-
activeState.translate_nonce,
|
|
42
41
|
)
|
|
43
42
|
} catch (error) {
|
|
44
|
-
output.text = composeTranslationFailureText(output.text
|
|
43
|
+
output.text = composeTranslationFailureText(output.text)
|
|
45
44
|
await logError(ctx.client, error)
|
|
46
45
|
}
|
|
47
46
|
} catch (error) {
|
package/src/constants/options.ts
CHANGED
|
@@ -22,9 +22,12 @@ export function resolveOptions(options: Record<string, unknown>): ResolvedTransl
|
|
|
22
22
|
)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const
|
|
26
|
-
? options.trigger
|
|
27
|
-
:
|
|
25
|
+
const rawTrigger = Array.isArray(options.trigger)
|
|
26
|
+
? options.trigger
|
|
27
|
+
: Array.isArray(options.triggerKeywords)
|
|
28
|
+
? options.triggerKeywords
|
|
29
|
+
: DEFAULT_TRIGGER
|
|
30
|
+
const trigger = rawTrigger.filter((value): value is string => typeof value === "string" && value.length > 0)
|
|
28
31
|
|
|
29
32
|
return {
|
|
30
33
|
model,
|
package/src/formatting.ts
CHANGED
|
@@ -1,35 +1,111 @@
|
|
|
1
1
|
import { FAILURE_NOTICE } from "./constants"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// Visible bilingual trailer structure (no invisible delimiters):
|
|
4
|
+
//
|
|
5
|
+
// <english>
|
|
6
|
+
//
|
|
7
|
+
// ---
|
|
8
|
+
//
|
|
9
|
+
// **<label>:**
|
|
10
|
+
//
|
|
11
|
+
// <translated>
|
|
12
|
+
//
|
|
13
|
+
// Failure variant:
|
|
14
|
+
//
|
|
15
|
+
// <english>
|
|
16
|
+
//
|
|
17
|
+
// ---
|
|
18
|
+
//
|
|
19
|
+
// _Translation unavailable for this segment._
|
|
20
|
+
//
|
|
21
|
+
// The structure renders cleanly under every Markdown front-end we ship to
|
|
22
|
+
// (web `marked`, OpenTUI `<markdown>`, plain text). The history transform
|
|
23
|
+
// recognises it by walking the trailing `---` separator backwards through the
|
|
24
|
+
// stored text and matching the exact label (or failure notice) the plugin
|
|
25
|
+
// emitted for the active session.
|
|
26
|
+
|
|
27
|
+
const SEPARATOR_LINE = "---"
|
|
28
|
+
|
|
29
|
+
interface ExtractContext {
|
|
30
|
+
/** Activation nonce. Used only by the legacy marker fallback. */
|
|
31
|
+
nonce: string
|
|
32
|
+
/** Display language label used when composing assistant text. */
|
|
33
|
+
label: string
|
|
5
34
|
}
|
|
6
35
|
|
|
7
|
-
function
|
|
8
|
-
return
|
|
36
|
+
export function composeTranslatedAssistantText(english: string, label: string, translated: string): string {
|
|
37
|
+
return `${english}\n\n${SEPARATOR_LINE}\n\n**${label}:**\n\n${translated}`
|
|
9
38
|
}
|
|
10
39
|
|
|
11
|
-
function
|
|
12
|
-
return
|
|
40
|
+
export function composeTranslationFailureText(english: string): string {
|
|
41
|
+
return `${english}\n\n${SEPARATOR_LINE}\n\n${FAILURE_NOTICE}`
|
|
13
42
|
}
|
|
14
43
|
|
|
15
|
-
export function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
)
|
|
21
|
-
|
|
44
|
+
export function extractEnglishHistoryText(text: string, ctx: ExtractContext): string {
|
|
45
|
+
const legacy = extractLegacyMarkerTrailer(text, ctx.nonce)
|
|
46
|
+
if (legacy !== null) return legacy
|
|
47
|
+
|
|
48
|
+
const structural = extractStructuralTrailer(text, ctx.label)
|
|
49
|
+
if (structural !== null) return structural
|
|
50
|
+
|
|
51
|
+
return text
|
|
22
52
|
}
|
|
23
53
|
|
|
24
|
-
|
|
25
|
-
|
|
54
|
+
function extractStructuralTrailer(text: string, label: string): string | null {
|
|
55
|
+
const labelLine = `**${label}:**`
|
|
56
|
+
const lines = text.split("\n")
|
|
57
|
+
|
|
58
|
+
// Ignore trailing blank lines so a final `\n` (or several) does not throw
|
|
59
|
+
// off the structural match.
|
|
60
|
+
let endLine = lines.length - 1
|
|
61
|
+
while (endLine >= 0 && lines[endLine] === "") endLine -= 1
|
|
62
|
+
|
|
63
|
+
// Smallest valid failure trailer is 5 lines: english, "", ---, "", FAILURE.
|
|
64
|
+
if (endLine < 4) return null
|
|
65
|
+
|
|
66
|
+
// Walk backwards: the trailer's `---` is always preceded by exactly one
|
|
67
|
+
// blank line and a non-empty English half, and followed by exactly one
|
|
68
|
+
// blank line plus either the label line or the failure notice extending
|
|
69
|
+
// to the end of `text`.
|
|
70
|
+
for (let i = endLine; i >= 2; i -= 1) {
|
|
71
|
+
if (lines[i] !== SEPARATOR_LINE) continue
|
|
72
|
+
if (lines[i - 1] !== "") continue
|
|
73
|
+
if (i - 2 < 0) continue
|
|
74
|
+
if (i + 2 > endLine) continue
|
|
75
|
+
if (lines[i + 1] !== "") continue
|
|
76
|
+
|
|
77
|
+
const headLine = lines[i + 2]
|
|
78
|
+
|
|
79
|
+
if (headLine === labelLine) {
|
|
80
|
+
// Success trailer: `**label:**\n\n<translated...>` extending to end.
|
|
81
|
+
if (i + 3 > endLine) continue
|
|
82
|
+
if (lines[i + 3] !== "") continue
|
|
83
|
+
// Translated content occupies lines i+4..endLine and must be non-empty.
|
|
84
|
+
if (i + 4 > endLine) continue
|
|
85
|
+
return lines.slice(0, i - 1).join("\n")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (headLine === FAILURE_NOTICE) {
|
|
89
|
+
// Failure trailer ends exactly at the notice.
|
|
90
|
+
if (i + 2 !== endLine) continue
|
|
91
|
+
return lines.slice(0, i - 1).join("\n")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null
|
|
26
96
|
}
|
|
27
97
|
|
|
28
|
-
|
|
98
|
+
// Legacy fallback. Earlier versions of the plugin stored bilingual assistant
|
|
99
|
+
// text wrapped in `<!-- oc-translate:{nonce}:start -->` ... `<!-- oc-translate:{nonce}:end -->`
|
|
100
|
+
// HTML comments. Those comments render cleanly in the web UI but show up as
|
|
101
|
+
// literal text in the terminal UI, which is the bug this refactor fixes.
|
|
102
|
+
// We keep parsing them so existing sessions continue to feed English-only
|
|
103
|
+
// history to the LLM after the plugin is upgraded.
|
|
104
|
+
function extractLegacyMarkerTrailer(text: string, nonce: string): string | null {
|
|
29
105
|
const lines = text.split("\n")
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const exactFailed =
|
|
106
|
+
const exactStart = `<!-- oc-translate:${nonce}:start -->`
|
|
107
|
+
const exactEnd = `<!-- oc-translate:${nonce}:end -->`
|
|
108
|
+
const exactFailed = `<!-- oc-translate:${nonce}:status:failed -->`
|
|
33
109
|
|
|
34
110
|
let lastNonEmpty = -1
|
|
35
111
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
@@ -38,10 +114,7 @@ export function extractEnglishHistoryText(text: string, nonce: string): string {
|
|
|
38
114
|
break
|
|
39
115
|
}
|
|
40
116
|
}
|
|
41
|
-
|
|
42
|
-
if (lastNonEmpty < 0 || lines[lastNonEmpty] !== exactEnd) {
|
|
43
|
-
return text
|
|
44
|
-
}
|
|
117
|
+
if (lastNonEmpty < 0 || lines[lastNonEmpty] !== exactEnd) return null
|
|
45
118
|
|
|
46
119
|
let endIndex = -1
|
|
47
120
|
for (let index = lastNonEmpty; index >= 0; index -= 1) {
|
|
@@ -50,7 +123,7 @@ export function extractEnglishHistoryText(text: string, nonce: string): string {
|
|
|
50
123
|
break
|
|
51
124
|
}
|
|
52
125
|
}
|
|
53
|
-
if (endIndex < 0) return
|
|
126
|
+
if (endIndex < 0) return null
|
|
54
127
|
|
|
55
128
|
let startIndex = -1
|
|
56
129
|
for (let index = endIndex - 1; index >= 0; index -= 1) {
|
|
@@ -59,27 +132,26 @@ export function extractEnglishHistoryText(text: string, nonce: string): string {
|
|
|
59
132
|
break
|
|
60
133
|
}
|
|
61
134
|
}
|
|
62
|
-
if (startIndex < 2) return
|
|
135
|
+
if (startIndex < 2) return null
|
|
63
136
|
|
|
64
137
|
let cursor = startIndex + 1
|
|
65
138
|
const failed = lines[cursor] === exactFailed
|
|
66
139
|
if (failed) cursor += 1
|
|
67
140
|
|
|
68
|
-
if (lines[cursor] !==
|
|
69
|
-
if (lines[cursor + 1] !== "") return
|
|
141
|
+
if (lines[cursor] !== SEPARATOR_LINE) return null
|
|
142
|
+
if (lines[cursor + 1] !== "") return null
|
|
70
143
|
|
|
71
144
|
if (failed) {
|
|
72
|
-
if (lines[cursor + 2] !== FAILURE_NOTICE) return
|
|
73
|
-
if (lines[cursor + 3] !== "") return
|
|
74
|
-
if (cursor + 4 !== endIndex) return
|
|
145
|
+
if (lines[cursor + 2] !== FAILURE_NOTICE) return null
|
|
146
|
+
if (lines[cursor + 3] !== "") return null
|
|
147
|
+
if (cursor + 4 !== endIndex) return null
|
|
75
148
|
} else {
|
|
76
149
|
const labelLine = lines[cursor + 2]
|
|
77
|
-
if (!/^\*\*.+:\*\*$/.test(labelLine)) return
|
|
78
|
-
if (lines[cursor + 3] !== "") return
|
|
79
|
-
if (cursor + 4 > endIndex) return
|
|
150
|
+
if (!/^\*\*.+:\*\*$/.test(labelLine)) return null
|
|
151
|
+
if (lines[cursor + 3] !== "") return null
|
|
152
|
+
if (cursor + 4 > endIndex) return null
|
|
80
153
|
}
|
|
81
154
|
|
|
82
|
-
if (lines[startIndex - 1] !== "") return
|
|
83
|
-
|
|
84
|
-
return english
|
|
155
|
+
if (lines[startIndex - 1] !== "") return null
|
|
156
|
+
return lines.slice(0, startIndex - 1).join("\n")
|
|
85
157
|
}
|