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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-translate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
"format:check": "biome format .",
|
|
35
35
|
"lint": "biome lint --write .",
|
|
36
36
|
"lint:check": "biome lint .",
|
|
37
|
+
"knip": "knip",
|
|
37
38
|
"check": "biome check --write .",
|
|
38
39
|
"check:ci": "biome check .",
|
|
39
|
-
"test": "bun test"
|
|
40
|
+
"test": "bun test",
|
|
41
|
+
"test:coverage": "bun test --coverage"
|
|
40
42
|
},
|
|
41
43
|
"peerDependencies": {
|
|
42
44
|
"@opencode-ai/plugin": ">=1.14.0"
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
"@opencode-ai/plugin": "^1.14.21",
|
|
56
58
|
"@types/node": "^25.6.0",
|
|
57
59
|
"@typescript/native-preview": "beta",
|
|
58
|
-
"bun-types": "^1.3.13"
|
|
60
|
+
"bun-types": "^1.3.13",
|
|
61
|
+
"knip": "^6.14.1"
|
|
59
62
|
}
|
|
60
63
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import {
|
|
3
|
+
buildInboundTranslationError,
|
|
4
|
+
isUserAuthoredTextPart,
|
|
5
|
+
LLM_LANGUAGE,
|
|
6
|
+
NONCE_PATTERN,
|
|
7
|
+
normalizeReason,
|
|
8
|
+
type TextPartLike,
|
|
9
|
+
type TranslateState,
|
|
10
|
+
} from "../constants"
|
|
11
|
+
import { hashText } from "../translator"
|
|
12
|
+
import { logError } from "./logging"
|
|
13
|
+
import { mergeTranslatedMetadata } from "./metadata"
|
|
14
|
+
import { createActivationBannerPart, createActivationBannerText, createLlmOnlyTextPart } from "./parts"
|
|
15
|
+
import { cacheSessionState, createState, resolveSessionState } from "./state"
|
|
16
|
+
import { findTriggerMatch, stripTriggerKeyword } from "./trigger"
|
|
17
|
+
import { type HookContext, INACTIVE_ROOT_SESSION, type ResolvedSessionState } from "./types"
|
|
18
|
+
|
|
19
|
+
type ChatMessageHook = NonNullable<Hooks["chat.message"]>
|
|
20
|
+
type ChatMessageInput = Parameters<ChatMessageHook>[0]
|
|
21
|
+
type ChatMessageOutput = Parameters<ChatMessageHook>[1]
|
|
22
|
+
|
|
23
|
+
interface ActivationResult {
|
|
24
|
+
state?: TranslateState
|
|
25
|
+
activatedThisTurn: boolean
|
|
26
|
+
aborted: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PartProcessingResult {
|
|
30
|
+
nextParts: TextPartLike[]
|
|
31
|
+
eligibleIndex: number
|
|
32
|
+
errors: { part: TextPartLike; error: unknown }[]
|
|
33
|
+
firstUserTextPart?: TextPartLike & { text: string }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function activateFromTrigger(
|
|
37
|
+
ctx: HookContext,
|
|
38
|
+
input: ChatMessageInput,
|
|
39
|
+
output: ChatMessageOutput,
|
|
40
|
+
resolved: ResolvedSessionState,
|
|
41
|
+
): Promise<ActivationResult> {
|
|
42
|
+
if (resolved.state || !resolved.canActivate)
|
|
43
|
+
return { state: resolved.state, activatedThisTurn: false, aborted: false }
|
|
44
|
+
|
|
45
|
+
const match = findTriggerMatch(output.parts as TextPartLike[], ctx.options.triggerKeywords)
|
|
46
|
+
if (!match) return { activatedThisTurn: false, aborted: false }
|
|
47
|
+
|
|
48
|
+
const part = output.parts[match.partArrayIndex] as TextPartLike & { text: string }
|
|
49
|
+
const originalText = part.text
|
|
50
|
+
part.text = stripTriggerKeyword(part.text, match.keyword, match.offset)
|
|
51
|
+
const state = createState(ctx.options)
|
|
52
|
+
if (!NONCE_PATTERN.test(state.translate_nonce)) {
|
|
53
|
+
part.text = originalText
|
|
54
|
+
await logError(ctx.client, new Error("Generated invalid translation nonce"))
|
|
55
|
+
return { activatedThisTurn: false, aborted: true }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
cacheSessionState(input.sessionID, state)
|
|
59
|
+
return { state, activatedThisTurn: true, aborted: false }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function translateUserPart(
|
|
63
|
+
ctx: HookContext,
|
|
64
|
+
state: TranslateState,
|
|
65
|
+
part: TextPartLike & { text: string },
|
|
66
|
+
eligibleIndex: number,
|
|
67
|
+
nextParts: TextPartLike[],
|
|
68
|
+
errors: { part: TextPartLike; error: unknown }[],
|
|
69
|
+
) {
|
|
70
|
+
try {
|
|
71
|
+
const english = await ctx.translator.translateText({
|
|
72
|
+
text: part.text,
|
|
73
|
+
sourceLanguage: state.translate_source_lang,
|
|
74
|
+
targetLanguage: LLM_LANGUAGE,
|
|
75
|
+
direction: "inbound",
|
|
76
|
+
})
|
|
77
|
+
const sourceHash = hashText(part.text)
|
|
78
|
+
part.metadata = { ...(part.metadata ?? {}), ...mergeTranslatedMetadata(state, part, english) }
|
|
79
|
+
part.text = `${part.text}\n\n_→ EN: ${english}_`
|
|
80
|
+
nextParts.push(
|
|
81
|
+
createLlmOnlyTextPart(part.sessionID, part.messageID, english, {
|
|
82
|
+
translate_role: "llm_only_translation",
|
|
83
|
+
translate_nonce: state.translate_nonce,
|
|
84
|
+
translate_source_hash: sourceHash,
|
|
85
|
+
translate_part_index: eligibleIndex,
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
errors.push({ part, error })
|
|
90
|
+
const reason = normalizeReason(error)
|
|
91
|
+
await logError(ctx.client, buildInboundTranslationError(state.translate_source_lang, reason))
|
|
92
|
+
const originalText = part.text
|
|
93
|
+
part.text = `${originalText}\n\n_⚠️ Translation failed: ${reason}. Original text was sent to the model._`
|
|
94
|
+
part.ignored = true
|
|
95
|
+
nextParts.push(
|
|
96
|
+
createLlmOnlyTextPart(part.sessionID, part.messageID, originalText, {
|
|
97
|
+
translate_role: "llm_only_fallback",
|
|
98
|
+
translate_nonce: state.translate_nonce,
|
|
99
|
+
translate_part_index: eligibleIndex,
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function processParts(
|
|
106
|
+
ctx: HookContext,
|
|
107
|
+
output: ChatMessageOutput,
|
|
108
|
+
state: TranslateState,
|
|
109
|
+
): Promise<PartProcessingResult> {
|
|
110
|
+
const result: PartProcessingResult = { nextParts: [], eligibleIndex: 0, errors: [] }
|
|
111
|
+
for (const part of output.parts as TextPartLike[]) {
|
|
112
|
+
result.nextParts.push(part)
|
|
113
|
+
if (!isUserAuthoredTextPart(part)) continue
|
|
114
|
+
result.firstUserTextPart ??= part
|
|
115
|
+
const currentEligibleIndex = result.eligibleIndex
|
|
116
|
+
result.eligibleIndex += 1
|
|
117
|
+
if (part.text.trim().length === 0) continue
|
|
118
|
+
await translateUserPart(ctx, state, part, currentEligibleIndex, result.nextParts, result.errors)
|
|
119
|
+
}
|
|
120
|
+
return result
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function appendActivationBanner(
|
|
124
|
+
ctx: HookContext,
|
|
125
|
+
input: ChatMessageInput,
|
|
126
|
+
output: ChatMessageOutput,
|
|
127
|
+
state: TranslateState,
|
|
128
|
+
processed: PartProcessingResult,
|
|
129
|
+
) {
|
|
130
|
+
const bannerText = createActivationBannerText(ctx.options)
|
|
131
|
+
if (processed.firstUserTextPart !== undefined) {
|
|
132
|
+
processed.firstUserTextPart.text = `${processed.firstUserTextPart.text}\n\n_${bannerText}_`
|
|
133
|
+
}
|
|
134
|
+
processed.nextParts.push(createActivationBannerPart(input.sessionID, output.message.id, state, bannerText))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function handleChatMessage(ctx: HookContext, input: ChatMessageInput, output: ChatMessageOutput) {
|
|
138
|
+
const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
|
|
139
|
+
const activation = await activateFromTrigger(ctx, input, output, resolved)
|
|
140
|
+
if (activation.aborted || !activation.state) return
|
|
141
|
+
|
|
142
|
+
const processed = await processParts(ctx, output, activation.state)
|
|
143
|
+
if (
|
|
144
|
+
activation.activatedThisTurn &&
|
|
145
|
+
processed.errors.length > 0 &&
|
|
146
|
+
processed.eligibleIndex === processed.errors.length
|
|
147
|
+
) {
|
|
148
|
+
cacheSessionState(input.sessionID, INACTIVE_ROOT_SESSION)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (activation.activatedThisTurn) appendActivationBanner(ctx, input, output, activation.state, processed)
|
|
153
|
+
output.parts.splice(0, output.parts.length, ...(processed.nextParts as typeof output.parts))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createChatMessageHook(ctx: HookContext): ChatMessageHook {
|
|
157
|
+
return async (input, output) => {
|
|
158
|
+
try {
|
|
159
|
+
await handleChatMessage(ctx, input, output)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
await logError(ctx.client, error)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Hooks, PluginInput, PluginOptions } from "@opencode-ai/plugin"
|
|
2
|
+
import { type PluginClientLike, resolveOptions } from "../constants"
|
|
3
|
+
import { createTranslator } from "../translator"
|
|
4
|
+
import { createChatMessageHook } from "./chat-message"
|
|
5
|
+
import { createMessagesTransformHook } from "./messages-transform"
|
|
6
|
+
import { createToolExecuteAfterHook, createToolExecuteBeforeHook, resetQuestionSnapshots } from "./question-hooks"
|
|
7
|
+
import { resetSessionStateCache } from "./state"
|
|
8
|
+
import { createTextCompleteHook } from "./text-complete"
|
|
9
|
+
import type { HookContext, HookDependencies } from "./types"
|
|
10
|
+
|
|
11
|
+
export { extractStoredState } from "./state"
|
|
12
|
+
export { findTriggerMatch, stripTriggerKeyword } from "./trigger"
|
|
13
|
+
|
|
14
|
+
export function __resetActivationCacheForTest() {
|
|
15
|
+
resetSessionStateCache()
|
|
16
|
+
resetQuestionSnapshots()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, deps: HookDependencies = {}): Hooks {
|
|
20
|
+
if (process.env.OPENCODE_TRANSLATE_DISABLE === "1") return {}
|
|
21
|
+
|
|
22
|
+
const client = ctx.client as unknown as PluginClientLike
|
|
23
|
+
const options = resolveOptions(rawOptions)
|
|
24
|
+
const hookContext: HookContext = {
|
|
25
|
+
client,
|
|
26
|
+
directory: ctx.directory,
|
|
27
|
+
options,
|
|
28
|
+
translator: deps.translator ?? createTranslator(client, options),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"chat.message": createChatMessageHook(hookContext),
|
|
33
|
+
"experimental.chat.messages.transform": createMessagesTransformHook(hookContext),
|
|
34
|
+
"experimental.text.complete": createTextCompleteHook(hookContext),
|
|
35
|
+
"tool.execute.before": createToolExecuteBeforeHook(hookContext),
|
|
36
|
+
"tool.execute.after": createToolExecuteAfterHook(hookContext),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { normalizeReason, PLUGIN_NAME, type PluginClientLike } from "../constants"
|
|
2
|
+
|
|
3
|
+
export function logError(client: PluginClientLike, error: unknown) {
|
|
4
|
+
return client.app.log({
|
|
5
|
+
body: {
|
|
6
|
+
service: PLUGIN_NAME,
|
|
7
|
+
level: "error",
|
|
8
|
+
message: normalizeReason(error),
|
|
9
|
+
},
|
|
10
|
+
})
|
|
11
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import { isTextPart, type MessageWithPartsLike } from "../constants"
|
|
3
|
+
import { extractEnglishHistoryText } from "../formatting"
|
|
4
|
+
import { logError } from "./logging"
|
|
5
|
+
import { isTranslatedUserDisplayPart } from "./metadata"
|
|
6
|
+
import { resolveSessionState } from "./state"
|
|
7
|
+
import type { HookContext } from "./types"
|
|
8
|
+
|
|
9
|
+
type MessagesTransformHook = NonNullable<Hooks["experimental.chat.messages.transform"]>
|
|
10
|
+
|
|
11
|
+
export function createMessagesTransformHook(ctx: HookContext): MessagesTransformHook {
|
|
12
|
+
return async (_input, output) => {
|
|
13
|
+
try {
|
|
14
|
+
const sessionID = output.messages[0]?.info.sessionID
|
|
15
|
+
if (!sessionID) return
|
|
16
|
+
|
|
17
|
+
const resolved = await resolveSessionState(ctx.client, ctx.directory, sessionID)
|
|
18
|
+
const activeState = resolved.state
|
|
19
|
+
if (!activeState) return
|
|
20
|
+
|
|
21
|
+
for (const message of output.messages as MessageWithPartsLike[]) {
|
|
22
|
+
if (message.info.role === "user") {
|
|
23
|
+
for (const part of message.parts) {
|
|
24
|
+
if (isTranslatedUserDisplayPart(part)) part.ignored = true
|
|
25
|
+
}
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (message.info.role !== "assistant") continue
|
|
30
|
+
for (const part of message.parts) {
|
|
31
|
+
if (isTextPart(part)) part.text = extractEnglishHistoryText(part.text, activeState.translate_nonce)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
await logError(ctx.client, error)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isTextPart,
|
|
3
|
+
isTranslateStateRecord,
|
|
4
|
+
LLM_LANGUAGE,
|
|
5
|
+
type StoredTextMetadata,
|
|
6
|
+
type TextPartLike,
|
|
7
|
+
type TranslateState,
|
|
8
|
+
} from "../constants"
|
|
9
|
+
import { hashText } from "../translator"
|
|
10
|
+
|
|
11
|
+
export function asMetadata(part: TextPartLike): StoredTextMetadata {
|
|
12
|
+
return (part.metadata ?? {}) as StoredTextMetadata
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function extractStateFromMetadata(metadata: StoredTextMetadata | undefined): TranslateState | undefined {
|
|
16
|
+
if (!isTranslateStateRecord(metadata)) return undefined
|
|
17
|
+
return {
|
|
18
|
+
translate_enabled: true,
|
|
19
|
+
translate_source_lang: metadata.translate_source_lang,
|
|
20
|
+
translate_display_lang: metadata.translate_display_lang,
|
|
21
|
+
translate_llm_lang: LLM_LANGUAGE,
|
|
22
|
+
translate_nonce: metadata.translate_nonce,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function mergeTranslatedMetadata(
|
|
27
|
+
state: TranslateState,
|
|
28
|
+
part: TextPartLike,
|
|
29
|
+
english: string,
|
|
30
|
+
): Record<string, unknown> {
|
|
31
|
+
return {
|
|
32
|
+
...(part.metadata ?? {}),
|
|
33
|
+
...state,
|
|
34
|
+
translate_source_hash: hashText(part.text ?? ""),
|
|
35
|
+
translate_en: english,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isTranslatedUserDisplayPart(part: TextPartLike): boolean {
|
|
40
|
+
if (!isTextPart(part) || part.synthetic === true) return false
|
|
41
|
+
return extractStateFromMetadata(asMetadata(part)) !== undefined
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseTranslatorModel,
|
|
3
|
+
type ResolvedTranslateOptions,
|
|
4
|
+
SPEC_VERSION,
|
|
5
|
+
type TextPartLike,
|
|
6
|
+
type TranslateState,
|
|
7
|
+
} from "../constants"
|
|
8
|
+
import { createSyntheticPartID } from "../translator"
|
|
9
|
+
|
|
10
|
+
export function createActivationBannerText(options: ResolvedTranslateOptions): string {
|
|
11
|
+
const { modelID } = parseTranslatorModel(options.translatorModel)
|
|
12
|
+
return `✓ Translation mode enabled · translator: ${modelID} · source: ${options.sourceLanguage} · display: ${options.displayLanguage}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createLlmOnlyTextPart(
|
|
16
|
+
sessionID: string,
|
|
17
|
+
messageID: string,
|
|
18
|
+
text: string,
|
|
19
|
+
metadata: Record<string, unknown>,
|
|
20
|
+
): TextPartLike {
|
|
21
|
+
return {
|
|
22
|
+
id: createSyntheticPartID(),
|
|
23
|
+
sessionID,
|
|
24
|
+
messageID,
|
|
25
|
+
type: "text",
|
|
26
|
+
text,
|
|
27
|
+
synthetic: true,
|
|
28
|
+
ignored: false,
|
|
29
|
+
metadata,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createActivationBannerPart(
|
|
34
|
+
sessionID: string,
|
|
35
|
+
messageID: string,
|
|
36
|
+
state: TranslateState,
|
|
37
|
+
text: string,
|
|
38
|
+
): TextPartLike {
|
|
39
|
+
return {
|
|
40
|
+
id: createSyntheticPartID(),
|
|
41
|
+
sessionID,
|
|
42
|
+
messageID,
|
|
43
|
+
type: "text",
|
|
44
|
+
text,
|
|
45
|
+
synthetic: true,
|
|
46
|
+
ignored: true,
|
|
47
|
+
metadata: {
|
|
48
|
+
...state,
|
|
49
|
+
translate_role: "activation_banner",
|
|
50
|
+
translate_spec_version: SPEC_VERSION,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import { buildInboundTranslationError, LLM_LANGUAGE, normalizeReason } from "../constants"
|
|
3
|
+
import {
|
|
4
|
+
isQuestionArgs,
|
|
5
|
+
type QuestionSnapshot,
|
|
6
|
+
type QuestionToolOutput,
|
|
7
|
+
restoreQuestionOutput,
|
|
8
|
+
snapshotQuestions,
|
|
9
|
+
translateQuestionArgs,
|
|
10
|
+
} from "../question-tool"
|
|
11
|
+
import { logError } from "./logging"
|
|
12
|
+
import { resolveSessionState } from "./state"
|
|
13
|
+
import { type HookContext, QUESTION_TOOL_ID } from "./types"
|
|
14
|
+
|
|
15
|
+
const questionSnapshots = new Map<string, QuestionSnapshot>()
|
|
16
|
+
|
|
17
|
+
export function resetQuestionSnapshots() {
|
|
18
|
+
questionSnapshots.clear()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks["tool.execute.before"]> {
|
|
22
|
+
return async (input, output) => {
|
|
23
|
+
try {
|
|
24
|
+
if (input.tool !== QUESTION_TOOL_ID) return
|
|
25
|
+
const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
|
|
26
|
+
const activeState = resolved.state
|
|
27
|
+
if (!activeState) return
|
|
28
|
+
|
|
29
|
+
const args = output.args as unknown
|
|
30
|
+
if (!isQuestionArgs(args)) return
|
|
31
|
+
|
|
32
|
+
const original = snapshotQuestions(args)
|
|
33
|
+
if (activeState.translate_display_lang !== LLM_LANGUAGE) {
|
|
34
|
+
try {
|
|
35
|
+
await translateQuestionArgs(args, (text) =>
|
|
36
|
+
ctx.translator.translateText({
|
|
37
|
+
text,
|
|
38
|
+
sourceLanguage: LLM_LANGUAGE,
|
|
39
|
+
targetLanguage: activeState.translate_display_lang,
|
|
40
|
+
direction: "outbound",
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
args.questions.splice(0, args.questions.length, ...snapshotQuestions({ questions: original }))
|
|
45
|
+
await logError(ctx.client, error)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
questionSnapshots.set(input.callID, { original, translated: snapshotQuestions(args) })
|
|
51
|
+
} catch (error) {
|
|
52
|
+
await logError(ctx.client, error)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createToolExecuteAfterHook(ctx: HookContext): NonNullable<Hooks["tool.execute.after"]> {
|
|
58
|
+
return async (input, output) => {
|
|
59
|
+
try {
|
|
60
|
+
if (input.tool !== QUESTION_TOOL_ID) return
|
|
61
|
+
const snapshot = questionSnapshots.get(input.callID)
|
|
62
|
+
if (!snapshot) return
|
|
63
|
+
questionSnapshots.delete(input.callID)
|
|
64
|
+
|
|
65
|
+
const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
|
|
66
|
+
const activeState = resolved.state
|
|
67
|
+
const translateCustomAnswer =
|
|
68
|
+
activeState && activeState.translate_source_lang !== LLM_LANGUAGE
|
|
69
|
+
? (text: string) =>
|
|
70
|
+
ctx.translator.translateText({
|
|
71
|
+
text,
|
|
72
|
+
sourceLanguage: activeState.translate_source_lang,
|
|
73
|
+
targetLanguage: LLM_LANGUAGE,
|
|
74
|
+
direction: "inbound",
|
|
75
|
+
})
|
|
76
|
+
: undefined
|
|
77
|
+
|
|
78
|
+
await restoreQuestionOutput(output as QuestionToolOutput, snapshot, {
|
|
79
|
+
...(translateCustomAnswer ? { translateCustomAnswer } : {}),
|
|
80
|
+
onTranslationError: async (error) => {
|
|
81
|
+
if (!activeState) {
|
|
82
|
+
await logError(ctx.client, error)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
await logError(
|
|
86
|
+
ctx.client,
|
|
87
|
+
buildInboundTranslationError(activeState.translate_source_lang, normalizeReason(error)),
|
|
88
|
+
)
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
} catch (error) {
|
|
92
|
+
await logError(ctx.client, error)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto"
|
|
2
|
+
import {
|
|
3
|
+
isTextPart,
|
|
4
|
+
LLM_LANGUAGE,
|
|
5
|
+
type MessageWithPartsLike,
|
|
6
|
+
type PluginClientLike,
|
|
7
|
+
type ResolvedTranslateOptions,
|
|
8
|
+
type TranslateState,
|
|
9
|
+
unwrapData,
|
|
10
|
+
} from "../constants"
|
|
11
|
+
import { asMetadata, extractStateFromMetadata } from "./metadata"
|
|
12
|
+
import {
|
|
13
|
+
type CachedSessionState,
|
|
14
|
+
INACTIVE_CHILD_SESSION,
|
|
15
|
+
INACTIVE_ROOT_SESSION,
|
|
16
|
+
type ResolvedSessionState,
|
|
17
|
+
} from "./types"
|
|
18
|
+
|
|
19
|
+
const sessionStateCache = new Map<string, CachedSessionState>()
|
|
20
|
+
|
|
21
|
+
export function resetSessionStateCache() {
|
|
22
|
+
sessionStateCache.clear()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function cacheSessionState(sessionID: string, state: CachedSessionState) {
|
|
26
|
+
sessionStateCache.set(sessionID, state)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createState(options: ResolvedTranslateOptions): TranslateState {
|
|
30
|
+
return {
|
|
31
|
+
translate_enabled: true,
|
|
32
|
+
translate_source_lang: options.sourceLanguage,
|
|
33
|
+
translate_display_lang: options.displayLanguage,
|
|
34
|
+
translate_llm_lang: LLM_LANGUAGE,
|
|
35
|
+
translate_nonce: randomBytes(16).toString("hex"),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function extractStoredState(messages: MessageWithPartsLike[]): TranslateState | undefined {
|
|
40
|
+
let fallback: TranslateState | undefined
|
|
41
|
+
|
|
42
|
+
for (const message of messages) {
|
|
43
|
+
for (const part of message.parts) {
|
|
44
|
+
if (!isTextPart(part)) continue
|
|
45
|
+
const metadata = asMetadata(part)
|
|
46
|
+
const state = extractStateFromMetadata(metadata)
|
|
47
|
+
if (!state) continue
|
|
48
|
+
if (metadata.translate_role === "activation_banner") return state
|
|
49
|
+
if (message.info.role === "user" && part.synthetic !== true && fallback === undefined) fallback = state
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return fallback
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cachedStateResult(cached: CachedSessionState): ResolvedSessionState {
|
|
57
|
+
if (cached === INACTIVE_ROOT_SESSION) return { sessionActive: false, canActivate: true, storedMessages: [] }
|
|
58
|
+
if (cached === INACTIVE_CHILD_SESSION) return { sessionActive: false, canActivate: false, storedMessages: [] }
|
|
59
|
+
return { sessionActive: true, canActivate: false, state: cached, storedMessages: [] }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function resolveSessionState(
|
|
63
|
+
client: PluginClientLike,
|
|
64
|
+
directory: string | undefined,
|
|
65
|
+
sessionID: string,
|
|
66
|
+
): Promise<ResolvedSessionState> {
|
|
67
|
+
const cached = sessionStateCache.get(sessionID)
|
|
68
|
+
if (cached !== undefined) return cachedStateResult(cached)
|
|
69
|
+
|
|
70
|
+
const session = unwrapData(
|
|
71
|
+
await client.session.get({
|
|
72
|
+
path: { id: sessionID },
|
|
73
|
+
query: { ...(directory ? { directory } : {}) },
|
|
74
|
+
throwOnError: true,
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
if (session.parentID != null) {
|
|
78
|
+
sessionStateCache.set(sessionID, INACTIVE_CHILD_SESSION)
|
|
79
|
+
return { sessionActive: false, canActivate: false, storedMessages: [] }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const storedMessages = unwrapData(
|
|
83
|
+
await client.session.messages({
|
|
84
|
+
path: { id: sessionID },
|
|
85
|
+
query: { ...(directory ? { directory } : {}) },
|
|
86
|
+
throwOnError: true,
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
const state = extractStoredState(storedMessages)
|
|
90
|
+
sessionStateCache.set(sessionID, state ?? INACTIVE_ROOT_SESSION)
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
sessionActive: Boolean(state),
|
|
94
|
+
canActivate: !state,
|
|
95
|
+
state: state ?? undefined,
|
|
96
|
+
storedMessages,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import { LLM_LANGUAGE, type MessageWithPartsLike, unwrapData } from "../constants"
|
|
3
|
+
import { composeTranslatedAssistantText, composeTranslationFailureText } from "../formatting"
|
|
4
|
+
import { getDisplayLanguageLabel } from "../labels"
|
|
5
|
+
import { logError } from "./logging"
|
|
6
|
+
import { resolveSessionState } from "./state"
|
|
7
|
+
import type { HookContext } from "./types"
|
|
8
|
+
|
|
9
|
+
type TextCompleteHook = NonNullable<Hooks["experimental.text.complete"]>
|
|
10
|
+
|
|
11
|
+
export function createTextCompleteHook(ctx: HookContext): TextCompleteHook {
|
|
12
|
+
return async (input, output) => {
|
|
13
|
+
try {
|
|
14
|
+
const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
|
|
15
|
+
const activeState = resolved.state
|
|
16
|
+
if (!activeState) return
|
|
17
|
+
|
|
18
|
+
const message = unwrapData(
|
|
19
|
+
await ctx.client.session.message({
|
|
20
|
+
path: { id: input.sessionID, messageID: input.messageID },
|
|
21
|
+
query: { ...(ctx.directory ? { directory: ctx.directory } : {}) },
|
|
22
|
+
throwOnError: true,
|
|
23
|
+
}),
|
|
24
|
+
) as MessageWithPartsLike & { info: Record<string, unknown> }
|
|
25
|
+
|
|
26
|
+
if (message.info.role !== "assistant") return
|
|
27
|
+
if (message.info.summary === true) return
|
|
28
|
+
if (activeState.translate_display_lang === LLM_LANGUAGE || output.text.length === 0) return
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const translated = await ctx.translator.translateText({
|
|
32
|
+
text: output.text,
|
|
33
|
+
sourceLanguage: LLM_LANGUAGE,
|
|
34
|
+
targetLanguage: activeState.translate_display_lang,
|
|
35
|
+
direction: "outbound",
|
|
36
|
+
})
|
|
37
|
+
output.text = composeTranslatedAssistantText(
|
|
38
|
+
output.text,
|
|
39
|
+
getDisplayLanguageLabel(activeState.translate_display_lang),
|
|
40
|
+
translated,
|
|
41
|
+
activeState.translate_nonce,
|
|
42
|
+
)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
output.text = composeTranslationFailureText(output.text, activeState.translate_nonce)
|
|
45
|
+
await logError(ctx.client, error)
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
await logError(ctx.client, error)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|