opencode-translate 0.1.2 → 0.2.0
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 +11 -11
- package/package.json +7 -4
- package/src/activation/chat-message.ts +164 -0
- package/src/activation/index.ts +38 -0
- package/src/activation/logging.ts +11 -0
- package/src/activation/messages-transform.ts +38 -0
- package/src/activation/metadata.ts +41 -0
- package/src/activation/parts.ts +53 -0
- package/src/activation/question-hooks.ts +95 -0
- package/src/activation/state.ts +97 -0
- package/src/activation/text-complete.ts +51 -0
- package/src/activation/trigger.ts +57 -0
- package/src/activation/types.ts +41 -0
- package/src/activation.ts +1 -633
- package/src/anthropic-oauth.ts +3 -3
- package/src/auth/codex-request.ts +108 -0
- package/src/auth/codex-response.ts +78 -0
- package/src/auth/codex-shared.ts +3 -0
- package/src/auth/headers.ts +18 -0
- package/src/auth/index.ts +153 -0
- package/src/auth/oauth-fetch.ts +100 -0
- package/src/auth/refresh.ts +102 -0
- package/src/auth/retry.ts +70 -0
- package/src/auth/store.ts +45 -0
- package/src/auth/types.ts +27 -0
- package/src/auth.ts +1 -725
- package/src/constants/errors.ts +24 -0
- package/src/constants/guards.ts +33 -0
- package/src/constants/options.ts +41 -0
- package/src/constants/plugin.ts +10 -0
- package/src/constants/types.ts +144 -0
- package/src/constants.ts +5 -261
- package/src/labels.ts +2 -16
- package/src/prompts.ts +2 -17
- package/src/question-tool.ts +1 -1
- package/src/translator/index.ts +125 -0
- package/src/translator/part-id.ts +43 -0
- package/src/translator/provider.ts +81 -0
- package/src/translator/retry.ts +62 -0
- package/src/translator/types.ts +17 -0
- package/src/translator.ts +1 -326
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# opencode-translate
|
|
2
2
|
|
|
3
|
-
`opencode-translate` is an OpenCode plugin that lets the user chat in a configured `
|
|
3
|
+
`opencode-translate` is an OpenCode plugin that lets the user chat in a configured `lang` while the main chat loop and compaction summariser only see English.
|
|
4
4
|
|
|
5
5
|
## What It Does
|
|
6
6
|
|
|
7
7
|
- Activates once per root session when any root-session user message contains a trigger keyword such as `$en`.
|
|
8
|
-
- Translates user-authored text parts from `
|
|
8
|
+
- Translates user-authored text parts from `lang` 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
|
-
- Translates assistant text parts from English into `
|
|
12
|
-
- Translates the built-in `question` tool's question text, header, and every option's label and description into `
|
|
11
|
+
- Translates assistant text parts from English into `lang` when each text part completes.
|
|
12
|
+
- Translates the built-in `question` tool's question text, header, and every option's label and description into `lang` 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
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
<!-- oc-translate:{nonce}:start -->
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
-
**{
|
|
21
|
+
**Translation ({lang}):**
|
|
22
22
|
|
|
23
23
|
<translated>
|
|
24
24
|
<!-- oc-translate:{nonce}:end -->
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
## v1 Limits
|
|
30
30
|
|
|
31
31
|
- No mid-session toggle off.
|
|
32
|
-
- No auto-detection of
|
|
32
|
+
- No auto-detection of the user's language. `lang` is required.
|
|
33
33
|
- No title translation or title-path English enforcement.
|
|
34
34
|
- No subagent translation.
|
|
35
35
|
- No translation of tool inputs, tool outputs, or reasoning parts.
|
|
@@ -60,8 +60,7 @@ Add it to `~/.config/opencode/opencode.json`:
|
|
|
60
60
|
["opencode-translate", {
|
|
61
61
|
"translatorModel": "anthropic/claude-haiku-4-5",
|
|
62
62
|
"triggerKeywords": ["$en"],
|
|
63
|
-
"
|
|
64
|
-
"displayLanguage": "ko",
|
|
63
|
+
"lang": "Korean",
|
|
65
64
|
"verbose": false
|
|
66
65
|
}]
|
|
67
66
|
]
|
|
@@ -87,11 +86,12 @@ $en 프로젝트 루트의 package.json을 읽고 요약해줘
|
|
|
87
86
|
| --- | --- | --- |
|
|
88
87
|
| `translatorModel` | string | `anthropic/claude-haiku-4-5` |
|
|
89
88
|
| `triggerKeywords` | string[] | `[$en]` |
|
|
90
|
-
| `
|
|
91
|
-
| `displayLanguage` | string | `en` |
|
|
89
|
+
| `lang` | string | Required |
|
|
92
90
|
| `apiKey` | string | `undefined` |
|
|
93
91
|
| `verbose` | boolean | `false` |
|
|
94
92
|
|
|
93
|
+
Set `lang` to the full name of the language the user reads and writes, such as `"Korean"`, `"Japanese"`, or `"Brazilian Portuguese"`. The value is injected directly into translation prompts, so language code mapping is not required.
|
|
94
|
+
|
|
95
95
|
## Privacy
|
|
96
96
|
|
|
97
97
|
Using this plugin means text goes to two model providers per turn:
|
|
@@ -133,7 +133,7 @@ If you prefer a plain API key, set `OPENAI_API_KEY` in the environment or pass `
|
|
|
133
133
|
|
|
134
134
|
## Manual Smoke Test
|
|
135
135
|
|
|
136
|
-
1. Install the plugin and configure `
|
|
136
|
+
1. Install the plugin and configure `lang: "Korean"`.
|
|
137
137
|
2. Start a new session and send `$en 프로젝트 루트의 package.json을 읽고 요약해줘`.
|
|
138
138
|
3. Confirm the activation banner appears.
|
|
139
139
|
4. Confirm the `→ EN: ...` preview appears under the user message.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-translate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "OpenCode plugin that lets the user chat in a configured
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
7
7
|
"license": "MIT",
|
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
"format:check": "biome format .",
|
|
35
35
|
"lint": "biome lint --write .",
|
|
36
36
|
"lint:check": "biome lint .",
|
|
37
|
+
"knip": "knip",
|
|
37
38
|
"check": "biome check --write .",
|
|
38
39
|
"check:ci": "biome check .",
|
|
39
|
-
"test": "bun test"
|
|
40
|
+
"test": "bun test",
|
|
41
|
+
"test:coverage": "bun test --coverage"
|
|
40
42
|
},
|
|
41
43
|
"peerDependencies": {
|
|
42
44
|
"@opencode-ai/plugin": ">=1.14.0"
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
"@opencode-ai/plugin": "^1.14.21",
|
|
56
58
|
"@types/node": "^25.6.0",
|
|
57
59
|
"@typescript/native-preview": "beta",
|
|
58
|
-
"bun-types": "^1.3.13"
|
|
60
|
+
"bun-types": "^1.3.13",
|
|
61
|
+
"knip": "^6.14.1"
|
|
59
62
|
}
|
|
60
63
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import {
|
|
3
|
+
buildInboundTranslationError,
|
|
4
|
+
isUserAuthoredTextPart,
|
|
5
|
+
LLM_LANGUAGE,
|
|
6
|
+
NONCE_PATTERN,
|
|
7
|
+
normalizeReason,
|
|
8
|
+
type TextPartLike,
|
|
9
|
+
type TranslateState,
|
|
10
|
+
} from "../constants"
|
|
11
|
+
import { hashText } from "../translator"
|
|
12
|
+
import { logError } from "./logging"
|
|
13
|
+
import { mergeTranslatedMetadata } from "./metadata"
|
|
14
|
+
import { createActivationBannerPart, createActivationBannerText, createLlmOnlyTextPart } from "./parts"
|
|
15
|
+
import { cacheSessionState, createState, resolveSessionState } from "./state"
|
|
16
|
+
import { findTriggerMatch, stripTriggerKeyword } from "./trigger"
|
|
17
|
+
import { type HookContext, INACTIVE_ROOT_SESSION, type ResolvedSessionState } from "./types"
|
|
18
|
+
|
|
19
|
+
type ChatMessageHook = NonNullable<Hooks["chat.message"]>
|
|
20
|
+
type ChatMessageInput = Parameters<ChatMessageHook>[0]
|
|
21
|
+
type ChatMessageOutput = Parameters<ChatMessageHook>[1]
|
|
22
|
+
|
|
23
|
+
interface ActivationResult {
|
|
24
|
+
state?: TranslateState
|
|
25
|
+
activatedThisTurn: boolean
|
|
26
|
+
aborted: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PartProcessingResult {
|
|
30
|
+
nextParts: TextPartLike[]
|
|
31
|
+
eligibleIndex: number
|
|
32
|
+
errors: { part: TextPartLike; error: unknown }[]
|
|
33
|
+
firstUserTextPart?: TextPartLike & { text: string }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function activateFromTrigger(
|
|
37
|
+
ctx: HookContext,
|
|
38
|
+
input: ChatMessageInput,
|
|
39
|
+
output: ChatMessageOutput,
|
|
40
|
+
resolved: ResolvedSessionState,
|
|
41
|
+
): Promise<ActivationResult> {
|
|
42
|
+
if (resolved.state || !resolved.canActivate)
|
|
43
|
+
return { state: resolved.state, activatedThisTurn: false, aborted: false }
|
|
44
|
+
|
|
45
|
+
const match = findTriggerMatch(output.parts as TextPartLike[], ctx.options.triggerKeywords)
|
|
46
|
+
if (!match) return { activatedThisTurn: false, aborted: false }
|
|
47
|
+
|
|
48
|
+
const part = output.parts[match.partArrayIndex] as TextPartLike & { text: string }
|
|
49
|
+
const originalText = part.text
|
|
50
|
+
part.text = stripTriggerKeyword(part.text, match.keyword, match.offset)
|
|
51
|
+
const state = createState(ctx.options)
|
|
52
|
+
if (!NONCE_PATTERN.test(state.translate_nonce)) {
|
|
53
|
+
part.text = originalText
|
|
54
|
+
await logError(ctx.client, new Error("Generated invalid translation nonce"))
|
|
55
|
+
return { activatedThisTurn: false, aborted: true }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
cacheSessionState(input.sessionID, state)
|
|
59
|
+
return { state, activatedThisTurn: true, aborted: false }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function translateUserPart(
|
|
63
|
+
ctx: HookContext,
|
|
64
|
+
state: TranslateState,
|
|
65
|
+
part: TextPartLike & { text: string },
|
|
66
|
+
eligibleIndex: number,
|
|
67
|
+
nextParts: TextPartLike[],
|
|
68
|
+
errors: { part: TextPartLike; error: unknown }[],
|
|
69
|
+
) {
|
|
70
|
+
try {
|
|
71
|
+
const english = await ctx.translator.translateText({
|
|
72
|
+
text: part.text,
|
|
73
|
+
sourceLanguage: state.translate_user_lang,
|
|
74
|
+
targetLanguage: LLM_LANGUAGE,
|
|
75
|
+
direction: "inbound",
|
|
76
|
+
})
|
|
77
|
+
const sourceHash = hashText(part.text)
|
|
78
|
+
part.metadata = { ...(part.metadata ?? {}), ...mergeTranslatedMetadata(state, part, english) }
|
|
79
|
+
part.text = `${part.text}\n\n_→ EN: ${english}_`
|
|
80
|
+
nextParts.push(
|
|
81
|
+
createLlmOnlyTextPart(part.sessionID, part.messageID, english, {
|
|
82
|
+
translate_role: "llm_only_translation",
|
|
83
|
+
translate_nonce: state.translate_nonce,
|
|
84
|
+
translate_source_hash: sourceHash,
|
|
85
|
+
translate_part_index: eligibleIndex,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
errors.push({ part, error })
|
|
90
|
+
const reason = normalizeReason(error)
|
|
91
|
+
await logError(ctx.client, buildInboundTranslationError(state.translate_user_lang, reason))
|
|
92
|
+
const originalText = part.text
|
|
93
|
+
part.text = `${originalText}\n\n_⚠️ Translation failed: ${reason}. Original text was sent to the model._`
|
|
94
|
+
part.ignored = true
|
|
95
|
+
nextParts.push(
|
|
96
|
+
createLlmOnlyTextPart(part.sessionID, part.messageID, originalText, {
|
|
97
|
+
translate_role: "llm_only_fallback",
|
|
98
|
+
translate_nonce: state.translate_nonce,
|
|
99
|
+
translate_part_index: eligibleIndex,
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function processParts(
|
|
106
|
+
ctx: HookContext,
|
|
107
|
+
output: ChatMessageOutput,
|
|
108
|
+
state: TranslateState,
|
|
109
|
+
): Promise<PartProcessingResult> {
|
|
110
|
+
const result: PartProcessingResult = { nextParts: [], eligibleIndex: 0, errors: [] }
|
|
111
|
+
for (const part of output.parts as TextPartLike[]) {
|
|
112
|
+
result.nextParts.push(part)
|
|
113
|
+
if (!isUserAuthoredTextPart(part)) continue
|
|
114
|
+
result.firstUserTextPart ??= part
|
|
115
|
+
const currentEligibleIndex = result.eligibleIndex
|
|
116
|
+
result.eligibleIndex += 1
|
|
117
|
+
if (part.text.trim().length === 0) continue
|
|
118
|
+
await translateUserPart(ctx, state, part, currentEligibleIndex, result.nextParts, result.errors)
|
|
119
|
+
}
|
|
120
|
+
return result
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function appendActivationBanner(
|
|
124
|
+
ctx: HookContext,
|
|
125
|
+
input: ChatMessageInput,
|
|
126
|
+
output: ChatMessageOutput,
|
|
127
|
+
state: TranslateState,
|
|
128
|
+
processed: PartProcessingResult,
|
|
129
|
+
) {
|
|
130
|
+
const bannerText = createActivationBannerText(ctx.options)
|
|
131
|
+
if (processed.firstUserTextPart !== undefined) {
|
|
132
|
+
processed.firstUserTextPart.text = `${processed.firstUserTextPart.text}\n\n_${bannerText}_`
|
|
133
|
+
}
|
|
134
|
+
processed.nextParts.push(createActivationBannerPart(input.sessionID, output.message.id, state, bannerText))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function handleChatMessage(ctx: HookContext, input: ChatMessageInput, output: ChatMessageOutput) {
|
|
138
|
+
const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
|
|
139
|
+
const activation = await activateFromTrigger(ctx, input, output, resolved)
|
|
140
|
+
if (activation.aborted || !activation.state) return
|
|
141
|
+
|
|
142
|
+
const processed = await processParts(ctx, output, activation.state)
|
|
143
|
+
if (
|
|
144
|
+
activation.activatedThisTurn &&
|
|
145
|
+
processed.errors.length > 0 &&
|
|
146
|
+
processed.eligibleIndex === processed.errors.length
|
|
147
|
+
) {
|
|
148
|
+
cacheSessionState(input.sessionID, INACTIVE_ROOT_SESSION)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (activation.activatedThisTurn) appendActivationBanner(ctx, input, output, activation.state, processed)
|
|
153
|
+
output.parts.splice(0, output.parts.length, ...(processed.nextParts as typeof output.parts))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createChatMessageHook(ctx: HookContext): ChatMessageHook {
|
|
157
|
+
return async (input, output) => {
|
|
158
|
+
try {
|
|
159
|
+
await handleChatMessage(ctx, input, output)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
await logError(ctx.client, error)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Hooks, PluginInput, PluginOptions } from "@opencode-ai/plugin"
|
|
2
|
+
import { type PluginClientLike, resolveOptions } from "../constants"
|
|
3
|
+
import { createTranslator } from "../translator"
|
|
4
|
+
import { createChatMessageHook } from "./chat-message"
|
|
5
|
+
import { createMessagesTransformHook } from "./messages-transform"
|
|
6
|
+
import { createToolExecuteAfterHook, createToolExecuteBeforeHook, resetQuestionSnapshots } from "./question-hooks"
|
|
7
|
+
import { resetSessionStateCache } from "./state"
|
|
8
|
+
import { createTextCompleteHook } from "./text-complete"
|
|
9
|
+
import type { HookContext, HookDependencies } from "./types"
|
|
10
|
+
|
|
11
|
+
export { extractStoredState } from "./state"
|
|
12
|
+
export { findTriggerMatch, stripTriggerKeyword } from "./trigger"
|
|
13
|
+
|
|
14
|
+
export function __resetActivationCacheForTest() {
|
|
15
|
+
resetSessionStateCache()
|
|
16
|
+
resetQuestionSnapshots()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, deps: HookDependencies = {}): Hooks {
|
|
20
|
+
if (process.env.OPENCODE_TRANSLATE_DISABLE === "1") return {}
|
|
21
|
+
|
|
22
|
+
const client = ctx.client as unknown as PluginClientLike
|
|
23
|
+
const options = resolveOptions(rawOptions)
|
|
24
|
+
const hookContext: HookContext = {
|
|
25
|
+
client,
|
|
26
|
+
directory: ctx.directory,
|
|
27
|
+
options,
|
|
28
|
+
translator: deps.translator ?? createTranslator(client, options),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"chat.message": createChatMessageHook(hookContext),
|
|
33
|
+
"experimental.chat.messages.transform": createMessagesTransformHook(hookContext),
|
|
34
|
+
"experimental.text.complete": createTextCompleteHook(hookContext),
|
|
35
|
+
"tool.execute.before": createToolExecuteBeforeHook(hookContext),
|
|
36
|
+
"tool.execute.after": createToolExecuteAfterHook(hookContext),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { normalizeReason, PLUGIN_NAME, type PluginClientLike } from "../constants"
|
|
2
|
+
|
|
3
|
+
export function logError(client: PluginClientLike, error: unknown) {
|
|
4
|
+
return client.app.log({
|
|
5
|
+
body: {
|
|
6
|
+
service: PLUGIN_NAME,
|
|
7
|
+
level: "error",
|
|
8
|
+
message: normalizeReason(error),
|
|
9
|
+
},
|
|
10
|
+
})
|
|
11
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import { isTextPart, type MessageWithPartsLike } from "../constants"
|
|
3
|
+
import { extractEnglishHistoryText } from "../formatting"
|
|
4
|
+
import { logError } from "./logging"
|
|
5
|
+
import { isTranslatedUserDisplayPart } from "./metadata"
|
|
6
|
+
import { resolveSessionState } from "./state"
|
|
7
|
+
import type { HookContext } from "./types"
|
|
8
|
+
|
|
9
|
+
type MessagesTransformHook = NonNullable<Hooks["experimental.chat.messages.transform"]>
|
|
10
|
+
|
|
11
|
+
export function createMessagesTransformHook(ctx: HookContext): MessagesTransformHook {
|
|
12
|
+
return async (_input, output) => {
|
|
13
|
+
try {
|
|
14
|
+
const sessionID = output.messages[0]?.info.sessionID
|
|
15
|
+
if (!sessionID) return
|
|
16
|
+
|
|
17
|
+
const resolved = await resolveSessionState(ctx.client, ctx.directory, sessionID)
|
|
18
|
+
const activeState = resolved.state
|
|
19
|
+
if (!activeState) return
|
|
20
|
+
|
|
21
|
+
for (const message of output.messages as MessageWithPartsLike[]) {
|
|
22
|
+
if (message.info.role === "user") {
|
|
23
|
+
for (const part of message.parts) {
|
|
24
|
+
if (isTranslatedUserDisplayPart(part)) part.ignored = true
|
|
25
|
+
}
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (message.info.role !== "assistant") continue
|
|
30
|
+
for (const part of message.parts) {
|
|
31
|
+
if (isTextPart(part)) part.text = extractEnglishHistoryText(part.text, activeState.translate_nonce)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
await logError(ctx.client, error)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isTextPart,
|
|
3
|
+
isTranslateStateRecord,
|
|
4
|
+
LLM_LANGUAGE,
|
|
5
|
+
type StoredTextMetadata,
|
|
6
|
+
type TextPartLike,
|
|
7
|
+
type TranslateState,
|
|
8
|
+
} from "../constants"
|
|
9
|
+
import { hashText } from "../translator"
|
|
10
|
+
|
|
11
|
+
export function asMetadata(part: TextPartLike): StoredTextMetadata {
|
|
12
|
+
return (part.metadata ?? {}) as StoredTextMetadata
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function extractStateFromMetadata(metadata: StoredTextMetadata | undefined): TranslateState | undefined {
|
|
16
|
+
if (!isTranslateStateRecord(metadata)) return undefined
|
|
17
|
+
return {
|
|
18
|
+
translate_enabled: true,
|
|
19
|
+
translate_user_lang: metadata.translate_user_lang,
|
|
20
|
+
translate_llm_lang: LLM_LANGUAGE,
|
|
21
|
+
translate_nonce: metadata.translate_nonce,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function mergeTranslatedMetadata(
|
|
26
|
+
state: TranslateState,
|
|
27
|
+
part: TextPartLike,
|
|
28
|
+
english: string,
|
|
29
|
+
): Record<string, unknown> {
|
|
30
|
+
return {
|
|
31
|
+
...(part.metadata ?? {}),
|
|
32
|
+
...state,
|
|
33
|
+
translate_source_hash: hashText(part.text ?? ""),
|
|
34
|
+
translate_en: english,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isTranslatedUserDisplayPart(part: TextPartLike): boolean {
|
|
39
|
+
if (!isTextPart(part) || part.synthetic === true) return false
|
|
40
|
+
return extractStateFromMetadata(asMetadata(part)) !== undefined
|
|
41
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseTranslatorModel,
|
|
3
|
+
type ResolvedTranslateOptions,
|
|
4
|
+
SPEC_VERSION,
|
|
5
|
+
type TextPartLike,
|
|
6
|
+
type TranslateState,
|
|
7
|
+
} from "../constants"
|
|
8
|
+
import { createSyntheticPartID } from "../translator"
|
|
9
|
+
|
|
10
|
+
export function createActivationBannerText(options: ResolvedTranslateOptions): string {
|
|
11
|
+
const { modelID } = parseTranslatorModel(options.translatorModel)
|
|
12
|
+
return `✓ Translation mode enabled · translator: ${modelID} · language: ${options.lang}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createLlmOnlyTextPart(
|
|
16
|
+
sessionID: string,
|
|
17
|
+
messageID: string,
|
|
18
|
+
text: string,
|
|
19
|
+
metadata: Record<string, unknown>,
|
|
20
|
+
): TextPartLike {
|
|
21
|
+
return {
|
|
22
|
+
id: createSyntheticPartID(),
|
|
23
|
+
sessionID,
|
|
24
|
+
messageID,
|
|
25
|
+
type: "text",
|
|
26
|
+
text,
|
|
27
|
+
synthetic: true,
|
|
28
|
+
ignored: false,
|
|
29
|
+
metadata,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createActivationBannerPart(
|
|
34
|
+
sessionID: string,
|
|
35
|
+
messageID: string,
|
|
36
|
+
state: TranslateState,
|
|
37
|
+
text: string,
|
|
38
|
+
): TextPartLike {
|
|
39
|
+
return {
|
|
40
|
+
id: createSyntheticPartID(),
|
|
41
|
+
sessionID,
|
|
42
|
+
messageID,
|
|
43
|
+
type: "text",
|
|
44
|
+
text,
|
|
45
|
+
synthetic: true,
|
|
46
|
+
ignored: true,
|
|
47
|
+
metadata: {
|
|
48
|
+
...state,
|
|
49
|
+
translate_role: "activation_banner",
|
|
50
|
+
translate_spec_version: SPEC_VERSION,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import { buildInboundTranslationError, LLM_LANGUAGE, normalizeReason } from "../constants"
|
|
3
|
+
import {
|
|
4
|
+
isQuestionArgs,
|
|
5
|
+
type QuestionSnapshot,
|
|
6
|
+
type QuestionToolOutput,
|
|
7
|
+
restoreQuestionOutput,
|
|
8
|
+
snapshotQuestions,
|
|
9
|
+
translateQuestionArgs,
|
|
10
|
+
} from "../question-tool"
|
|
11
|
+
import { logError } from "./logging"
|
|
12
|
+
import { resolveSessionState } from "./state"
|
|
13
|
+
import { type HookContext, QUESTION_TOOL_ID } from "./types"
|
|
14
|
+
|
|
15
|
+
const questionSnapshots = new Map<string, QuestionSnapshot>()
|
|
16
|
+
|
|
17
|
+
export function resetQuestionSnapshots() {
|
|
18
|
+
questionSnapshots.clear()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks["tool.execute.before"]> {
|
|
22
|
+
return async (input, output) => {
|
|
23
|
+
try {
|
|
24
|
+
if (input.tool !== QUESTION_TOOL_ID) return
|
|
25
|
+
const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
|
|
26
|
+
const activeState = resolved.state
|
|
27
|
+
if (!activeState) return
|
|
28
|
+
|
|
29
|
+
const args = output.args as unknown
|
|
30
|
+
if (!isQuestionArgs(args)) return
|
|
31
|
+
|
|
32
|
+
const original = snapshotQuestions(args)
|
|
33
|
+
if (activeState.translate_user_lang !== LLM_LANGUAGE) {
|
|
34
|
+
try {
|
|
35
|
+
await translateQuestionArgs(args, (text) =>
|
|
36
|
+
ctx.translator.translateText({
|
|
37
|
+
text,
|
|
38
|
+
sourceLanguage: LLM_LANGUAGE,
|
|
39
|
+
targetLanguage: activeState.translate_user_lang,
|
|
40
|
+
direction: "outbound",
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
args.questions.splice(0, args.questions.length, ...snapshotQuestions({ questions: original }))
|
|
45
|
+
await logError(ctx.client, error)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
questionSnapshots.set(input.callID, { original, translated: snapshotQuestions(args) })
|
|
51
|
+
} catch (error) {
|
|
52
|
+
await logError(ctx.client, error)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createToolExecuteAfterHook(ctx: HookContext): NonNullable<Hooks["tool.execute.after"]> {
|
|
58
|
+
return async (input, output) => {
|
|
59
|
+
try {
|
|
60
|
+
if (input.tool !== QUESTION_TOOL_ID) return
|
|
61
|
+
const snapshot = questionSnapshots.get(input.callID)
|
|
62
|
+
if (!snapshot) return
|
|
63
|
+
questionSnapshots.delete(input.callID)
|
|
64
|
+
|
|
65
|
+
const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
|
|
66
|
+
const activeState = resolved.state
|
|
67
|
+
const translateCustomAnswer =
|
|
68
|
+
activeState && activeState.translate_user_lang !== LLM_LANGUAGE
|
|
69
|
+
? (text: string) =>
|
|
70
|
+
ctx.translator.translateText({
|
|
71
|
+
text,
|
|
72
|
+
sourceLanguage: activeState.translate_user_lang,
|
|
73
|
+
targetLanguage: LLM_LANGUAGE,
|
|
74
|
+
direction: "inbound",
|
|
75
|
+
})
|
|
76
|
+
: undefined
|
|
77
|
+
|
|
78
|
+
await restoreQuestionOutput(output as QuestionToolOutput, snapshot, {
|
|
79
|
+
...(translateCustomAnswer ? { translateCustomAnswer } : {}),
|
|
80
|
+
onTranslationError: async (error) => {
|
|
81
|
+
if (!activeState) {
|
|
82
|
+
await logError(ctx.client, error)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
await logError(
|
|
86
|
+
ctx.client,
|
|
87
|
+
buildInboundTranslationError(activeState.translate_user_lang, normalizeReason(error)),
|
|
88
|
+
)
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
} catch (error) {
|
|
92
|
+
await logError(ctx.client, error)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto"
|
|
2
|
+
import {
|
|
3
|
+
isTextPart,
|
|
4
|
+
LLM_LANGUAGE,
|
|
5
|
+
type MessageWithPartsLike,
|
|
6
|
+
type PluginClientLike,
|
|
7
|
+
type ResolvedTranslateOptions,
|
|
8
|
+
type TranslateState,
|
|
9
|
+
unwrapData,
|
|
10
|
+
} from "../constants"
|
|
11
|
+
import { asMetadata, extractStateFromMetadata } from "./metadata"
|
|
12
|
+
import {
|
|
13
|
+
type CachedSessionState,
|
|
14
|
+
INACTIVE_CHILD_SESSION,
|
|
15
|
+
INACTIVE_ROOT_SESSION,
|
|
16
|
+
type ResolvedSessionState,
|
|
17
|
+
} from "./types"
|
|
18
|
+
|
|
19
|
+
const sessionStateCache = new Map<string, CachedSessionState>()
|
|
20
|
+
|
|
21
|
+
export function resetSessionStateCache() {
|
|
22
|
+
sessionStateCache.clear()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function cacheSessionState(sessionID: string, state: CachedSessionState) {
|
|
26
|
+
sessionStateCache.set(sessionID, state)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createState(options: ResolvedTranslateOptions): TranslateState {
|
|
30
|
+
return {
|
|
31
|
+
translate_enabled: true,
|
|
32
|
+
translate_user_lang: options.lang,
|
|
33
|
+
translate_llm_lang: LLM_LANGUAGE,
|
|
34
|
+
translate_nonce: randomBytes(16).toString("hex"),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function extractStoredState(messages: MessageWithPartsLike[]): TranslateState | undefined {
|
|
39
|
+
let fallback: TranslateState | undefined
|
|
40
|
+
|
|
41
|
+
for (const message of messages) {
|
|
42
|
+
for (const part of message.parts) {
|
|
43
|
+
if (!isTextPart(part)) continue
|
|
44
|
+
const metadata = asMetadata(part)
|
|
45
|
+
const state = extractStateFromMetadata(metadata)
|
|
46
|
+
if (!state) continue
|
|
47
|
+
if (metadata.translate_role === "activation_banner") return state
|
|
48
|
+
if (message.info.role === "user" && part.synthetic !== true && fallback === undefined) fallback = state
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return fallback
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cachedStateResult(cached: CachedSessionState): ResolvedSessionState {
|
|
56
|
+
if (cached === INACTIVE_ROOT_SESSION) return { sessionActive: false, canActivate: true, storedMessages: [] }
|
|
57
|
+
if (cached === INACTIVE_CHILD_SESSION) return { sessionActive: false, canActivate: false, storedMessages: [] }
|
|
58
|
+
return { sessionActive: true, canActivate: false, state: cached, storedMessages: [] }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function resolveSessionState(
|
|
62
|
+
client: PluginClientLike,
|
|
63
|
+
directory: string | undefined,
|
|
64
|
+
sessionID: string,
|
|
65
|
+
): Promise<ResolvedSessionState> {
|
|
66
|
+
const cached = sessionStateCache.get(sessionID)
|
|
67
|
+
if (cached !== undefined) return cachedStateResult(cached)
|
|
68
|
+
|
|
69
|
+
const session = unwrapData(
|
|
70
|
+
await client.session.get({
|
|
71
|
+
path: { id: sessionID },
|
|
72
|
+
query: { ...(directory ? { directory } : {}) },
|
|
73
|
+
throwOnError: true,
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
if (session.parentID != null) {
|
|
77
|
+
sessionStateCache.set(sessionID, INACTIVE_CHILD_SESSION)
|
|
78
|
+
return { sessionActive: false, canActivate: false, storedMessages: [] }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const storedMessages = unwrapData(
|
|
82
|
+
await client.session.messages({
|
|
83
|
+
path: { id: sessionID },
|
|
84
|
+
query: { ...(directory ? { directory } : {}) },
|
|
85
|
+
throwOnError: true,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
const state = extractStoredState(storedMessages)
|
|
89
|
+
sessionStateCache.set(sessionID, state ?? INACTIVE_ROOT_SESSION)
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
sessionActive: Boolean(state),
|
|
93
|
+
canActivate: !state,
|
|
94
|
+
state: state ?? undefined,
|
|
95
|
+
storedMessages,
|
|
96
|
+
}
|
|
97
|
+
}
|