opencode-translate 0.3.4 → 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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # opencode-translate
2
2
 
3
+ ![banner](https://github.com/user-attachments/assets/0bb6739c-abc5-4c3e-837e-2aaf5533a359)
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,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "OpenCode plugin that lets the user chat in a configured language while the main chat loop only sees English.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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, activeState.translate_nonce)
37
+ if (isTextPart(part)) part.text = extractEnglishHistoryText(part.text, extractContext)
32
38
  }
33
39
  }
34
40
  } catch (error) {
@@ -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 · translator: ${modelID} · language: ${options.lang}`
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, activeState.translate_nonce)
43
+ output.text = composeTranslationFailureText(output.text)
45
44
  await logError(ctx.client, error)
46
45
  }
47
46
  } catch (error) {
package/src/formatting.ts CHANGED
@@ -1,35 +1,111 @@
1
1
  import { FAILURE_NOTICE } from "./constants"
2
2
 
3
- function startMarker(nonce: string) {
4
- return `<!-- oc-translate:${nonce}:start -->`
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 endMarker(nonce: string) {
8
- return `<!-- oc-translate:${nonce}:end -->`
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 failedMarker(nonce: string) {
12
- return `<!-- oc-translate:${nonce}:status:failed -->`
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 composeTranslatedAssistantText(
16
- english: string,
17
- label: string,
18
- translated: string,
19
- nonce: string,
20
- ): string {
21
- return `${english}\n\n${startMarker(nonce)}\n---\n\n**${label}:**\n\n${translated}\n${endMarker(nonce)}`
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
- export function composeTranslationFailureText(english: string, nonce: string): string {
25
- return `${english}\n\n${startMarker(nonce)}\n${failedMarker(nonce)}\n---\n\n${FAILURE_NOTICE}\n\n${endMarker(nonce)}`
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
- export function extractEnglishHistoryText(text: string, nonce: string): string {
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 exactEnd = endMarker(nonce)
31
- const exactStart = startMarker(nonce)
32
- const exactFailed = failedMarker(nonce)
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 text
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 text
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] !== "---") return text
69
- if (lines[cursor + 1] !== "") return text
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 text
73
- if (lines[cursor + 3] !== "") return text
74
- if (cursor + 4 !== endIndex) return text
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 text
78
- if (lines[cursor + 3] !== "") return text
79
- if (cursor + 4 > endIndex) return text
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 text
83
- const english = lines.slice(0, startIndex - 1).join("\n")
84
- return english
155
+ if (lines[startIndex - 1] !== "") return null
156
+ return lines.slice(0, startIndex - 1).join("\n")
85
157
  }