opencode-translate 0.0.8 → 0.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
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",
package/src/activation.ts CHANGED
@@ -2,7 +2,6 @@ import { randomBytes } from "node:crypto"
2
2
  import type { Hooks, PluginInput, PluginOptions } from "@opencode-ai/plugin"
3
3
  import {
4
4
  buildInboundTranslationError,
5
- buildStaleCacheError,
6
5
  isTextPart,
7
6
  isTranslateStateRecord,
8
7
  isUserAuthoredTextPart,
@@ -159,6 +158,28 @@ function createSyntheticTextPart(
159
158
  }
160
159
  }
161
160
 
161
+ // LLM-only text part: hidden from the TUI but the only LLM-visible
162
+ // representation of the user's source-language text. The original
163
+ // user-authored part is marked `ignored:true` so the LLM never sees it,
164
+ // and this synthetic English twin carries the actual prompt content.
165
+ function createLlmOnlyTextPart(
166
+ sessionID: string,
167
+ messageID: string,
168
+ text: string,
169
+ metadata: Record<string, unknown>,
170
+ ): TextPartLike {
171
+ return {
172
+ id: createSyntheticPartID(),
173
+ sessionID,
174
+ messageID,
175
+ type: "text",
176
+ text,
177
+ synthetic: true,
178
+ ignored: false,
179
+ metadata,
180
+ }
181
+ }
182
+
162
183
  function escapeRegex(value: string): string {
163
184
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
164
185
  }
@@ -271,10 +292,6 @@ async function resolveSessionState(
271
292
  }
272
293
  }
273
294
 
