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 +4 -4
- package/package.json +1 -1
- package/src/activation.ts +74 -29
- package/src/question-tool.ts +45 -22
package/README.md
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
## What It Does
|
|
6
6
|
|
|
7
|
-
- Activates once per root session when
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
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
|
|
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:
|
|
274
|
+
sessionActive: true,
|
|
254
275
|
canActivate: false,
|
|
255
|
-
state: cached
|
|
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,
|
|
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
|
|
283
|
-
sessionStateCache.set(sessionID,
|
|
303
|
+
} else {
|
|
304
|
+
sessionStateCache.set(sessionID, INACTIVE_ROOT_SESSION)
|
|
284
305
|
}
|
|
285
306
|
|
|
286
307
|
return {
|
|
287
308
|
sessionActive: Boolean(state),
|
|
288
|
-
canActivate:
|
|
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,
|
|
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
|
|
540
|
-
//
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/question-tool.ts
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
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.
|
|
84
|
-
// snapshot
|
|
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
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// the user
|
|
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
|
-
|
|
118
|
+
options: RestoreQuestionOutputOptions,
|
|
119
|
+
): Promise<string> {
|
|
113
120
|
const idx = translatedOptions.findIndex((option) => option.label === selectedLabel)
|
|
114
|
-
if (idx
|
|
115
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
}
|