opencode-translate 0.0.4 → 0.0.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 +1 -0
- package/package.json +1 -1
- package/src/activation.ts +61 -0
- package/src/question-tool.ts +142 -0
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
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
13
|
- Stores assistant text as:
|
|
13
14
|
|
|
14
15
|
```md
|
package/package.json
CHANGED
package/src/activation.ts
CHANGED
|
@@ -23,12 +23,23 @@ import {
|
|
|
23
23
|
} from "./constants"
|
|
24
24
|
import { composeTranslatedAssistantText, composeTranslationFailureText, extractEnglishHistoryText } from "./formatting"
|
|
25
25
|
import { getDisplayLanguageLabel } from "./labels"
|
|
26
|
+
import {
|
|
27
|
+
isQuestionArgs,
|
|
28
|
+
type QuestionSnapshot,
|
|
29
|
+
type QuestionToolOutput,
|
|
30
|
+
restoreQuestionOutput,
|
|
31
|
+
snapshotQuestions,
|
|
32
|
+
translateQuestionArgs,
|
|
33
|
+
} from "./question-tool"
|
|
26
34
|
import { createSyntheticPartID, createTranslator, hashText } from "./translator"
|
|
27
35
|
|
|
28
36
|
const sessionStateCache = new Map<string, TranslateState | null>()
|
|
37
|
+
const questionSnapshots = new Map<string, QuestionSnapshot>()
|
|
38
|
+
const QUESTION_TOOL_ID = "question"
|
|
29
39
|
|
|
30
40
|
export function __resetActivationCacheForTest() {
|
|
31
41
|
sessionStateCache.clear()
|
|
42
|
+
questionSnapshots.clear()
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
interface ResolvedSessionState {
|
|
@@ -452,5 +463,55 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
|
|
|
452
463
|
await logError(client, error)
|
|
453
464
|
}
|
|
454
465
|
},
|
|
466
|
+
// Translate the built-in `question` tool so the TUI dialog renders in
|
|
467
|
+
// the user's displayLanguage. The tool output string is restored back
|
|
468
|
+
// to English in `tool.execute.after` so the main LLM context stays
|
|
469
|
+
// English-only.
|
|
470
|
+
"tool.execute.before": async (input, output) => {
|
|
471
|
+
try {
|
|
472
|
+
if (input.tool !== QUESTION_TOOL_ID) return
|
|
473
|
+
const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
|
|
474
|
+
const activeState = resolved.state
|
|
475
|
+
if (!activeState) return
|
|
476
|
+
if (activeState.translate_display_lang === LLM_LANGUAGE) return
|
|
477
|
+
|
|
478
|
+
const args = output.args as unknown
|
|
479
|
+
if (!isQuestionArgs(args)) return
|
|
480
|
+
|
|
481
|
+
const original = snapshotQuestions(args)
|
|
482
|
+
try {
|
|
483
|
+
await translateQuestionArgs(args, (text) =>
|
|
484
|
+
translator.translateText({
|
|
485
|
+
text,
|
|
486
|
+
sourceLanguage: LLM_LANGUAGE,
|
|
487
|
+
targetLanguage: activeState.translate_display_lang,
|
|
488
|
+
direction: "outbound",
|
|
489
|
+
}),
|
|
490
|
+
)
|
|
491
|
+
} catch (error) {
|
|
492
|
+
// Translation failed: restore the originals so the dialog at least
|
|
493
|
+
// renders in English instead of a half-translated mess.
|
|
494
|
+
args.questions.splice(0, args.questions.length, ...snapshotQuestions({ questions: original }))
|
|
495
|
+
await logError(client, error)
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const translated = snapshotQuestions(args)
|
|
500
|
+
questionSnapshots.set(input.callID, { original, translated })
|
|
501
|
+
} catch (error) {
|
|
502
|
+
await logError(client, error)
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
"tool.execute.after": async (input, output) => {
|
|
506
|
+
try {
|
|
507
|
+
if (input.tool !== QUESTION_TOOL_ID) return
|
|
508
|
+
const snapshot = questionSnapshots.get(input.callID)
|
|
509
|
+
if (!snapshot) return
|
|
510
|
+
questionSnapshots.delete(input.callID)
|
|
511
|
+
restoreQuestionOutput(output as QuestionToolOutput, snapshot)
|
|
512
|
+
} catch (error) {
|
|
513
|
+
await logError(client, error)
|
|
514
|
+
}
|
|
515
|
+
},
|
|
455
516
|
}
|
|
456
517
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Translation layer for OpenCode's built-in `question` tool.
|
|
2
|
+
//
|
|
3
|
+
// Flow:
|
|
4
|
+
// 1. Agent (main LLM, English-only) invokes the `question` tool with an
|
|
5
|
+
// `args.questions[]` payload in English.
|
|
6
|
+
// 2. `tool.execute.before` hook translates each question's text, header,
|
|
7
|
+
// and every option's label + description into `displayLanguage` so the
|
|
8
|
+
// question prompt renders in the user's language.
|
|
9
|
+
// 3. OpenCode publishes `question.asked`; the TUI shows the translated
|
|
10
|
+
// dialog and the user picks an option (or types a custom answer).
|
|
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.
|
|
14
|
+
//
|
|
15
|
+
// A per-callID snapshot is kept so mapping a user-selected translated label
|
|
16
|
+
// back to its original English label is deterministic.
|
|
17
|
+
|
|
18
|
+
type TextRecord = { question: string; header: string; options: OptionRecord[]; multiple?: boolean; custom?: boolean }
|
|
19
|
+
type OptionRecord = { label: string; description: string }
|
|
20
|
+
|
|
21
|
+
export interface QuestionArgs {
|
|
22
|
+
questions: TextRecord[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface QuestionSnapshot {
|
|
26
|
+
original: TextRecord[]
|
|
27
|
+
translated: TextRecord[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface QuestionToolOutput {
|
|
31
|
+
title?: string
|
|
32
|
+
output?: string
|
|
33
|
+
metadata?: { answers?: readonly (readonly string[])[] } | Record<string, unknown>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cloneQuestion(q: TextRecord): TextRecord {
|
|
37
|
+
return {
|
|
38
|
+
question: q.question,
|
|
39
|
+
header: q.header,
|
|
40
|
+
options: q.options.map((option) => ({ label: option.label, description: option.description })),
|
|
41
|
+
...(q.multiple !== undefined ? { multiple: q.multiple } : {}),
|
|
42
|
+
...(q.custom !== undefined ? { custom: q.custom } : {}),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function snapshotQuestions(args: QuestionArgs): TextRecord[] {
|
|
47
|
+
return args.questions.map(cloneQuestion)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isQuestionArgs(value: unknown): value is QuestionArgs {
|
|
51
|
+
if (!value || typeof value !== "object") return false
|
|
52
|
+
const questions = (value as Record<string, unknown>).questions
|
|
53
|
+
if (!Array.isArray(questions)) return false
|
|
54
|
+
for (const q of questions) {
|
|
55
|
+
if (!q || typeof q !== "object") return false
|
|
56
|
+
const record = q as Record<string, unknown>
|
|
57
|
+
if (typeof record.question !== "string") return false
|
|
58
|
+
if (typeof record.header !== "string") return false
|
|
59
|
+
if (!Array.isArray(record.options)) return false
|
|
60
|
+
for (const opt of record.options) {
|
|
61
|
+
if (!opt || typeof opt !== "object") return false
|
|
62
|
+
const optRecord = opt as Record<string, unknown>
|
|
63
|
+
if (typeof optRecord.label !== "string") return false
|
|
64
|
+
if (typeof optRecord.description !== "string") return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function assignTranslation(
|
|
71
|
+
container: Record<string, string>,
|
|
72
|
+
key: string,
|
|
73
|
+
translate: (text: string) => Promise<string>,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const original = container[key]
|
|
76
|
+
if (!original || original.length === 0) return
|
|
77
|
+
const translated = await translate(original)
|
|
78
|
+
container[key] = translated
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Translate every display-facing string in `args` in parallel. Returns the
|
|
82
|
+
// snapshot of the translated form so the caller can pair it with the
|
|
83
|
+
// pre-translation snapshot taken with `snapshotQuestions`.
|
|
84
|
+
export async function translateQuestionArgs(
|
|
85
|
+
args: QuestionArgs,
|
|
86
|
+
translate: (text: string) => Promise<string>,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const jobs: Promise<void>[] = []
|
|
89
|
+
|
|
90
|
+
for (const q of args.questions) {
|
|
91
|
+
jobs.push(assignTranslation(q as unknown as Record<string, string>, "question", translate))
|
|
92
|
+
jobs.push(assignTranslation(q as unknown as Record<string, string>, "header", translate))
|
|
93
|
+
for (const option of q.options) {
|
|
94
|
+
jobs.push(assignTranslation(option as unknown as Record<string, string>, "label", translate))
|
|
95
|
+
jobs.push(assignTranslation(option as unknown as Record<string, string>, "description", translate))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await Promise.all(jobs)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Given the user-selected labels (`answers`), find the matching translated
|
|
103
|
+
// option and return its original English label. If no match (e.g. a custom
|
|
104
|
+
// free-text answer), return the label verbatim so the LLM still sees what
|
|
105
|
+
// the user actually typed.
|
|
106
|
+
function restoreLabel(
|
|
107
|
+
selectedLabel: string,
|
|
108
|
+
translatedOptions: readonly OptionRecord[],
|
|
109
|
+
originalOptions: readonly OptionRecord[],
|
|
110
|
+
): string {
|
|
111
|
+
const idx = translatedOptions.findIndex((option) => option.label === selectedLabel)
|
|
112
|
+
if (idx < 0) return selectedLabel
|
|
113
|
+
return originalOptions[idx]?.label ?? selectedLabel
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Reconstruct the exact output string the question tool would have produced
|
|
117
|
+
// if it had been called with the original English args. Mirrors the format
|
|
118
|
+
// in `packages/opencode/src/tool/question.ts` (as of opencode 1.14.x).
|
|
119
|
+
export function buildRestoredOutput(
|
|
120
|
+
original: readonly TextRecord[],
|
|
121
|
+
translated: readonly TextRecord[],
|
|
122
|
+
answers: readonly (readonly string[])[],
|
|
123
|
+
): string {
|
|
124
|
+
const formatted = original
|
|
125
|
+
.map((q, i) => {
|
|
126
|
+
const selected = answers[i] ?? []
|
|
127
|
+
const translatedOptions = translated[i]?.options ?? []
|
|
128
|
+
const originalOptions = q.options
|
|
129
|
+
const restored = selected.map((label) => restoreLabel(label, translatedOptions, originalOptions))
|
|
130
|
+
const rendered = restored.length > 0 ? restored.join(", ") : "Unanswered"
|
|
131
|
+
return `"${q.question}"="${rendered}"`
|
|
132
|
+
})
|
|
133
|
+
.join(", ")
|
|
134
|
+
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function restoreQuestionOutput(output: QuestionToolOutput, snapshot: QuestionSnapshot): void {
|
|
138
|
+
if (typeof output.output !== "string") return
|
|
139
|
+
const answersRaw = (output.metadata as { answers?: readonly (readonly string[])[] } | undefined)?.answers
|
|
140
|
+
const answers = Array.isArray(answersRaw) ? answersRaw : []
|
|
141
|
+
output.output = buildRestoredOutput(snapshot.original, snapshot.translated, answers)
|
|
142
|
+
}
|