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 +1 -1
- package/src/activation.ts +48 -33
- package/src/constants.ts +0 -6
- package/src/translator.ts +36 -2
package/package.json
CHANGED
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
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|