opencode-translate 0.1.2 → 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.
- package/package.json +6 -3
- package/src/activation/chat-message.ts +164 -0
- package/src/activation/index.ts +38 -0
- package/src/activation/logging.ts +11 -0
- package/src/activation/messages-transform.ts +38 -0
- package/src/activation/metadata.ts +42 -0
- package/src/activation/parts.ts +53 -0
- package/src/activation/question-hooks.ts +95 -0
- package/src/activation/state.ts +98 -0
- package/src/activation/text-complete.ts +51 -0
- package/src/activation/trigger.ts +57 -0
- package/src/activation/types.ts +41 -0
- package/src/activation.ts +1 -633
- package/src/anthropic-oauth.ts +3 -3
- package/src/auth/codex-request.ts +108 -0
- package/src/auth/codex-response.ts +78 -0
- package/src/auth/codex-shared.ts +3 -0
- package/src/auth/headers.ts +18 -0
- package/src/auth/index.ts +153 -0
- package/src/auth/oauth-fetch.ts +100 -0
- package/src/auth/refresh.ts +102 -0
- package/src/auth/retry.ts +70 -0
- package/src/auth/store.ts +45 -0
- package/src/auth/types.ts +27 -0
- package/src/auth.ts +1 -725
- package/src/constants/errors.ts +24 -0
- package/src/constants/guards.ts +34 -0
- package/src/constants/options.ts +37 -0
- package/src/constants/plugin.ts +10 -0
- package/src/constants/types.ts +147 -0
- package/src/constants.ts +5 -261
- package/src/labels.ts +0 -2
- package/src/translator/index.ts +125 -0
- package/src/translator/part-id.ts +43 -0
- package/src/translator/provider.ts +81 -0
- package/src/translator/retry.ts +62 -0
- package/src/translator/types.ts +17 -0
- package/src/translator.ts +1 -326
|
@@ -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,633 +1 @@
|
|
|
1
|
-
|
|
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`; typed custom answers also go
|
|
559
|
-
// through the inbound user-message translation path.
|
|
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
|
-
|
|
567
|
-
const args = output.args as unknown
|
|
568
|
-
if (!isQuestionArgs(args)) return
|
|
569
|
-
|
|
570
|
-
const original = snapshotQuestions(args)
|
|
571
|
-
if (activeState.translate_display_lang !== LLM_LANGUAGE) {
|
|
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
|
-
|
|
590
|
-
const translated = snapshotQuestions(args)
|
|
591
|
-
questionSnapshots.set(input.callID, { original, translated })
|
|
592
|
-
} catch (error) {
|
|
593
|
-
await logError(client, error)
|
|
594
|
-
}
|
|
595
|
-
},
|
|
596
|
-
"tool.execute.after": async (input, output) => {
|
|
597
|
-
try {
|
|
598
|
-
if (input.tool !== QUESTION_TOOL_ID) return
|
|
599
|
-
const snapshot = questionSnapshots.get(input.callID)
|
|
600
|
-
if (!snapshot) return
|
|
601
|
-
questionSnapshots.delete(input.callID)
|
|
602
|
-
const resolved = await resolveSessionState(client, ctx.directory, input.sessionID)
|
|
603
|
-
const activeState = resolved.state
|
|
604
|
-
const translateCustomAnswer =
|
|
605
|
-
activeState && activeState.translate_source_lang !== LLM_LANGUAGE
|
|
606
|
-
? (text: string) =>
|
|
607
|
-
translator.translateText({
|
|
608
|
-
text,
|
|
609
|
-
sourceLanguage: activeState.translate_source_lang,
|
|
610
|
-
targetLanguage: LLM_LANGUAGE,
|
|
611
|
-
direction: "inbound",
|
|
612
|
-
})
|
|
613
|
-
: undefined
|
|
614
|
-
|
|
615
|
-
await restoreQuestionOutput(output as QuestionToolOutput, snapshot, {
|
|
616
|
-
...(translateCustomAnswer ? { translateCustomAnswer } : {}),
|
|
617
|
-
onTranslationError: async (error) => {
|
|
618
|
-
if (!activeState) {
|
|
619
|
-
await logError(client, error)
|
|
620
|
-
return
|
|
621
|
-
}
|
|
622
|
-
await logError(
|
|
623
|
-
client,
|
|
624
|
-
buildInboundTranslationError(activeState.translate_source_lang, normalizeReason(error)),
|
|
625
|
-
)
|
|
626
|
-
},
|
|
627
|
-
})
|
|
628
|
-
} catch (error) {
|
|
629
|
-
await logError(client, error)
|
|
630
|
-
}
|
|
631
|
-
},
|
|
632
|
-
}
|
|
633
|
-
}
|
|
1
|
+
export * from "./activation/index"
|
package/src/anthropic-oauth.ts
CHANGED
|
@@ -22,10 +22,10 @@ import { createHash } from "node:crypto"
|
|
|
22
22
|
|
|
23
23
|
export const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK."
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
const REQUIRED_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"] as const
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const CLAUDE_CODE_VERSION = "2.1.87"
|
|
28
|
+
const CLAUDE_CODE_ENTRYPOINT = "sdk-cli"
|
|
29
29
|
export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`
|
|
30
30
|
|
|
31
31
|
// Deterministic billing header parameters — must match Claude Code's own derivation.
|