opencode-translate 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,7 @@ Hooks never throw. If the translator fails (network error, auth failure, provide
44
44
  3. Falls back to sending the original (untranslated) user text to the model.
45
45
  4. On activation-turn failure, it also rolls back activation so the next turn retries cleanly.
46
46
 
47
- A stalled provider request is additionally bounded by a 60s hard timeout per translation call, so a hung upstream cannot block the OpenCode session.
47
+ A stalled provider request is additionally bounded by a 180s hard timeout per translation call, so a hung upstream cannot block the OpenCode session.
48
48
 
49
49
  ## Install
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
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",
@@ -76,7 +76,7 @@ async function translateUserPart(
76
76
  })
77
77
  const sourceHash = hashText(part.text)
78
78
  part.metadata = { ...(part.metadata ?? {}), ...mergeTranslatedMetadata(state, part, english) }
79
- part.text = `${part.text}\n\n_→ EN: ${english}_`
79
+ part.text = `${part.text}\n\n→ EN: ${english}`
80
80
  nextParts.push(
81
81
  createLlmOnlyTextPart(part.sessionID, part.messageID, english, {
82
82
  translate_role: "llm_only_translation",
@@ -90,7 +90,7 @@ async function translateUserPart(
90
90
  const reason = normalizeReason(error)
91
91
  await logError(ctx.client, buildInboundTranslationError(state.translate_user_lang, reason))
92
92
  const originalText = part.text
93
- part.text = `${originalText}\n\n_⚠️ Translation failed: ${reason}. Original text was sent to the model._`
93
+ part.text = `${originalText}\n\n⚠️ Translation failed: ${reason}. Original text was sent to the model.`
94
94
  part.ignored = true
95
95
  nextParts.push(
96
96
  createLlmOnlyTextPart(part.sessionID, part.messageID, originalText, {
@@ -129,7 +129,7 @@ function appendActivationBanner(
129
129
  ) {
130
130
  const bannerText = createActivationBannerText(ctx.options)
131
131
  if (processed.firstUserTextPart !== undefined) {
132
- processed.firstUserTextPart.text = `${processed.firstUserTextPart.text}\n\n_${bannerText}_`
132
+ processed.firstUserTextPart.text = `${processed.firstUserTextPart.text}\n\n${bannerText}`
133
133
  }
134
134
  processed.nextParts.push(createActivationBannerPart(input.sessionID, output.message.id, state, bannerText))
135
135
  }
@@ -19,7 +19,7 @@ export function __resetActivationCacheForTest() {
19
19
  export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, deps: HookDependencies = {}): Hooks {
20
20
  if (process.env.OPENCODE_TRANSLATE_DISABLE === "1") return {}
21
21
 
22
- const client = ctx.client as unknown as PluginClientLike
22
+ const client = ctx.client as PluginClientLike
23
23
  const options = resolveOptions(rawOptions)
24
24
  const hookContext: HookContext = {
25
25
  client,
@@ -12,8 +12,19 @@ import { logError } from "./logging"
12
12
  import { resolveSessionState } from "./state"
13
13
  import { type HookContext, QUESTION_TOOL_ID } from "./types"
14
14
 
15
+ const QUESTION_SNAPSHOT_LIMIT = 1_000
16
+
15
17
  const questionSnapshots = new Map<string, QuestionSnapshot>()
16
18
 
19
+ function pruneQuestionSnapshots() {
20
+ while (questionSnapshots.size > QUESTION_SNAPSHOT_LIMIT) {
21
+ for (const callID of questionSnapshots.keys()) {
22
+ questionSnapshots.delete(callID)
23
+ break
24
+ }
25
+ }
26
+ }
27
+
17
28
  export function resetQuestionSnapshots() {
18
29
  questionSnapshots.clear()
19
30
  }
@@ -26,8 +37,8 @@ export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks
26
37
  const activeState = resolved.state
27
38
  if (!activeState) return
28
39
 
29
- const args = output.args as unknown
30
- if (!isQuestionArgs(args)) return
40
+ if (!isQuestionArgs(output.args)) return
41
+ const args = output.args
31
42
 
32
43
  const original = snapshotQuestions(args)
33
44
  if (activeState.translate_user_lang !== LLM_LANGUAGE) {
@@ -48,6 +59,7 @@ export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks
48
59
  }
49
60
 
50
61
  questionSnapshots.set(input.callID, { original, translated: snapshotQuestions(args) })
62
+ pruneQuestionSnapshots()
51
63
  } catch (error) {
52
64
  await logError(ctx.client, error)
53
65
  }
@@ -64,24 +76,20 @@ export function createToolExecuteAfterHook(ctx: HookContext): NonNullable<Hooks[
64
76
 
65
77
  const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
66
78
  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
79
+ if (!activeState || activeState.translate_user_lang === LLM_LANGUAGE) {
80
+ await restoreQuestionOutput(output as QuestionToolOutput, snapshot)
81
+ return
82
+ }
77
83
 
78
84
  await restoreQuestionOutput(output as QuestionToolOutput, snapshot, {
79
- ...(translateCustomAnswer ? { translateCustomAnswer } : {}),
85
+ translateCustomAnswer: (text: string) =>
86
+ ctx.translator.translateText({
87
+ text,
88
+ sourceLanguage: activeState.translate_user_lang,
89
+ targetLanguage: LLM_LANGUAGE,
90
+ direction: "inbound",
91
+ }),
80
92
  onTranslationError: async (error) => {
81
- if (!activeState) {
82
- await logError(ctx.client, error)
83
- return
84
- }
85
93
  await logError(
86
94
  ctx.client,
87
95
  buildInboundTranslationError(activeState.translate_user_lang, normalizeReason(error)),
package/src/auth/index.ts CHANGED
@@ -18,12 +18,8 @@ import { refreshAnthropic, refreshOpenAI } from "./refresh"
18
18
  import { ensureOAuthInfo, normalizeProviderKey, readAuthMap } from "./store"
19
19
  import type { AuthDependencies, AuthRuntime, ResolvedCredential } from "./types"
20
20
 
21
- const credentialCache = new Map<string, ResolvedCredential>()
22
- const oauthRefreshInflight = new Map<string, Promise<OAuthInfo>>()
23
-
24
21
  export function __resetAuthCachesForTest() {
25
- credentialCache.clear()
26
- oauthRefreshInflight.clear()
22
+ // Resolver instances own their caches; this remains as a stable test helper.
27
23
  }
28
24
 
29
25
  function isMissingCredentialError(error: unknown): boolean {
@@ -72,6 +68,8 @@ export function createCredentialResolver(
72
68
  options: ResolvedTranslateOptions,
73
69
  deps: AuthDependencies = {},
74
70
  ) {
71
+ const credentialCache = new Map<string, ResolvedCredential>()
72
+ const oauthRefreshInflight = new Map<string, Promise<OAuthInfo>>()
75
73
  const runtime: AuthRuntime = {
76
74
  fetchImpl: deps.fetchImpl ?? fetch,
77
75
  sleep: deps.sleep ?? ((ms: number) => sleep(ms)),
@@ -84,13 +82,14 @@ export function createCredentialResolver(
84
82
  if (!info) return undefined
85
83
  if (info.expires >= now() + 60_000) return info
86
84
 
87
- const existing = oauthRefreshInflight.get(providerID)
85
+ const inflightKey = `${providerID}:${info.refresh}`
86
+ const existing = oauthRefreshInflight.get(inflightKey)
88
87
  if (existing) return existing
89
88
 
90
89
  const refreshPromise = refreshProviderOAuth(providerID, info, client, runtime).finally(() => {
91
- oauthRefreshInflight.delete(providerID)
90
+ oauthRefreshInflight.delete(inflightKey)
92
91
  })
93
- oauthRefreshInflight.set(providerID, refreshPromise)
92
+ oauthRefreshInflight.set(inflightKey, refreshPromise)
94
93
  return refreshPromise
95
94
  }
96
95
 
@@ -76,35 +76,47 @@ export function isQuestionArgs(value: unknown): value is QuestionArgs {
76
76
  return true
77
77
  }
78
78
 
79
- async function assignTranslation(
80
- container: Record<string, string>,
81
- key: string,
82
- translate: (text: string) => Promise<string>,
83
- ): Promise<void> {
84
- const original = container[key]
85
- if (!original || original.length === 0) return
86
- const translated = await translate(original)
87
- container[key] = unwrapEchoedTextEnvelope(translated)
79
+ async function translatedDisplayText(text: string, translate: (text: string) => Promise<string>): Promise<string> {
80
+ if (text.length === 0) return text
81
+ return unwrapEchoedTextEnvelope(await translate(text))
88
82
  }
89
83
 
90
- // Translate every display-facing string in `args` in parallel. The caller can
91
- // snapshot the translated form afterward with `snapshotQuestions`.
84
+ // Translate every display-facing string in parallel, then commit the translated
85
+ // clone only after every translation succeeds.
92
86
  export async function translateQuestionArgs(
93
87
  args: QuestionArgs,
94
88
  translate: (text: string) => Promise<string>,
95
89
  ): Promise<void> {
90
+ const translatedQuestions = snapshotQuestions(args)
96
91
  const jobs: Promise<void>[] = []
97
92
 
98
- for (const q of args.questions) {
99
- jobs.push(assignTranslation(q as unknown as Record<string, string>, "question", translate))
100
- jobs.push(assignTranslation(q as unknown as Record<string, string>, "header", translate))
93
+ for (const q of translatedQuestions) {
94
+ jobs.push(
95
+ (async () => {
96
+ q.question = await translatedDisplayText(q.question, translate)
97
+ })(),
98
+ )
99
+ jobs.push(
100
+ (async () => {
101
+ q.header = await translatedDisplayText(q.header, translate)
102
+ })(),
103
+ )
101
104
  for (const option of q.options) {
102
- jobs.push(assignTranslation(option as unknown as Record<string, string>, "label", translate))
103
- jobs.push(assignTranslation(option as unknown as Record<string, string>, "description", translate))
105
+ jobs.push(
106
+ (async () => {
107
+ option.label = await translatedDisplayText(option.label, translate)
108
+ })(),
109
+ )
110
+ jobs.push(
111
+ (async () => {
112
+ option.description = await translatedDisplayText(option.description, translate)
113
+ })(),
114
+ )
104
115
  }
105
116
  }
106
117
 
107
118
  await Promise.all(jobs)
119
+ args.questions.splice(0, args.questions.length, ...translatedQuestions)
108
120
  }
109
121
 
110
122
  // Given a user-selected label, find the matching translated option and return
@@ -1,5 +1,5 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises"
2
- import { generateText } from "ai"
2
+ import { generateText, type LanguageModel } from "ai"
3
3
  import { createCredentialResolver } from "../auth"
4
4
  import {
5
5
  buildAuthUnavailableError,
@@ -73,20 +73,20 @@ export function createTranslator(
73
73
  const credentials = await credentialResolver.resolve(options.translatorModel)
74
74
  const factory = await loadFactory(providerID)
75
75
  const provider = instantiateProvider(factory, providerID, credentials)
76
- const model = instantiateModel(provider, modelID)
76
+ const model = instantiateModel(provider, modelID) as LanguageModel
77
77
 
78
78
  const rawTranslated = await withRetry(async () => {
79
79
  try {
80
- const result = (await withTimeout(
80
+ const result = await withTimeout(
81
81
  generateTextImpl({
82
- model: model as never,
82
+ model,
83
83
  system: buildSystemPrompt(input),
84
84
  ...(supportsTemperature(providerID, modelID) ? { temperature: 0 } : {}),
85
85
  prompt: buildUserPrompt(input),
86
- }) as Promise<{ text: string }>,
86
+ }),
87
87
  timeoutMs,
88
88
  "Translator generateText",
89
- )) as { text: string }
89
+ )
90
90
  return result.text
91
91
  } catch (error) {
92
92
  if (isAuthMessage(error)) throw error