opencode-translate 0.1.1 → 0.1.3

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 (40) hide show
  1. package/README.md +1 -1
  2. package/package.json +6 -3
  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 +42 -0
  8. package/src/activation/parts.ts +53 -0
  9. package/src/activation/question-hooks.ts +95 -0
  10. package/src/activation/state.ts +98 -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 -607
  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 +34 -0
  29. package/src/constants/options.ts +37 -0
  30. package/src/constants/plugin.ts +10 -0
  31. package/src/constants/types.ts +147 -0
  32. package/src/constants.ts +5 -261
  33. package/src/labels.ts +0 -2
  34. package/src/question-tool.ts +45 -22
  35. package/src/translator/index.ts +125 -0
  36. package/src/translator/part-id.ts +43 -0
  37. package/src/translator/provider.ts +81 -0
  38. package/src/translator/retry.ts +62 -0
  39. package/src/translator/types.ts +17 -0
  40. package/src/translator.ts +1 -326
package/README.md CHANGED
@@ -9,7 +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
+ - 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -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_source_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_source_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,42 @@
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_source_lang: metadata.translate_source_lang,
20
+ translate_display_lang: metadata.translate_display_lang,
21
+ translate_llm_lang: LLM_LANGUAGE,
22
+ translate_nonce: metadata.translate_nonce,
23
+ }
24
+ }
25
+
26
+ export function mergeTranslatedMetadata(
27
+ state: TranslateState,
28
+ part: TextPartLike,
29
+ english: string,
30
+ ): Record<string, unknown> {
31
+ return {
32
+ ...(part.metadata ?? {}),
33
+ ...state,
34
+ translate_source_hash: hashText(part.text ?? ""),
35
+ translate_en: english,
36
+ }
37
+ }
38
+
39
+ export function isTranslatedUserDisplayPart(part: TextPartLike): boolean {
40
+ if (!isTextPart(part) || part.synthetic === true) return false
41
+ return extractStateFromMetadata(asMetadata(part)) !== undefined
42
+ }
@@ -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} · source: ${options.sourceLanguage} · display: ${options.displayLanguage}`
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_display_lang !== LLM_LANGUAGE) {
34
+ try {
35
+ await translateQuestionArgs(args, (text) =>
36
+ ctx.translator.translateText({
37
+ text,
38
+ sourceLanguage: LLM_LANGUAGE,
39
+ targetLanguage: activeState.translate_display_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_source_lang !== LLM_LANGUAGE
69
+ ? (text: string) =>
70
+ ctx.translator.translateText({
71
+ text,
72
+ sourceLanguage: activeState.translate_source_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_source_lang, normalizeReason(error)),
88
+ )
89
+ },
90
+ })
91
+ } catch (error) {
92
+ await logError(ctx.client, error)
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,98 @@
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_source_lang: options.sourceLanguage,
33
+ translate_display_lang: options.displayLanguage,
34
+ translate_llm_lang: LLM_LANGUAGE,
35
+ translate_nonce: randomBytes(16).toString("hex"),
36
+ }
37
+ }
38
+
39
+ export function extractStoredState(messages: MessageWithPartsLike[]): TranslateState | undefined {
40
+ let fallback: TranslateState | undefined
41
+
42
+ for (const message of messages) {
43
+ for (const part of message.parts) {
44
+ if (!isTextPart(part)) continue
45
+ const metadata = asMetadata(part)
46
+ const state = extractStateFromMetadata(metadata)
47
+ if (!state) continue
48
+ if (metadata.translate_role === "activation_banner") return state
49
+ if (message.info.role === "user" && part.synthetic !== true && fallback === undefined) fallback = state
50
+ }
51
+ }
52
+
53
+ return fallback
54
+ }
55
+
56
+ function cachedStateResult(cached: CachedSessionState): ResolvedSessionState {
57
+ if (cached === INACTIVE_ROOT_SESSION) return { sessionActive: false, canActivate: true, storedMessages: [] }
58
+ if (cached === INACTIVE_CHILD_SESSION) return { sessionActive: false, canActivate: false, storedMessages: [] }
59
+ return { sessionActive: true, canActivate: false, state: cached, storedMessages: [] }
60
+ }
61
+
62
+ export async function resolveSessionState(
63
+ client: PluginClientLike,
64
+ directory: string | undefined,
65
+ sessionID: string,
66
+ ): Promise<ResolvedSessionState> {
67
+ const cached = sessionStateCache.get(sessionID)
68
+ if (cached !== undefined) return cachedStateResult(cached)
69
+
70
+ const session = unwrapData(
71
+ await client.session.get({
72
+ path: { id: sessionID },
73
+ query: { ...(directory ? { directory } : {}) },
74
+ throwOnError: true,
75
+ }),
76
+ )
77
+ if (session.parentID != null) {
78
+ sessionStateCache.set(sessionID, INACTIVE_CHILD_SESSION)
79
+ return { sessionActive: false, canActivate: false, storedMessages: [] }
80
+ }
81
+
82
+ const storedMessages = unwrapData(
83
+ await client.session.messages({
84
+ path: { id: sessionID },
85
+ query: { ...(directory ? { directory } : {}) },
86
+ throwOnError: true,
87
+ }),
88
+ )
89
+ const state = extractStoredState(storedMessages)
90
+ sessionStateCache.set(sessionID, state ?? INACTIVE_ROOT_SESSION)
91
+
92
+ return {
93
+ sessionActive: Boolean(state),
94
+ canActivate: !state,
95
+ state: state ?? undefined,
96
+ storedMessages,
97
+ }
98
+ }
@@ -0,0 +1,51 @@
1
+ import type { Hooks } from "@opencode-ai/plugin"
2
+ import { LLM_LANGUAGE, type MessageWithPartsLike, unwrapData } from "../constants"
3
+ import { composeTranslatedAssistantText, composeTranslationFailureText } from "../formatting"
4
+ import { getDisplayLanguageLabel } from "../labels"
5
+ import { logError } from "./logging"
6
+ import { resolveSessionState } from "./state"
7
+ import type { HookContext } from "./types"
8
+
9
+ type TextCompleteHook = NonNullable<Hooks["experimental.text.complete"]>
10
+
11
+ export function createTextCompleteHook(ctx: HookContext): TextCompleteHook {
12
+ return async (input, output) => {
13
+ try {
14
+ const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
15
+ const activeState = resolved.state
16
+ if (!activeState) return
17
+
18
+ const message = unwrapData(
19
+ await ctx.client.session.message({
20
+ path: { id: input.sessionID, messageID: input.messageID },
21
+ query: { ...(ctx.directory ? { directory: ctx.directory } : {}) },
22
+ throwOnError: true,
23
+ }),
24
+ ) as MessageWithPartsLike & { info: Record<string, unknown> }
25
+
26
+ if (message.info.role !== "assistant") return
27
+ if (message.info.summary === true) return
28
+ if (activeState.translate_display_lang === LLM_LANGUAGE || output.text.length === 0) return
29
+
30
+ try {
31
+ const translated = await ctx.translator.translateText({
32
+ text: output.text,
33
+ sourceLanguage: LLM_LANGUAGE,
34
+ targetLanguage: activeState.translate_display_lang,
35
+ direction: "outbound",
36
+ })
37
+ output.text = composeTranslatedAssistantText(
38
+ output.text,
39
+ getDisplayLanguageLabel(activeState.translate_display_lang),
40
+ translated,
41
+ activeState.translate_nonce,
42
+ )
43
+ } catch (error) {
44
+ output.text = composeTranslationFailureText(output.text, activeState.translate_nonce)
45
+ await logError(ctx.client, error)
46
+ }
47
+ } catch (error) {
48
+ await logError(ctx.client, error)
49
+ }
50
+ }
51
+ }