opencode-translate 0.1.0 → 0.1.2

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
@@ -4,12 +4,12 @@
4
4
 
5
5
  ## What It Does
6
6
 
7
- - Activates once per root session when the first user message contains a trigger keyword such as `$en`.
7
+ - Activates once per root session when any root-session user message contains a trigger keyword such as `$en`.
8
8
  - Translates user-authored text parts from `sourceLanguage` to English before the main LLM sees them.
9
9
  - Stores the original user text, plus a cached English translation in part metadata.
10
10
  - Shows a visible `→ EN: ...` preview under each translated user text part.
11
11
  - Translates assistant text parts from English into `displayLanguage` when each text part completes.
12
- - Translates the built-in `question` tool's question text, header, and every option's label and description into `displayLanguage` so the TUI confirmation dialog is in the user's language. The tool output string returned to the LLM is restored to English, keeping the chat history English-only.
12
+ - Translates the built-in `question` tool's question text, header, and every option's label and description into `displayLanguage` so the TUI confirmation dialog is in the user's language. The tool output string returned to the LLM is restored to English, including translation of non-empty custom answers.
13
13
  - Stores assistant text as:
14
14
 
15
15
  ```md
@@ -42,7 +42,7 @@ Hooks never throw. If the translator fails (network error, auth failure, provide
42
42
  1. Logs the error via `client.app.log` (visible with `verbose: true`).
43
43
  2. Emits a `⚠️ Translation failed: …` synthetic part.
44
44
  3. Falls back to sending the original (untranslated) user text to the model.
45
- 4. On first-turn activation failure, it also rolls back activation so the next turn retries cleanly.
45
+ 4. On activation-turn failure, it also rolls back activation so the next turn retries cleanly.
46
46
 
47
47
  A stalled provider request is additionally bounded by a 60s hard timeout per translation call, so a hung upstream cannot block the OpenCode session.
48
48
 
@@ -75,7 +75,7 @@ opencode auth login anthropic
75
75
  export ANTHROPIC_API_KEY=...
76
76
  ```
77
77
 
78
- Start a brand-new session and put the trigger in the first message:
78
+ Put the trigger in the message where translation should begin. Earlier messages in the session are left as-is:
79
79
 
