opencode-translate 1.0.5 → 1.0.7
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/dist/index.js +2271 -0
- package/index.d.ts +5 -0
- package/package.json +10 -4
- package/src/activation/chat-message.ts +0 -164
- package/src/activation/index.ts +0 -38
- package/src/activation/logging.ts +0 -11
- package/src/activation/messages-transform.ts +0 -44
- package/src/activation/metadata.ts +0 -41
- package/src/activation/parts.ts +0 -46
- package/src/activation/question-hooks.ts +0 -126
- package/src/activation/state.ts +0 -97
- package/src/activation/text-complete.ts +0 -50
- package/src/activation/trigger.ts +0 -57
- package/src/activation/types.ts +0 -47
- package/src/activation.ts +0 -1
- package/src/anthropic-oauth.ts +0 -148
- package/src/auth/codex-request.ts +0 -108
- package/src/auth/codex-response.ts +0 -78
- package/src/auth/codex-shared.ts +0 -3
- package/src/auth/headers.ts +0 -18
- package/src/auth/index.ts +0 -177
- package/src/auth/oauth-fetch.ts +0 -100
- package/src/auth/refresh.ts +0 -102
- package/src/auth/retry.ts +0 -70
- package/src/auth/store.ts +0 -98
- package/src/auth/types.ts +0 -27
- package/src/auth.ts +0 -1
- package/src/constants/errors.ts +0 -24
- package/src/constants/guards.ts +0 -33
- package/src/constants/options.ts +0 -55
- package/src/constants/plugin.ts +0 -9
- package/src/constants/types.ts +0 -159
- package/src/constants.ts +0 -5
- package/src/formatting.ts +0 -157
- package/src/index.ts +0 -7
- package/src/labels.ts +0 -3
- package/src/prompts.ts +0 -123
- package/src/question-tool.ts +0 -234
- package/src/translator/index.ts +0 -172
- package/src/translator/part-id.ts +0 -43
- package/src/translator/provider.ts +0 -411
- package/src/translator/retry.ts +0 -62
- package/src/translator/types.ts +0 -24
- package/src/translator.ts +0 -1
package/index.d.ts
ADDED
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-translate",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "OpenCode plugin that lets the user chat in a configured language while the main chat loop only sees English.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
7
8
|
"license": "MIT",
|
|
8
9
|
"repository": {
|
|
9
10
|
"type": "git",
|
|
@@ -21,7 +22,8 @@
|
|
|
21
22
|
"i18n"
|
|
22
23
|
],
|
|
23
24
|
"files": [
|
|
24
|
-
"
|
|
25
|
+
"dist",
|
|
26
|
+
"index.d.ts",
|
|
25
27
|
"LICENSE",
|
|
26
28
|
"README.md"
|
|
27
29
|
],
|
|
@@ -29,6 +31,7 @@
|
|
|
29
31
|
"access": "public"
|
|
30
32
|
},
|
|
31
33
|
"scripts": {
|
|
34
|
+
"build": "bun build src/index.ts --outfile dist/index.js --target node --format esm --packages external",
|
|
32
35
|
"typecheck": "tsgo --noEmit",
|
|
33
36
|
"format": "biome format --write .",
|
|
34
37
|
"format:check": "biome format .",
|
|
@@ -38,7 +41,10 @@
|
|
|
38
41
|
"check": "biome check --write .",
|
|
39
42
|
"check:ci": "biome check .",
|
|
40
43
|
"test": "bun test",
|
|
41
|
-
"test:coverage": "bun test --coverage"
|
|
44
|
+
"test:coverage": "bun test --coverage",
|
|
45
|
+
"test:package": "node scripts/package-smoke.mjs",
|
|
46
|
+
"prepare": "bun run build",
|
|
47
|
+
"prepack": "bun run build"
|
|
42
48
|
},
|
|
43
49
|
"peerDependencies": {
|
|
44
50
|
"@opencode-ai/plugin": ">=1.14.0"
|
|
@@ -1,164 +0,0 @@
|
|
|
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.trigger)
|
|
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_user_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_user_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
|
-
}
|
package/src/activation/index.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
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 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
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
-
import { isTextPart, type MessageWithPartsLike } from "../constants"
|
|
3
|
-
import { extractEnglishHistoryText } from "../formatting"
|
|
4
|
-
import { getDisplayLanguageLabel } from "../labels"
|
|
5
|
-
import { logError } from "./logging"
|
|
6
|
-
import { isTranslatedUserDisplayPart } from "./metadata"
|
|
7
|
-
import { resolveSessionState } from "./state"
|
|
8
|
-
import type { HookContext } from "./types"
|
|
9
|
-
|
|
10
|
-
type MessagesTransformHook = NonNullable<Hooks["experimental.chat.messages.transform"]>
|
|
11
|
-
|
|
12
|
-
export function createMessagesTransformHook(ctx: HookContext): MessagesTransformHook {
|
|
13
|
-
return async (_input, output) => {
|
|
14
|
-
try {
|
|
15
|
-
const sessionID = output.messages[0]?.info.sessionID
|
|
16
|
-
if (!sessionID) return
|
|
17
|
-
|
|
18
|
-
const resolved = await resolveSessionState(ctx.client, ctx.directory, sessionID)
|
|
19
|
-
const activeState = resolved.state
|
|
20
|
-
if (!activeState) return
|
|
21
|
-
|
|
22
|
-
const extractContext = {
|
|
23
|
-
nonce: activeState.translate_nonce,
|
|
24
|
-
label: getDisplayLanguageLabel(activeState.translate_user_lang),
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
for (const message of output.messages as MessageWithPartsLike[]) {
|
|
28
|
-
if (message.info.role === "user") {
|
|
29
|
-
for (const part of message.parts) {
|
|
30
|
-
if (isTranslatedUserDisplayPart(part)) part.ignored = true
|
|
31
|
-
}
|
|
32
|
-
continue
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (message.info.role !== "assistant") continue
|
|
36
|
-
for (const part of message.parts) {
|
|
37
|
-
if (isTextPart(part)) part.text = extractEnglishHistoryText(part.text, extractContext)
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
} catch (error) {
|
|
41
|
-
await logError(ctx.client, error)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
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_user_lang: metadata.translate_user_lang,
|
|
20
|
-
translate_llm_lang: LLM_LANGUAGE,
|
|
21
|
-
translate_nonce: metadata.translate_nonce,
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function mergeTranslatedMetadata(
|
|
26
|
-
state: TranslateState,
|
|
27
|
-
part: TextPartLike,
|
|
28
|
-
english: string,
|
|
29
|
-
): Record<string, unknown> {
|
|
30
|
-
return {
|
|
31
|
-
...(part.metadata ?? {}),
|
|
32
|
-
...state,
|
|
33
|
-
translate_source_hash: hashText(part.text ?? ""),
|
|
34
|
-
translate_en: english,
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function isTranslatedUserDisplayPart(part: TextPartLike): boolean {
|
|
39
|
-
if (!isTextPart(part) || part.synthetic === true) return false
|
|
40
|
-
return extractStateFromMetadata(asMetadata(part)) !== undefined
|
|
41
|
-
}
|
package/src/activation/parts.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { type ResolvedTranslateOptions, SPEC_VERSION, type TextPartLike, type TranslateState } from "../constants"
|
|
2
|
-
import { createSyntheticPartID } from "../translator"
|
|
3
|
-
|
|
4
|
-
export function createActivationBannerText(options: ResolvedTranslateOptions): string {
|
|
5
|
-
return `✓ Translation mode enabled · model: ${options.model} · language: ${options.lang}`
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function createLlmOnlyTextPart(
|
|
9
|
-
sessionID: string,
|
|
10
|
-
messageID: string,
|
|
11
|
-
text: string,
|
|
12
|
-
metadata: Record<string, unknown>,
|
|
13
|
-
): TextPartLike {
|
|
14
|
-
return {
|
|
15
|
-
id: createSyntheticPartID(),
|
|
16
|
-
sessionID,
|
|
17
|
-
messageID,
|
|
18
|
-
type: "text",
|
|
19
|
-
text,
|
|
20
|
-
synthetic: true,
|
|
21
|
-
ignored: false,
|
|
22
|
-
metadata,
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function createActivationBannerPart(
|
|
27
|
-
sessionID: string,
|
|
28
|
-
messageID: string,
|
|
29
|
-
state: TranslateState,
|
|
30
|
-
text: string,
|
|
31
|
-
): TextPartLike {
|
|
32
|
-
return {
|
|
33
|
-
id: createSyntheticPartID(),
|
|
34
|
-
sessionID,
|
|
35
|
-
messageID,
|
|
36
|
-
type: "text",
|
|
37
|
-
text,
|
|
38
|
-
synthetic: true,
|
|
39
|
-
ignored: true,
|
|
40
|
-
metadata: {
|
|
41
|
-
...state,
|
|
42
|
-
translate_role: "activation_banner",
|
|
43
|
-
translate_spec_version: SPEC_VERSION,
|
|
44
|
-
},
|
|
45
|
-
}
|
|
46
|
-
}
|
|
@@ -1,126 +0,0 @@
|
|
|
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
|
-
restoreQuestionArgs,
|
|
8
|
-
restoreQuestionOutput,
|
|
9
|
-
snapshotQuestions,
|
|
10
|
-
translateQuestionArgs,
|
|
11
|
-
} from "../question-tool"
|
|
12
|
-
import { logError } from "./logging"
|
|
13
|
-
import { resolveSessionState } from "./state"
|
|
14
|
-
import { type HookContext, QUESTION_TOOL_ID } from "./types"
|
|
15
|
-
|
|
16
|
-
const QUESTION_SNAPSHOT_LIMIT = 1_000
|
|
17
|
-
|
|
18
|
-
const questionSnapshots = new Map<string, QuestionSnapshot>()
|
|
19
|
-
|
|
20
|
-
function pruneQuestionSnapshots() {
|
|
21
|
-
while (questionSnapshots.size > QUESTION_SNAPSHOT_LIMIT) {
|
|
22
|
-
for (const callID of questionSnapshots.keys()) {
|
|
23
|
-
questionSnapshots.delete(callID)
|
|
24
|
-
break
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function resetQuestionSnapshots() {
|
|
30
|
-
questionSnapshots.clear()
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks["tool.execute.before"]> {
|
|
34
|
-
return async (input, output) => {
|
|
35
|
-
try {
|
|
36
|
-
if (input.tool !== QUESTION_TOOL_ID) return
|
|
37
|
-
const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
|
|
38
|
-
const activeState = resolved.state
|
|
39
|
-
if (!activeState) return
|
|
40
|
-
|
|
41
|
-
if (!isQuestionArgs(output.args)) return
|
|
42
|
-
const args = output.args
|
|
43
|
-
|
|
44
|
-
const original = snapshotQuestions(args)
|
|
45
|
-
if (activeState.translate_user_lang !== LLM_LANGUAGE) {
|
|
46
|
-
try {
|
|
47
|
-
await translateQuestionArgs(args, (texts) =>
|
|
48
|
-
ctx.translator.translateTexts
|
|
49
|
-
? ctx.translator.translateTexts({
|
|
50
|
-
texts,
|
|
51
|
-
sourceLanguage: LLM_LANGUAGE,
|
|
52
|
-
targetLanguage: activeState.translate_user_lang,
|
|
53
|
-
direction: "outbound",
|
|
54
|
-
})
|
|
55
|
-
: Promise.all(
|
|
56
|
-
texts.map((text) =>
|
|
57
|
-
ctx.translator.translateText({
|
|
58
|
-
text,
|
|
59
|
-
sourceLanguage: LLM_LANGUAGE,
|
|
60
|
-
targetLanguage: activeState.translate_user_lang,
|
|
61
|
-
direction: "outbound",
|
|
62
|
-
}),
|
|
63
|
-
),
|
|
64
|
-
),
|
|
65
|
-
)
|
|
66
|
-
} catch (error) {
|
|
67
|
-
args.questions.splice(0, args.questions.length, ...snapshotQuestions({ questions: original }))
|
|
68
|
-
await logError(ctx.client, error)
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
questionSnapshots.set(input.callID, {
|
|
74
|
-
original,
|
|
75
|
-
translated: snapshotQuestions(args),
|
|
76
|
-
userLanguage: activeState.translate_user_lang,
|
|
77
|
-
})
|
|
78
|
-
pruneQuestionSnapshots()
|
|
79
|
-
} catch (error) {
|
|
80
|
-
await logError(ctx.client, error)
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function createToolExecuteAfterHook(ctx: HookContext): NonNullable<Hooks["tool.execute.after"]> {
|
|
86
|
-
return async (input, output) => {
|
|
87
|
-
try {
|
|
88
|
-
if (input.tool !== QUESTION_TOOL_ID) return
|
|
89
|
-
const snapshot = questionSnapshots.get(input.callID)
|
|
90
|
-
if (!snapshot) return
|
|
91
|
-
questionSnapshots.delete(input.callID)
|
|
92
|
-
if (isQuestionArgs(input.args)) restoreQuestionArgs(input.args, snapshot.original)
|
|
93
|
-
|
|
94
|
-
if (snapshot.userLanguage === LLM_LANGUAGE) {
|
|
95
|
-
await restoreQuestionOutput(output as QuestionToolOutput, snapshot)
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
await restoreQuestionOutput(output as QuestionToolOutput, snapshot, {
|
|
100
|
-
translateCustomAnswers: (texts: readonly string[]) =>
|
|
101
|
-
ctx.translator.translateTexts
|
|
102
|
-
? ctx.translator.translateTexts({
|
|
103
|
-
texts,
|
|
104
|
-
sourceLanguage: snapshot.userLanguage,
|
|
105
|
-
targetLanguage: LLM_LANGUAGE,
|
|
106
|
-
direction: "inbound",
|
|
107
|
-
})
|
|
108
|
-
: Promise.all(
|
|
109
|
-
texts.map((text) =>
|
|
110
|
-
ctx.translator.translateText({
|
|
111
|
-
text,
|
|
112
|
-
sourceLanguage: snapshot.userLanguage,
|
|
113
|
-
targetLanguage: LLM_LANGUAGE,
|
|
114
|
-
direction: "inbound",
|
|
115
|
-
}),
|
|
116
|
-
),
|
|
117
|
-
),
|
|
118
|
-
onTranslationError: async (error) => {
|
|
119
|
-
await logError(ctx.client, buildInboundTranslationError(snapshot.userLanguage, normalizeReason(error)))
|
|
120
|
-
},
|
|
121
|
-
})
|
|
122
|
-
} catch (error) {
|
|
123
|
-
await logError(ctx.client, error)
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
package/src/activation/state.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
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_user_lang: options.lang,
|
|
33
|
-
translate_llm_lang: LLM_LANGUAGE,
|
|
34
|
-
translate_nonce: randomBytes(16).toString("hex"),
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function extractStoredState(messages: MessageWithPartsLike[]): TranslateState | undefined {
|
|
39
|
-
let fallback: TranslateState | undefined
|
|
40
|
-
|
|
41
|
-
for (const message of messages) {
|
|
42
|
-
for (const part of message.parts) {
|
|
43
|
-
if (!isTextPart(part)) continue
|
|
44
|
-
const metadata = asMetadata(part)
|
|
45
|
-
const state = extractStateFromMetadata(metadata)
|
|
46
|
-
if (!state) continue
|
|
47
|
-
if (metadata.translate_role === "activation_banner") return state
|
|
48
|
-
if (message.info.role === "user" && part.synthetic !== true && fallback === undefined) fallback = state
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return fallback
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function cachedStateResult(cached: CachedSessionState): ResolvedSessionState {
|
|
56
|
-
if (cached === INACTIVE_ROOT_SESSION) return { sessionActive: false, canActivate: true, storedMessages: [] }
|
|
57
|
-
if (cached === INACTIVE_CHILD_SESSION) return { sessionActive: false, canActivate: false, storedMessages: [] }
|
|
58
|
-
return { sessionActive: true, canActivate: false, state: cached, storedMessages: [] }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export async function resolveSessionState(
|
|
62
|
-
client: PluginClientLike,
|
|
63
|
-
directory: string | undefined,
|
|
64
|
-
sessionID: string,
|
|
65
|
-
): Promise<ResolvedSessionState> {
|
|
66
|
-
const cached = sessionStateCache.get(sessionID)
|
|
67
|
-
if (cached !== undefined) return cachedStateResult(cached)
|
|
68
|
-
|
|
69
|
-
const session = unwrapData(
|
|
70
|
-
await client.session.get({
|
|
71
|
-
path: { id: sessionID },
|
|
72
|
-
query: { ...(directory ? { directory } : {}) },
|
|
73
|
-
throwOnError: true,
|
|
74
|
-
}),
|
|
75
|
-
)
|
|
76
|
-
if (session.parentID != null) {
|
|
77
|
-
sessionStateCache.set(sessionID, INACTIVE_CHILD_SESSION)
|
|
78
|
-
return { sessionActive: false, canActivate: false, storedMessages: [] }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const storedMessages = unwrapData(
|
|
82
|
-
await client.session.messages({
|
|
83
|
-
path: { id: sessionID },
|
|
84
|
-
query: { ...(directory ? { directory } : {}) },
|
|
85
|
-
throwOnError: true,
|
|
86
|
-
}),
|
|
87
|
-
)
|
|
88
|
-
const state = extractStoredState(storedMessages)
|
|
89
|
-
sessionStateCache.set(sessionID, state ?? INACTIVE_ROOT_SESSION)
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
sessionActive: Boolean(state),
|
|
93
|
-
canActivate: !state,
|
|
94
|
-
state: state ?? undefined,
|
|
95
|
-
storedMessages,
|
|
96
|
-
}
|
|
97
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
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_user_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_user_lang,
|
|
35
|
-
direction: "outbound",
|
|
36
|
-
})
|
|
37
|
-
output.text = composeTranslatedAssistantText(
|
|
38
|
-
output.text,
|
|
39
|
-
getDisplayLanguageLabel(activeState.translate_user_lang),
|
|
40
|
-
translated,
|
|
41
|
-
)
|
|
42
|
-
} catch (error) {
|
|
43
|
-
output.text = composeTranslationFailureText(output.text)
|
|
44
|
-
await logError(ctx.client, error)
|
|
45
|
-
}
|
|
46
|
-
} catch (error) {
|
|
47
|
-
await logError(ctx.client, error)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|