opencode-input-translator 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Taeyeong Kim
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # opencode-input-translator
2
+
3
+ OpenCode plugin that automatically translates non-English user input to English before it reaches the AI model.
4
+
5
+ ## Installation
6
+
7
+ Add to your `opencode.json` or `opencode.jsonc`:
8
+
9
+ ```json
10
+ {
11
+ "plugin": ["opencode-input-translator@latest"]
12
+ }
13
+ ```
14
+
15
+ ## Configuration
16
+
17
+ Set these environment variables before running OpenCode:
18
+
19
+ | Variable | Required | Default | Description |
20
+ |----------|----------|---------|-------------|
21
+ | `TRANSLATOR_API_KEY` | Yes | — | API key for the OpenAI-compatible translation service |
22
+ | `TRANSLATOR_BASE_URL` | Yes | — | Base URL of the translation API (e.g. `https://api.openai.com`) |
23
+ | `TRANSLATOR_MODEL` | No | `gpt-5-nano-2025-08-07` | Model to use for translation |
24
+
25
+ ## How it works
26
+
27
+ 1. Intercepts outgoing user messages via OpenCode's `experimental.chat.messages.transform` hook
28
+ 2. Detects the language of each text part — skips messages already in English
29
+ 3. Extracts and preserves code blocks so they are never translated
30
+ 4. Translates the remaining prose to English using your configured model
31
+ 5. Replaces the original message text with the translated version
32
+
33
+ Messages that are already in English, empty, synthetic, or previously translated are left untouched.
34
+
35
+ ## Supported providers
36
+
37
+ Any OpenAI-compatible API works:
38
+
39
+ - OpenAI
40
+ - Ollama
41
+ - LM Studio
42
+ - Any other service with an OpenAI-compatible `/v1/chat/completions` endpoint
43
+
44
+ ## License
45
+
46
+ MIT
@@ -0,0 +1,7 @@
1
+ import { Plugin } from "@opencode-ai/plugin";
2
+
3
+ //#region src/index.d.ts
4
+ declare const InputTranslatorPlugin: Plugin;
5
+ //#endregion
6
+ export { InputTranslatorPlugin, InputTranslatorPlugin as default };
7
+ //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs ADDED
@@ -0,0 +1,133 @@
1
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
2
+ import { Output, generateText } from "ai";
3
+ import { z } from "zod";
4
+
5
+ //#region src/detector.ts
6
+ function isEnglish(text) {
7
+ if (!text || text.trim().length === 0) return true;
8
+ let prose = text.replace(/```[\s\S]*?```/g, " ");
9
+ prose = prose.replace(/`[^`]*`/g, " ");
10
+ let latinCount = 0;
11
+ let nonLatinCount = 0;
12
+ for (const ch of prose) {
13
+ const cp = ch.codePointAt(0);
14
+ if (cp === void 0) continue;
15
+ if (cp >= 65 && cp <= 90 || cp >= 97 && cp <= 122 || cp >= 192 && cp <= 591) latinCount++;
16
+ else if (cp >= 19968 && cp <= 40959 || cp >= 44032 && cp <= 55215 || cp >= 1024 && cp <= 1279 || cp >= 1536 && cp <= 1791 || cp >= 3584 && cp <= 3711 || cp >= 2304 && cp <= 2431) nonLatinCount++;
17
+ }
18
+ const total = latinCount + nonLatinCount;
19
+ if (total === 0) return true;
20
+ return latinCount / total >= .7;
21
+ }
22
+
23
+ //#endregion
24
+ //#region src/extractor.ts
25
+ function extractCodeBlocks(text) {
26
+ const blocks = [];
27
+ let index = 0;
28
+ let prose = text;
29
+ prose = prose.replace(/```[\s\S]*?```/g, (match) => {
30
+ const placeholder = `__CODE_BLOCK_${index++}__`;
31
+ blocks.push({
32
+ placeholder,
33
+ original: match
34
+ });
35
+ return placeholder;
36
+ });
37
+ prose = prose.replace(/`[^`]+`/g, (match) => {
38
+ const placeholder = `__CODE_BLOCK_${index++}__`;
39
+ blocks.push({
40
+ placeholder,
41
+ original: match
42
+ });
43
+ return placeholder;
44
+ });
45
+ return {
46
+ prose,
47
+ blocks
48
+ };
49
+ }
50
+ function restoreCodeBlocks(text, blocks) {
51
+ let result = text;
52
+ for (const block of blocks) result = result.replace(block.placeholder, block.original);
53
+ return result;
54
+ }
55
+
56
+ //#endregion
57
+ //#region src/translator.ts
58
+ const translationSchema = z.object({ translation: z.string().describe("The English translation of the input text") });
59
+ async function translateToEnglish(text, config) {
60
+ try {
61
+ const { output: result } = await generateText({
62
+ model: createOpenAICompatible({
63
+ name: "translator",
64
+ baseURL: `${config.baseUrl}/v1`,
65
+ apiKey: config.apiKey,
66
+ supportsStructuredOutputs: true
67
+ })(config.model),
68
+ output: Output.object({ schema: translationSchema }),
69
+ system: "Translate the following text to English. If the text is already in English, output it unchanged (as is).",
70
+ prompt: text,
71
+ maxRetries: 3,
72
+ abortSignal: AbortSignal.timeout(10 * 1e3)
73
+ });
74
+ if (!result) throw new Error("No content in response");
75
+ return {
76
+ translated: true,
77
+ text: result.translation
78
+ };
79
+ } catch (err) {
80
+ console.warn("[translateToEnglish] error:", err);
81
+ return {
82
+ translated: false,
83
+ text
84
+ };
85
+ }
86
+ }
87
+
88
+ //#endregion
89
+ //#region src/index.ts
90
+ const InputTranslatorPlugin = async ({ client }) => {
91
+ const apiKey = process.env.TRANSLATOR_API_KEY;
92
+ const baseUrl = process.env.TRANSLATOR_BASE_URL;
93
+ const model = process.env.TRANSLATOR_MODEL ?? "gpt-5-nano-2025-08-07";
94
+ if (!apiKey || !baseUrl) {
95
+ await client.app.log({ body: {
96
+ service: "input-translator",
97
+ level: "warn",
98
+ message: "Missing TRANSLATOR_API_KEY or TRANSLATOR_BASE_URL. Input Translator Plugin is disabled."
99
+ } });
100
+ return {};
101
+ }
102
+ const config = {
103
+ apiKey,
104
+ baseUrl,
105
+ model
106
+ };
107
+ return { "experimental.chat.messages.transform": async (_input, output) => {
108
+ for (const message of output.messages) {
109
+ if (message.info.role !== "user") continue;
110
+ for (const part of message.parts) {
111
+ if (part.type !== "text") continue;
112
+ if (part.text.trim().length === 0) continue;
113
+ if (part.synthetic === true || part.ignored === true) continue;
114
+ if (part.metadata?.__translated === true) continue;
115
+ if (isEnglish(part.text)) continue;
116
+ const { prose, blocks } = extractCodeBlocks(part.text);
117
+ if (prose.trim().length === 0) continue;
118
+ const result = await translateToEnglish(prose, config);
119
+ if (result.translated) {
120
+ part.text = restoreCodeBlocks(result.text, blocks);
121
+ part.metadata = {
122
+ ...part.metadata,
123
+ __translated: true
124
+ };
125
+ }
126
+ }
127
+ }
128
+ } };
129
+ };
130
+
131
+ //#endregion
132
+ export { InputTranslatorPlugin, InputTranslatorPlugin as default };
133
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/detector.ts","../src/extractor.ts","../src/translator.ts","../src/index.ts"],"sourcesContent":["export function isEnglish(text: string): boolean {\n if (!text || text.trim().length === 0) return true;\n\n let prose = text.replace(/```[\\s\\S]*?```/g, ' ');\n prose = prose.replace(/`[^`]*`/g, ' ');\n\n let latinCount = 0;\n let nonLatinCount = 0;\n\n for (const ch of prose) {\n const cp = ch.codePointAt(0);\n if (cp === undefined) continue;\n\n if (\n (cp >= 0x0041 && cp <= 0x005a) || // A-Z\n (cp >= 0x0061 && cp <= 0x007a) || // a-z\n (cp >= 0x00c0 && cp <= 0x024f) // Latin Extended\n ) {\n latinCount++;\n } else if (\n (cp >= 0x4e00 && cp <= 0x9fff) || // CJK\n (cp >= 0xac00 && cp <= 0xd7af) || // Hangul\n (cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic\n (cp >= 0x0600 && cp <= 0x06ff) || // Arabic\n (cp >= 0x0e00 && cp <= 0x0e7f) || // Thai\n (cp >= 0x0900 && cp <= 0x097f) // Devanagari\n ) {\n nonLatinCount++;\n }\n }\n\n const total = latinCount + nonLatinCount;\n if (total === 0) return true;\n\n return latinCount / total >= 0.7;\n}\n","import type { CodeBlock } from './types.ts';\n\nexport function extractCodeBlocks(text: string): {\n prose: string;\n blocks: CodeBlock[];\n} {\n const blocks: CodeBlock[] = [];\n let index = 0;\n let prose = text;\n\n // 1. Extract fenced code blocks (``` ... ```) first\n prose = prose.replace(/```[\\s\\S]*?```/g, (match) => {\n const placeholder = `__CODE_BLOCK_${index++}__`;\n blocks.push({ placeholder, original: match });\n return placeholder;\n });\n\n // 2. Extract inline code (` ... `)\n prose = prose.replace(/`[^`]+`/g, (match) => {\n const placeholder = `__CODE_BLOCK_${index++}__`;\n blocks.push({ placeholder, original: match });\n return placeholder;\n });\n\n return { prose, blocks };\n}\n\nexport function restoreCodeBlocks(text: string, blocks: CodeBlock[]): string {\n let result = text;\n for (const block of blocks) {\n result = result.replace(block.placeholder, block.original);\n }\n return result;\n}\n","import { createOpenAICompatible } from '@ai-sdk/openai-compatible';\nimport { generateText, Output } from 'ai';\nimport { z } from 'zod';\nimport type { TranslationResult, TranslatorConfig } from './types.ts';\n\nconst translationSchema = z.object({\n translation: z.string().describe('The English translation of the input text'),\n});\n\nexport async function translateToEnglish(\n text: string,\n config: TranslatorConfig,\n): Promise<TranslationResult> {\n try {\n const provider = createOpenAICompatible({\n name: 'translator',\n baseURL: `${config.baseUrl}/v1`,\n apiKey: config.apiKey,\n supportsStructuredOutputs: true,\n });\n\n const { output: result } = await generateText({\n model: provider(config.model),\n output: Output.object({\n schema: translationSchema,\n }),\n system:\n 'Translate the following text to English. If the text is already in English, output it unchanged (as is).',\n prompt: text,\n maxRetries: 3,\n abortSignal: AbortSignal.timeout(10 * 1000),\n });\n\n if (!result) throw new Error('No content in response');\n\n return { translated: true, text: result.translation };\n } catch (err) {\n console.warn('[translateToEnglish] error:', err);\n return { translated: false, text };\n }\n}\n","import type { Plugin } from '@opencode-ai/plugin';\nimport { isEnglish } from './detector.ts';\nimport { extractCodeBlocks, restoreCodeBlocks } from './extractor.ts';\nimport { translateToEnglish } from './translator.ts';\nimport type { TranslatorConfig } from './types.ts';\n\nexport const InputTranslatorPlugin: Plugin = async ({ client }) => {\n const apiKey = process.env.TRANSLATOR_API_KEY;\n const baseUrl = process.env.TRANSLATOR_BASE_URL;\n const model = process.env.TRANSLATOR_MODEL ?? 'gpt-5-nano-2025-08-07';\n\n if (!apiKey || !baseUrl) {\n await client.app.log({\n body: {\n service: 'input-translator',\n level: 'warn',\n message:\n 'Missing TRANSLATOR_API_KEY or TRANSLATOR_BASE_URL. Input Translator Plugin is disabled.',\n },\n });\n return {};\n }\n\n const config: TranslatorConfig = { apiKey, baseUrl, model };\n\n return {\n 'experimental.chat.messages.transform': async (_input, output) => {\n for (const message of output.messages) {\n if (message.info.role !== 'user') continue;\n\n for (const part of message.parts) {\n if (part.type !== 'text') continue;\n if (part.text.trim().length === 0) continue;\n if (part.synthetic === true || part.ignored === true) continue;\n if (part.metadata?.__translated === true) continue;\n if (isEnglish(part.text)) continue;\n\n const { prose, blocks } = extractCodeBlocks(part.text);\n if (prose.trim().length === 0) continue;\n\n const result = await translateToEnglish(prose, config);\n if (result.translated) {\n part.text = restoreCodeBlocks(result.text, blocks);\n part.metadata = { ...part.metadata, __translated: true };\n }\n }\n }\n },\n };\n};\n\nexport default InputTranslatorPlugin;\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "opencode-input-translator",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin that translates non-English user input to English",
5
+ "type": "module",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": "./dist/index.mjs",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "check": "biome check",
16
+ "check:fix": "biome check --fix",
17
+ "typecheck": "tsc --noEmit",
18
+ "build": "bun run check && tsdown",
19
+ "local-deploy": "bun run build && cp dist/index.mjs ~/.opencode/plugins/opencode-input-translator.mjs",
20
+ "prepublishOnly": "bun run build"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/tyeongkim/opencode-input-translator"
25
+ },
26
+ "keywords": [
27
+ "opencode",
28
+ "plugin",
29
+ "translator",
30
+ "i18n"
31
+ ],
32
+ "license": "MIT",
33
+ "peerDependencies": {
34
+ "typescript": "^5",
35
+ "@opencode-ai/plugin": ">=1.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "^2.4.2",
39
+ "@opencode-ai/plugin": "^1.2.6",
40
+ "@types/bun": "latest",
41
+ "tsdown": "^0.20.3"
42
+ },
43
+ "dependencies": {
44
+ "@ai-sdk/openai-compatible": "^2.0.30",
45
+ "ai": "^6.0.92",
46
+ "zod": "^4.3.6"
47
+ }
48
+ }