80
80
  ```text
81
81
  $en 프로젝트 루트의 package.json을 읽고 요약해줘
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode plugin that lets the user chat in a configured source language while the main chat loop only sees English.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/activation.ts CHANGED
@@ -32,7 +32,12 @@ import {
32
32
  } from "./question-tool"
33
33
  import { createSyntheticPartID, createTranslator, hashText } from "./translator"
34
34
 
35
- const sessionStateCache = new Map<string, TranslateState | null>()
35
+ const INACTIVE_ROOT_SESSION = "inactive-root"
36
+ const INACTIVE_CHILD_SESSION = "inactive-child"
37
+
38
+ type CachedSessionState = TranslateState | typeof INACTIVE_ROOT_SESSION | typeof INACTIVE_CHILD_SESSION
39
+
40
+ const sessionStateCache = new Map<string, CachedSessionState>()
36
41
  const questionSnapshots = new Map<string, QuestionSnapshot>()
37
42
  const QUESTION_TOOL_ID = "question"
38
43
 
@@ -249,10 +254,26 @@ async function resolveSessionState(
249
254
  ): Promise<ResolvedSessionState> {
250
255
  const cached = sessionStateCache.get(sessionID)
251
256
  if (cached !== undefined) {
257
+ if (cached === INACTIVE_ROOT_SESSION) {
258
+ return {
259
+ sessionActive: false,
260
+ canActivate: true,
261
+ storedMessages: [],
262
+ }
263
+ }
264
+
265
+ if (cached === INACTIVE_CHILD_SESSION) {
266
+ return {
267
+ sessionActive: false,
268
+ canActivate: false,
269
+ storedMessages: [],
270
+ }
271
+ }
272
+
252
273
  return {
253
- sessionActive: Boolean(cached),
274
+ sessionActive: true,
254
275
  canActivate: false,
255
- state: cached ?? undefined,
276
+ state: cached,
256
277
  storedMessages: [],
257
278
  }
258
279
  }
@@ -265,7 +286,7 @@ async function resolveSessionState(
265
286
  }),
266
287
  )
267
288
  if (session.parentID != null) {
268
- sessionStateCache.set(sessionID, null)
289
+ sessionStateCache.set(sessionID, INACTIVE_CHILD_SESSION)
269
290
  return { sessionActive: false, canActivate: false, storedMessages: [] }
270
291
  }
271
292
 
@@ -279,13 +300,13 @@ async function resolveSessionState(
279
300
  const state = extractStoredState(storedMessages)
280
301
  if (state) {
281
302
  sessionStateCache.set(sessionID, state)
282
- } else if (storedMessages.length > 0) {
283
- sessionStateCache.set(sessionID, null)
303
+ } else {
304
+ sessionStateCache.set(sessionID, INACTIVE_ROOT_SESSION)
284
305
  }
285
306
 
286
307
  return {
287
308
  sessionActive: Boolean(state),
288
- canActivate: storedMessages.length === 0,
309
+ canActivate: !state,
289
310
  state: state ?? undefined,
290
311
  storedMessages,
291
312
  }
@@ -326,8 +347,6 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
326
347
  }
327
348
  activatedThisTurn = true
328
349
  sessionStateCache.set(input.sessionID, activeState)
329
- } else {
330
- sessionStateCache.set(input.sessionID, null)
331
350
  }
332
351
  }
333
352
 
@@ -424,7 +443,7 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
424
443
  // user-authored part, roll back activation so the next turn does a
425
444
  // clean retry instead of cementing broken state.
426
445
  if (activatedThisTurn && translationErrors.length > 0 && eligibleIndex === translationErrors.length) {
427
- sessionStateCache.set(input.sessionID, null)
446
+ sessionStateCache.set(input.sessionID, INACTIVE_ROOT_SESSION)
428
447
  return
429
448
  }
430
449
 
@@ -536,35 +555,36 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
536
555
  },
537
556
  // Translate the built-in `question` tool so the TUI dialog renders in
538
557
  // the user's displayLanguage. The tool output string is restored back
539
- // to English in `tool.execute.after` so the main LLM context stays
540
- // English-only.
558
+ // to English in `tool.execute.after`; typed custom answers also go
559
+ // through the inbound user-message translation path.
541
560
  "tool.execute.before": async (input, output) => {
542
561
  try {
543
562
  if (input.tool !== QUESTION_TOOL_ID) return
544
563
  const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
545
564
  const activeState = resolved.state
546
565
  if (!activeState) return
547
- if (activeState.translate_display_lang === LLM_LANGUAGE) return
548
566
 
549
567
  const args = output.args as unknown
550
568
  if (!isQuestionArgs(args)) return
551
569
 
552
570
  const original = snapshotQuestions(args)
553
- try {
554
- await translateQuestionArgs(args, (text) =>
555
- translator.translateText({
556
- text,
557
- sourceLanguage: LLM_LANGUAGE,
558
- targetLanguage: activeState.translate_display_lang,
559
- direction: "outbound",
560
- }),
561
- )
562
- } catch (error) {
563
- // Translation failed: restore the originals so the dialog at least
564
- // renders in English instead of a half-translated mess.
565
- args.questions.splice(0, args.questions.length, ...snapshotQuestions({ questions: original }))
566
- await logError(client, error)
567
- return
571
+ if (activeState.translate_display_lang !== LLM_LANGUAGE) {
572
+ try {
573
+ await translateQuestionArgs(args, (text) =>
574
+ translator.translateText({
575
+ text,
576
+ sourceLanguage: LLM_LANGUAGE,
577
+ targetLanguage: activeState.translate_display_lang,
578
+ direction: "outbound",
579
+ }),
580
+ )
581
+ } catch (error) {
582
+ // Translation failed: restore the originals so the dialog at least
583
+ // renders in English instead of a half-translated mess.
584
+ args.questions.splice(0, args.questions.length, ...snapshotQuestions({ questions: original }))
585
+ await logError(client, error)
586
+ return
587
+ }
568
588
  }
569
589
 
570
590
  const translated = snapshotQuestions(args)
@@ -579,7 +599,32 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
579
599
  const snapshot = questionSnapshots.get(input.callID)
580
600
  if (!snapshot) return
581
601
  questionSnapshots.delete(input.callID)
582
- restoreQuestionOutput(output as QuestionToolOutput, snapshot)
602
+ const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
603
+ const activeState = resolved.state
604
+ const translateCustomAnswer =
605
+ activeState && activeState.translate_source_lang !== LLM_LANGUAGE
606
+ ? (text: string) =>
607
+ translator.translateText({
608
+ text,
609
+ sourceLanguage: activeState.translate_source_lang,
610
+ targetLanguage: LLM_LANGUAGE,
611
+ direction: "inbound",
612
+ })
613
+ : undefined
614
+
615
+ await restoreQuestionOutput(output as QuestionToolOutput, snapshot, {
616
+ ...(translateCustomAnswer ? { translateCustomAnswer } : {}),
617
+ onTranslationError: async (error) => {
618
+ if (!activeState) {
619
+ await logError(client, error)
620
+ return
621
+ }
622
+ await logError(
623
+ client,
624
+ buildInboundTranslationError(activeState.translate_source_lang, normalizeReason(error)),
625
+ )
626
+ },
627
+ })
583
628
  } catch (error) {
584
629
  await logError(client, error)
585
630
  }
@@ -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
  }