274
- function shouldRequireCache(part: TextPartLike): boolean {
275
- return isUserAuthoredTextPart(part) && part.text.trim().length > 0
276
- }
277
-
278
295
  export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, deps: HookDependencies = {}): Hooks {
279
296
  if (process.env.OPENCODE_TRANSLATE_DISABLE === "1") {
280
297
  return {}
@@ -342,7 +359,13 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
342
359
  ...(part.metadata ?? {}),
343
360
  ...mergeTranslatedMetadata(activeState, part, english),
344
361
  }
362
+ // Hide the user's source-language text from the LLM. The TUI
363
+ // still renders it because `ignored:true` only affects the
364
+ // user-side LLM serializer (`message-v2.ts:773`).
365
+ part.ignored = true
345
366
 
367
+ // UI-only preview so the user can verify the translation that
368
+ // was sent to the LLM.
346
369
  nextParts.push(
347
370
  createSyntheticTextPart(part.sessionID, part.messageID, `→ EN: ${english}`, {
348
371
  translate_role: "translation_preview",
@@ -351,6 +374,17 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
351
374
  translate_part_index: currentEligibleIndex,
352
375
  }),
353
376
  )
377
+
378
+ // LLM-only English twin. This is the actual prompt the model
379
+ // sees in place of the now-`ignored` source-language part.
380
+ nextParts.push(
381
+ createLlmOnlyTextPart(part.sessionID, part.messageID, english, {
382
+ translate_role: "llm_only_translation",
383
+ translate_nonce: activeState.translate_nonce,
384
+ translate_source_hash: sourceHash,
385
+ translate_part_index: currentEligibleIndex,
386
+ }),
387
+ )
354
388
  } catch (error) {
355
389
  // Fall back to sending the original text to the LLM so the user
356
390
  // still gets a response. Surface the error as a synthetic part.
@@ -404,35 +438,16 @@ export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, de
404
438
  const activeState = resolved.state
405
439
  if (!activeState) return
406
440
 
441
+ // User parts need no in-place rewriting: the source-language text
442
+ // part is `ignored:true` so the LLM serializer skips it, and a
443
+ // synthetic English twin (created in `chat.message`) carries the
444
+ // actual prompt content. Only assistant parts still need their
445
+ // localized trailer stripped before re-entering the model.
407
446
  for (const message of output.messages as MessageWithPartsLike[]) {
408
- if (message.info.role === "user") {
409
- for (const part of message.parts) {
410
- if (!isTextPart(part)) continue
411
- if (!shouldRequireCache(part)) continue
412
- const metadata = asMetadata(part)
413
- const sourceHash = hashText(part.text)
414
- if (
415
- metadata.translate_enabled === true &&
416
- metadata.translate_nonce === activeState.translate_nonce &&
417
- metadata.translate_source_hash === sourceHash &&
418
- typeof metadata.translate_en === "string"
419
- ) {
420
- part.text = metadata.translate_en
421
- continue
422
- }
423
-
424
- // Stale cache or untranslated text. Send it through as-is
425
- // (English history would be ideal, but we shouldn't block the
426
- // session). Also log so the user can diagnose if needed.
427
- await logError(client, buildStaleCacheError())
428
- }
429
- }
430
-
431
- if (message.info.role === "assistant") {
432
- for (const part of message.parts) {
433
- if (!isTextPart(part)) continue
434
- part.text = extractEnglishHistoryText(part.text, activeState.translate_nonce)
435
- }
447
+ if (message.info.role !== "assistant") continue
448
+ for (const part of message.parts) {
449
+ if (!isTextPart(part)) continue
450
+ part.text = extractEnglishHistoryText(part.text, activeState.translate_nonce)
436
451
  }
437
452
  }
438
453
  } catch (error) {
package/src/constants.ts CHANGED
@@ -216,12 +216,6 @@ export function buildInboundTranslationError(sourceLanguage: string, reason: str
216
216
  )
217
217
  }
218
218
 
219
- export function buildStaleCacheError(): Error {
220
- return new Error(
221
- `[${PLUGIN_NAME}:STALE_CACHE] A previously translated user message was edited. Resend the message or start a new session.`,
222
- )
223
- }
224
-
225
219
  export function buildAuthUnavailableError(providerID: string, envVar: string): Error {
226
220
  return new Error(
227
221
  `[${PLUGIN_NAME}:AUTH_UNAVAILABLE] No credential found for provider "${providerID}". Set ${envVar} in the environment, run "opencode auth login ${providerID}", or set options.apiKey in opencode.json.`,
package/src/translator.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from "node:crypto"
1
+ import { createHash, randomBytes } from "node:crypto"
2
2
  import { setTimeout as sleep } from "node:timers/promises"
3
3
  import { generateText } from "ai"
4
4
  import { createCredentialResolver } from "./auth"
@@ -51,8 +51,29 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
51
51
 
52
52
  const providerFactoryCache = new Map<string, unknown>()
53
53
 
54
+ const PART_ID_LENGTH = 26
55
+ const PART_ID_PREFIX = "prt"
56
+ const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
57
+ let partLastTimestamp = 0
58
+ let partCounter = 0
59
+
54
60
  export function __resetTranslatorCachesForTest() {
55
61
  providerFactoryCache.clear()
62
+ __resetSyntheticPartIDForTest()
63
+ }
64
+
65
+ export function __resetSyntheticPartIDForTest() {
66
+ partLastTimestamp = 0
67
+ partCounter = 0
68
+ }
69
+
70
+ function randomBase62(length: number): string {
71
+ const bytes = randomBytes(length)
72
+ let result = ""
73
+ for (let index = 0; index < length; index += 1) {
74
+ result += BASE62_CHARS[bytes[index] % BASE62_CHARS.length]
75
+ }
76
+ return result
56
77
  }
57
78
 
58
79
  function getStatus(error: unknown): number | undefined {
@@ -201,7 +222,20 @@ export function hashText(text: string): string {
201
222
  }
202
223
 
203
224
  export function createSyntheticPartID(): string {
204
- return `prt_${randomUUID().replaceAll("-", "")}`
225
+ const currentTimestamp = Date.now()
226
+ if (currentTimestamp !== partLastTimestamp) {
227
+ partLastTimestamp = currentTimestamp
228
+ partCounter = 0
229
+ }
230
+ partCounter += 1
231
+
232
+ const encoded = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(partCounter)
233
+ const timeBytes = Buffer.alloc(6)
234
+ for (let index = 0; index < 6; index += 1) {
235
+ timeBytes[index] = Number((encoded >> BigInt(40 - 8 * index)) & BigInt(0xff))
236
+ }
237
+
238
+ return `${PART_ID_PREFIX}_${timeBytes.toString("hex")}${randomBase62(PART_ID_LENGTH - 12)}`
205
239
  }
206
240
 
207
241
  export function createTranslator(