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
@@ -0,0 +1,57 @@
1
+ import { isUserAuthoredTextPart, type TextPartLike } from "../constants"
2
+ import type { TriggerMatch } from "./types"
3
+
4
+ function escapeRegex(value: string): string {
5
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
6
+ }
7
+
8
+ export function findTriggerMatch(parts: TextPartLike[], triggerKeywords: string[]): TriggerMatch | undefined {
9
+ let eligibleIndex = 0
10
+ for (let partArrayIndex = 0; partArrayIndex < parts.length; partArrayIndex += 1) {
11
+ const part = parts[partArrayIndex]
12
+ if (!isUserAuthoredTextPart(part)) continue
13
+
14
+ let bestForPart: TriggerMatch | undefined
15
+ for (const keyword of triggerKeywords) {
16
+ const pattern = new RegExp(`(^|[ \\t\\r\\n\\f\\v])${escapeRegex(keyword)}(?=$|[ \\t\\r\\n\\f\\v])`)
17
+ const match = pattern.exec(part.text)
18
+ if (!match) continue
19
+ const offset = match.index + match[1].length
20
+ if (!bestForPart || offset < bestForPart.offset) bestForPart = { partArrayIndex, eligibleIndex, keyword, offset }
21
+ }
22
+
23
+ if (bestForPart) return bestForPart
24
+ eligibleIndex += 1
25
+ }
26
+
27
+ return undefined
28
+ }
29
+
30
+ export function stripTriggerKeyword(text: string, keyword: string, offset: number): string {
31
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1
32
+ const nextNewline = text.indexOf("\n", offset)
33
+ const lineEnd = nextNewline === -1 ? text.length : nextNewline
34
+ const line = text.slice(lineStart, lineEnd)
35
+ const localOffset = offset - lineStart
36
+
37
+ let rewrittenLine: string
38
+ if (localOffset === 0 && line.startsWith(`${keyword} `)) {
39
+ rewrittenLine = line.slice(keyword.length + 1)
40
+ } else if (
41
+ localOffset + keyword.length === line.length &&
42
+ localOffset > 0 &&
43
+ line.slice(localOffset - 1, localOffset) === " "
44
+ ) {
45
+ rewrittenLine = line.slice(0, localOffset - 1)
46
+ } else if (
47
+ localOffset > 0 &&
48
+ line.slice(localOffset - 1, localOffset) === " " &&
49
+ line.slice(localOffset + keyword.length, localOffset + keyword.length + 1) === " "
50
+ ) {
51
+ rewrittenLine = `${line.slice(0, localOffset - 1)} ${line.slice(localOffset + keyword.length + 1)}`
52
+ } else {
53
+ rewrittenLine = `${line.slice(0, localOffset)}${line.slice(localOffset + keyword.length)}`
54
+ }
55
+
56
+ return `${text.slice(0, lineStart)}${rewrittenLine}${text.slice(lineEnd)}`
57
+ }
@@ -0,0 +1,41 @@
1
+ import type { PluginClientLike, ResolvedTranslateOptions, TranslateState } from "../constants"
2
+
3
+ export const INACTIVE_ROOT_SESSION = "inactive-root"
4
+ export const INACTIVE_CHILD_SESSION = "inactive-child"
5
+ export const QUESTION_TOOL_ID = "question"
6
+
7
+ export type CachedSessionState = TranslateState | typeof INACTIVE_ROOT_SESSION | typeof INACTIVE_CHILD_SESSION
8
+
9
+ export interface ResolvedSessionState {
10
+ sessionActive: boolean
11
+ canActivate: boolean
12
+ state?: TranslateState
13
+ storedMessages: import("../constants").MessageWithPartsLike[]
14
+ }
15
+
16
+ export interface TriggerMatch {
17
+ partArrayIndex: number
18
+ eligibleIndex: number
19
+ keyword: string
20
+ offset: number
21
+ }
22
+
23
+ interface TranslatorLike {
24
+ translateText(input: {
25
+ text: string
26
+ sourceLanguage: string
27
+ targetLanguage: string
28
+ direction: "inbound" | "outbound"
29
+ }): Promise<string>
30
+ }
31
+
32
+ export interface HookDependencies {
33
+ translator?: TranslatorLike
34
+ }
35
+
36
+ export interface HookContext {
37
+ client: PluginClientLike
38
+ directory?: string
39
+ options: ResolvedTranslateOptions
40
+ translator: TranslatorLike
41
+ }
package/src/activation.ts CHANGED
@@ -1,607 +1 @@
1
- import { randomBytes } from "node:crypto"
2
- import type { Hooks, PluginInput, PluginOptions } from "@opencode-ai/plugin"
3
- import {
4
- buildInboundTranslationError,
5
- isTextPart,
6
- isTranslateStateRecord,
7
- isUserAuthoredTextPart,
8
- LLM_LANGUAGE,
9
- type MessageWithPartsLike,
10
- NONCE_PATTERN,
11
- normalizeReason,
12
- PLUGIN_NAME,
13
- type PluginClientLike,
14
- parseTranslatorModel,
15
- type ResolvedTranslateOptions,
16
- resolveOptions,
17
- SPEC_VERSION,
18
- type StoredTextMetadata,
19
- type TextPartLike,
20
- type TranslateState,
21
- unwrapData,
22
- } from "./constants"
23
- import { composeTranslatedAssistantText, composeTranslationFailureText, extractEnglishHistoryText } from "./formatting"
24
- import { getDisplayLanguageLabel } from "./labels"
25
- import {
26
- isQuestionArgs,
27
- type QuestionSnapshot,
28
- type QuestionToolOutput,
29
- restoreQuestionOutput,
30
- snapshotQuestions,
31
- translateQuestionArgs,
32
- } from "./question-tool"
33
- import { createSyntheticPartID, createTranslator, hashText } from "./translator"
34
-
35
- const INACTIVE_ROOT_SESSION = "inactive-root"
36
- const INACTIVE_CHILD_SESSION = "inactive-child"
37
-
38
- type CachedSessionState = TranslateState | typeof INACTIVE_ROOT_SESSION | typeof INACTIVE_CHILD_SESSION
39
-
40
- const sessionStateCache = new Map<string, CachedSessionState>()
41
- const questionSnapshots = new Map<string, QuestionSnapshot>()
42
- const QUESTION_TOOL_ID = "question"
43
-
44
- export function __resetActivationCacheForTest() {
45
- sessionStateCache.clear()
46
- questionSnapshots.clear()
47
- }
48
-
49
- interface ResolvedSessionState {
50
- sessionActive: boolean
51
- canActivate: boolean
52
- state?: TranslateState
53
- storedMessages: MessageWithPartsLike[]
54
- }
55
-
56
- interface TriggerMatch {
57
- partArrayIndex: number
58
- eligibleIndex: number
59
- keyword: string
60
- offset: number
61
- }
62
-
63
- interface HookDependencies {
64
- translator?: {
65
- translateText(input: {
66
- text: string
67
- sourceLanguage: string
68
- targetLanguage: string
69
- direction: "inbound" | "outbound"
70
- }): Promise<string>
71
- }
72
- }
73
-
74
- function logError(client: PluginClientLike, error: unknown) {
75
- return client.app.log({
76
- body: {
77
- service: PLUGIN_NAME,
78
- level: "error",
79
- message: normalizeReason(error),
80
- },
81
- })
82
- }
83
-
84
- function createState(options: ResolvedTranslateOptions): TranslateState {
85
- return {
86
- translate_enabled: true,
87
- translate_source_lang: options.sourceLanguage,
88
- translate_display_lang: options.displayLanguage,
89
- translate_llm_lang: LLM_LANGUAGE,
90
- translate_nonce: randomBytes(16).toString("hex"),
91
- }
92
- }
93
-
94
- function createActivationBannerText(options: ResolvedTranslateOptions): string {
95
- const { modelID } = parseTranslatorModel(options.translatorModel)
96
- return `✓ Translation mode enabled · translator: ${modelID} · source: ${options.sourceLanguage} · display: ${options.displayLanguage}`
97
- }
98
-
99
- function asMetadata(part: TextPartLike): StoredTextMetadata {
100
- return (part.metadata ?? {}) as StoredTextMetadata
101
- }
102
-
103
- function extractStateFromMetadata(metadata: StoredTextMetadata | undefined): TranslateState | undefined {
104
- if (!isTranslateStateRecord(metadata)) return undefined
105
- return {
106
- translate_enabled: true,
107
- translate_source_lang: metadata.translate_source_lang,
108
- translate_display_lang: metadata.translate_display_lang,
109
- translate_llm_lang: LLM_LANGUAGE,
110
- translate_nonce: metadata.translate_nonce,
111
- }
112
- }
113
-
114
- export function extractStoredState(messages: MessageWithPartsLike[]): TranslateState | undefined {
115
- let fallback: TranslateState | undefined
116
-
117
- for (const message of messages) {
118
- for (const part of message.parts) {
119
- if (!isTextPart(part)) continue
120
- const metadata = asMetadata(part)
121
- const state = extractStateFromMetadata(metadata)
122
- if (!state) continue
123
- if (metadata.translate_role === "activation_banner") return state
124
- if (message.info.role === "user" && part.synthetic !== true && fallback === undefined) {
125
- fallback = state
126
- }
127
- }
128
- }
129
-
130
- return fallback
131
- }
132
-
133
- function mergeTranslatedMetadata(state: TranslateState, part: TextPartLike, english: string): Record<string, unknown> {
134
- return {
135
- ...(part.metadata ?? {}),
136
- ...state,
137
- translate_source_hash: hashText(part.text ?? ""),
138
- translate_en: english,
139
- }
140
- }
141
-
142
- // OpenCode's flag semantics are not uniform across every UI path:
143
- // synthetic: true -> hidden from the user UI, still sent to the LLM
144
- // ignored: true -> hidden from the LLM, still shown in the main message UI
145
- // both true -> hidden from both (used for metadata-only marker
146
- // parts like the activation banner that exist purely
147
- // to carry state across reloads)
148
- // Some secondary UIs, including the web `/fork` dialog, only consider text
149
- // parts that are neither synthetic nor ignored. Persist translated user display
150
- // parts as non-ignored so those flows can find them; before model serialization,
151
- // `experimental.chat.messages.transform` marks those display parts ignored and
152
- // lets the synthetic English twins carry the actual prompt text.
153
- // The plugin's user-facing status text (translation preview, activation
154
- // banner, failure notice) lives inline on the source-language user part
155
- // itself rather than as sibling parts, because OpenCode's
156
- // `UserMessageDisplay` renders only the first non-synthetic text part
157
- // per user message.
158
-
159
- // LLM-only text part: hidden from the TUI but the only LLM-visible
160
- // representation of the user's source-language text. The original
161
- // user-authored part is marked `ignored:true` in the model-bound transform so
162
- // the LLM never sees it, and this synthetic English twin carries the actual
163
- // prompt content.
164
- function createLlmOnlyTextPart(
165
- sessionID: string,
166
- messageID: string,
167
- text: string,
168
- metadata: Record<string, unknown>,
169
- ): TextPartLike {
170
- return {
171
- id: createSyntheticPartID(),
172
- sessionID,
173
- messageID,
174
- type: "text",
175
- text,
176
- synthetic: true,
177
- ignored: false,
178
- metadata,
179
- }
180
- }
181
-
182
- function isTranslatedUserDisplayPart(part: TextPartLike): boolean {
183
- if (!isTextPart(part) || part.synthetic === true) return false
184
- return extractStateFromMetadata(asMetadata(part)) !== undefined
185
- }
186
-
187
- function escapeRegex(value: string): string {
188
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
189
- }
190
-
191
- export function findTriggerMatch(parts: TextPartLike[], triggerKeywords: string[]): TriggerMatch | undefined {
192
- let eligibleIndex = 0
193
- for (let partArrayIndex = 0; partArrayIndex < parts.length; partArrayIndex += 1) {
194
- const part = parts[partArrayIndex]
195
- if (!isUserAuthoredTextPart(part)) continue
196
-
197
- let bestForPart: TriggerMatch | undefined
198
- for (let keywordIndex = 0; keywordIndex < triggerKeywords.length; keywordIndex += 1) {
199
- const keyword = triggerKeywords[keywordIndex]
200
- const pattern = new RegExp(`(^|[ \\t\\r\\n\\f\\v])${escapeRegex(keyword)}(?=$|[ \\t\\r\\n\\f\\v])`)
201
- const match = pattern.exec(part.text)
202
- if (!match) continue
203
- const offset = match.index + match[1].length
204
- if (!bestForPart || offset < bestForPart.offset) {
205
- bestForPart = {
206
- partArrayIndex,
207
- eligibleIndex,
208
- keyword,
209
- offset,
210
- }
211
- }
212
- }
213
-
214
- if (bestForPart) return bestForPart
215
- eligibleIndex += 1
216
- }
217
-
218
- return undefined
219
- }
220
-
221
- export function stripTriggerKeyword(text: string, keyword: string, offset: number): string {
222
- const lineStart = text.lastIndexOf("\n", offset - 1) + 1
223
- const nextNewline = text.indexOf("\n", offset)
224
- const lineEnd = nextNewline === -1 ? text.length : nextNewline
225
- const line = text.slice(lineStart, lineEnd)
226
- const localOffset = offset - lineStart
227
-
228
- let rewrittenLine: string
229
- if (localOffset === 0 && line.startsWith(`${keyword} `)) {
230
- rewrittenLine = line.slice(keyword.length + 1)
231
- } else if (
232
- localOffset + keyword.length === line.length &&
233
- localOffset > 0 &&
234
- line.slice(localOffset - 1, localOffset) === " "
235
- ) {
236
- rewrittenLine = line.slice(0, localOffset - 1)
237
- } else if (
238
- localOffset > 0 &&
239
- line.slice(localOffset - 1, localOffset) === " " &&
240
- line.slice(localOffset + keyword.length, localOffset + keyword.length + 1) === " "
241
- ) {
242
- rewrittenLine = `${line.slice(0, localOffset - 1)} ${line.slice(localOffset + keyword.length + 1)}`
243
- } else {
244
- rewrittenLine = `${line.slice(0, localOffset)}${line.slice(localOffset + keyword.length)}`
245
- }
246
-
247
- return `${text.slice(0, lineStart)}${rewrittenLine}${text.slice(lineEnd)}`
248
- }
249
-
250
- async function resolveSessionState(
251
- client: PluginClientLike,
252
- directory: string | undefined,
253
- sessionID: string,
254
- ): Promise<ResolvedSessionState> {
255
- const cached = sessionStateCache.get(sessionID)
256
- if (cached !== undefined) {
257
- if (cached === INACTIVE_ROOT_SESSION) {
258
- return {
259
- sessionActive: false,
260
- canActivate: true,
261
- storedMessages: [],
262
- }
263
- }
264
-
265
- if (cached === INACTIVE_CHILD_SESSION) {
266
- return {
267
- sessionActive: false,
268
- canActivate: false,
269
- storedMessages: [],
270
- }
271
- }
272
-
273
- return {
274
- sessionActive: true,
275
- canActivate: false,
276
- state: cached,
277
- storedMessages: [],
278
- }
279
- }
280
-
281
- const session = unwrapData(
282
- await client.session.get({
283
- path: { id: sessionID },
284
- query: { ...(directory ? { directory } : {}) },
285
- throwOnError: true,
286
- }),
287
- )
288
- if (session.parentID != null) {
289
- sessionStateCache.set(sessionID, INACTIVE_CHILD_SESSION)
290
- return { sessionActive: false, canActivate: false, storedMessages: [] }
291
- }
292
-
293
- const storedMessages = unwrapData(
294
- await client.session.messages({
295
- path: { id: sessionID },
296
- query: { ...(directory ? { directory } : {}) },
297
- throwOnError: true,
298
- }),
299
- )
300
- const state = extractStoredState(storedMessages)
301
- if (state) {
302
- sessionStateCache.set(sessionID, state)
303
- } else {
304
- sessionStateCache.set(sessionID, INACTIVE_ROOT_SESSION)
305
- }
306
-
307
- return {
308
- sessionActive: Boolean(state),
309
- canActivate: !state,
310
- state: state ?? undefined,
311
- storedMessages,
312
- }
313
- }
314
-
315
- export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, deps: HookDependencies = {}): Hooks {
316
- if (process.env.OPENCODE_TRANSLATE_DISABLE === "1") {
317
- return {}
318
- }
319
-
320
- const client = ctx.client as unknown as PluginClientLike
321
- const options = resolveOptions(rawOptions)
322
- const translator = deps.translator ?? createTranslator(client, options)
323
-
324
- return {
325
- "chat.message": async (input, output) => {
326
- // Hooks must never throw. A thrown error propagates into OpenCode's
327
- // Effect runtime as a defect, kills the fiber, and stalls the session —
328
- // to the user this looks like infinite loading with no error message.
329
- // Instead, we log the failure and fall back to the untranslated text so
330
- // the chat keeps moving.
331
- try {
332
- const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
333
- let activeState = resolved.state
334
- let activatedThisTurn = false
335
-
336
- if (!activeState && resolved.canActivate) {
337
- const match = findTriggerMatch(output.parts as TextPartLike[], options.triggerKeywords)
338
- if (match) {
339
- const part = output.parts[match.partArrayIndex] as TextPartLike & { text: string }
340
- const originalText = part.text
341
- part.text = stripTriggerKeyword(part.text, match.keyword, match.offset)
342
- activeState = createState(options)
343
- if (!NONCE_PATTERN.test(activeState.translate_nonce)) {
344
- part.text = originalText
345
- await logError(client, new Error("Generated invalid translation nonce"))
346
- return
347
- }
348
- activatedThisTurn = true
349
- sessionStateCache.set(input.sessionID, activeState)
350
- }
351
- }
352
-
353
- if (!activeState) return
354
-
355
- const nextParts: TextPartLike[] = []
356
- let eligibleIndex = 0
357
- const translationErrors: { part: TextPartLike; error: unknown }[] = []
358
- // Track the first user-authored text part so the activation
359
- // banner can be inlined onto it. OpenCode's `UserMessageDisplay`
360
- // renders only the first non-synthetic text part per user
361
- // message, so any standalone banner part is never visible in
362
- // the TUI.
363
- let firstUserTextPart: (TextPartLike & { text: string }) | undefined
364
-
365
- for (const part of output.parts as TextPartLike[]) {
366
- nextParts.push(part)
367
- if (!isUserAuthoredTextPart(part)) continue
368
-
369
- if (firstUserTextPart === undefined) {
370
- firstUserTextPart = part
371
- }
372
-
373
- const currentEligibleIndex = eligibleIndex
374
- eligibleIndex += 1
375
- if (part.text.trim().length === 0) continue
376
-
377
- try {
378
- const english = await translator.translateText({
379
- text: part.text,
380
- sourceLanguage: activeState.translate_source_lang,
381
- targetLanguage: LLM_LANGUAGE,
382
- direction: "inbound",
383
- })
384
-
385
- const sourceHash = hashText(part.text)
386
- // Compute metadata against the ORIGINAL `part.text` so the
387
- // stored `translate_source_hash` keeps a stable round-trip
388
- // identity even after we splice the preview into the part
389
- // text below.
390
- part.metadata = {
391
- ...(part.metadata ?? {}),
392
- ...mergeTranslatedMetadata(activeState, part, english),
393
- }
394
- // Inline the `→ EN: ...` preview into the source-language
395
- // part. OpenCode's `UserMessageDisplay`
396
- // (packages/ui/src/components/message-part.tsx) renders only
397
- // ONE text part per user message — the first non-synthetic —
398
- // so a sibling `synthetic:false + ignored:true` preview part
399
- // would never reach the screen. Combining them here keeps
400
- // the original visible alongside the English twin while still
401
- // satisfying secondary UI filters like `/fork`, which require
402
- // non-synthetic, non-ignored text. The transform hook marks this
403
- // display part ignored in the model-bound copy.
404
- part.text = `${part.text}\n\n_→ EN: ${english}_`
405
-
406
- // LLM-only English twin. This is the actual prompt the model
407
- // sees in place of the source-language display part.
408
- nextParts.push(
409
- createLlmOnlyTextPart(part.sessionID, part.messageID, english, {
410
- translate_role: "llm_only_translation",
411
- translate_nonce: activeState.translate_nonce,
412
- translate_source_hash: sourceHash,
413
- translate_part_index: currentEligibleIndex,
414
- }),
415
- )
416
- } catch (error) {
417
- // Translation failed. Append a visible warning to the
418
- // source-language part (same UI constraint as the success
419
- // path: only one text part per user message renders) and
420
- // route the original, untranslated text to the LLM via a
421
- // dedicated fallback twin so the model still gets a clean
422
- // prompt without the warning text leaking into context.
423
- translationErrors.push({ part, error })
424
- const reason = normalizeReason(error)
425
- const wrapped = buildInboundTranslationError(activeState.translate_source_lang, reason)
426
- await logError(client, wrapped)
427
-
428
- const originalText = part.text
429
- part.text = `${originalText}\n\n_⚠️ Translation failed: ${reason}. Original text was sent to the model._`
430
- part.ignored = true
431
-
432
- nextParts.push(
433
- createLlmOnlyTextPart(part.sessionID, part.messageID, originalText, {
434
- translate_role: "llm_only_fallback",
435
- translate_nonce: activeState.translate_nonce,
436
- translate_part_index: currentEligibleIndex,
437
- }),
438
- )
439
- }
440
- }
441
-
442
- // If we activated this turn but translation failed for every
443
- // user-authored part, roll back activation so the next turn does a
444
- // clean retry instead of cementing broken state.
445
- if (activatedThisTurn && translationErrors.length > 0 && eligibleIndex === translationErrors.length) {
446
- sessionStateCache.set(input.sessionID, INACTIVE_ROOT_SESSION)
447
- return
448
- }
449
-
450
- if (activatedThisTurn) {
451
- const bannerText = createActivationBannerText(options)
452
- // Inline the activation banner into the first user-authored
453
- // text part so it actually reaches the TUI (see comment above
454
- // for the single-text-part rendering constraint). The banner
455
- // sits underneath the inline `→ EN: ...` preview that was
456
- // appended during translation, giving the user a one-time
457
- // confirmation that translation mode just turned on.
458
- if (firstUserTextPart !== undefined) {
459
- firstUserTextPart.text = `${firstUserTextPart.text}\n\n_${bannerText}_`
460
- }
461
- // Also emit the banner as a metadata-only synthetic part so
462
- // `extractStoredState`'s canonical `translate_role ===
463
- // "activation_banner"` marker is preserved across reloads.
464
- // `synthetic:true + ignored:true` keeps it hidden from both
465
- // the TUI and the LLM serializer; it exists purely as a
466
- // database row carrying state metadata.
467
- nextParts.push({
468
- id: createSyntheticPartID(),
469
- sessionID: input.sessionID,
470
- messageID: output.message.id,
471
- type: "text",
472
- text: bannerText,
473
- synthetic: true,
474
- ignored: true,
475
- metadata: {
476
- ...activeState,
477
- translate_role: "activation_banner",
478
- translate_spec_version: SPEC_VERSION,
479
- },
480
- })
481
- }
482
-
483
- output.parts.splice(0, output.parts.length, ...(nextParts as typeof output.parts))
484
- } catch (error) {
485
- await logError(client, error)
486
- }
487
- },
488
- "experimental.chat.messages.transform": async (_input, output) => {
489
- try {
490
- const sessionID = output.messages[0]?.info.sessionID
491
- if (!sessionID) return
492
-
493
- const resolved = await resolveSessionState(client, ctx.directory, sessionID)
494
- const activeState = resolved.state
495
- if (!activeState) return
496
-
497
- for (const message of output.messages as MessageWithPartsLike[]) {
498
- if (message.info.role === "user") {
499
- for (const part of message.parts) {
500
- if (!isTranslatedUserDisplayPart(part)) continue
501
- part.ignored = true
502
- }
503
- continue
504
- }
505
-
506
- if (message.info.role !== "assistant") continue
507
- for (const part of message.parts) {
508
- if (!isTextPart(part)) continue
509
- part.text = extractEnglishHistoryText(part.text, activeState.translate_nonce)
510
- }
511
- }
512
- } catch (error) {
513
- await logError(client, error)
514
- }
515
- },
516
- "experimental.text.complete": async (input, output) => {
517
- try {
518
- const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
519
- const activeState = resolved.state
520
- if (!activeState) return
521
-
522
- const message = unwrapData(
523
- await client.session.message({
524
- path: { id: input.sessionID, messageID: input.messageID },
525
- query: { ...(ctx.directory ? { directory: ctx.directory } : {}) },
526
- throwOnError: true,
527
- }),
528
- ) as MessageWithPartsLike & { info: Record<string, unknown> }
529
-
530
- if (message.info.role !== "assistant") return
531
- if (message.info.summary === true) return
532
- if (activeState.translate_display_lang === LLM_LANGUAGE || output.text.length === 0) return
533
-
534
- try {
535
- const translated = await translator.translateText({
536
- text: output.text,
537
- sourceLanguage: LLM_LANGUAGE,
538
- targetLanguage: activeState.translate_display_lang,
539
- direction: "outbound",
540
- })
541
-
542
- output.text = composeTranslatedAssistantText(
543
- output.text,
544
- getDisplayLanguageLabel(activeState.translate_display_lang),
545
- translated,
546
- activeState.translate_nonce,
547
- )
548
- } catch (error) {
549
- output.text = composeTranslationFailureText(output.text, activeState.translate_nonce)
550
- await logError(client, error)
551
- }
552
- } catch (error) {
553
- await logError(client, error)
554
- }
555
- },
556
- // Translate the built-in `question` tool so the TUI dialog renders in
557
- // the user's displayLanguage. The tool output string is restored back
558
- // to English in `tool.execute.after` so the main LLM context stays
559
- // English-only.
560
- "tool.execute.before": async (input, output) => {
561
- try {
562
- if (input.tool !== QUESTION_TOOL_ID) return
563
- const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
564
- const activeState = resolved.state
565
- if (!activeState) return
566
- if (activeState.translate_display_lang === LLM_LANGUAGE) return
567
-
568
- const args = output.args as unknown
569
- if (!isQuestionArgs(args)) return
570
-
571
- const original = snapshotQuestions(args)
572
- try {
573
- await translateQuestionArgs(args, (text) =>
574
- translator.translateText({
575
- text,
576
- sourceLanguage: LLM_LANGUAGE,
577
- targetLanguage: activeState.translate_display_lang,
578
- direction: "outbound",
579
- }),
580
- )
581
- } catch (error) {
582
- // Translation failed: restore the originals so the dialog at least
583
- // renders in English instead of a half-translated mess.
584
- args.questions.splice(0, args.questions.length, ...snapshotQuestions({ questions: original }))
585
- await logError(client, error)
586
- return
587
- }
588
-
589
- const translated = snapshotQuestions(args)
590
- questionSnapshots.set(input.callID, { original, translated })
591
- } catch (error) {
592
- await logError(client, error)
593
- }
594
- },
595
- "tool.execute.after": async (input, output) => {
596
- try {
597
- if (input.tool !== QUESTION_TOOL_ID) return
598
- const snapshot = questionSnapshots.get(input.callID)
599
- if (!snapshot) return
600
- questionSnapshots.delete(input.callID)
601
- restoreQuestionOutput(output as QuestionToolOutput, snapshot)
602
- } catch (error) {
603
- await logError(client, error)
604
- }
605
- },
606
- }
607
- }
1
+ export * from "./activation/index"