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.
Files changed (41) hide show
  1. package/README.md +11 -11
  2. package/package.json +7 -4
  3. package/src/activation/chat-message.ts +164 -0
  4. package/src/activation/index.ts +38 -0
  5. package/src/activation/logging.ts +11 -0
  6. package/src/activation/messages-transform.ts +38 -0
  7. package/src/activation/metadata.ts +41 -0
  8. package/src/activation/parts.ts +53 -0
  9. package/src/activation/question-hooks.ts +95 -0
  10. package/src/activation/state.ts +97 -0
  11. package/src/activation/text-complete.ts +51 -0
  12. package/src/activation/trigger.ts +57 -0
  13. package/src/activation/types.ts +41 -0
  14. package/src/activation.ts +1 -633
  15. package/src/anthropic-oauth.ts +3 -3
  16. package/src/auth/codex-request.ts +108 -0
  17. package/src/auth/codex-response.ts +78 -0
  18. package/src/auth/codex-shared.ts +3 -0
  19. package/src/auth/headers.ts +18 -0
  20. package/src/auth/index.ts +153 -0
  21. package/src/auth/oauth-fetch.ts +100 -0
  22. package/src/auth/refresh.ts +102 -0
  23. package/src/auth/retry.ts +70 -0
  24. package/src/auth/store.ts +45 -0
  25. package/src/auth/types.ts +27 -0
  26. package/src/auth.ts +1 -725
  27. package/src/constants/errors.ts +24 -0
  28. package/src/constants/guards.ts +33 -0
  29. package/src/constants/options.ts +41 -0
  30. package/src/constants/plugin.ts +10 -0
  31. package/src/constants/types.ts +144 -0
  32. package/src/constants.ts +5 -261
  33. package/src/labels.ts +2 -16
  34. package/src/prompts.ts +2 -17
  35. package/src/question-tool.ts +1 -1
  36. package/src/translator/index.ts +125 -0
  37. package/src/translator/part-id.ts +43 -0
  38. package/src/translator/provider.ts +81 -0
  39. package/src/translator/retry.ts +62 -0
  40. package/src/translator/types.ts +17 -0
  41. 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 `sourceLanguage` while the main chat loop and compaction summariser only see English.
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 `sourceLanguage` to English before the main LLM sees them.
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 `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, including translation of non-empty custom answers.
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
- **{displayLanguageLabel}:**
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 source or display language.
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
- "sourceLanguage": "ko",
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
- | `sourceLanguage` | string | `en` |
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 `sourceLanguage: "ko"`, `displayLanguage: "ko"`.
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.1.2",
4
- "description": "OpenCode plugin that lets the user chat in a configured source language while the main chat loop only sees English.",
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
+ }