opencode-translate 2.0.0 → 2.0.2
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/README.md +68 -3
- package/dist/index.js +75 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,22 +64,76 @@ $en 프로젝트 루트의 package.json을 읽고 요약해줘
|
|
|
64
64
|
All subsequent messages in the same session are translated automatically — no need to repeat `$en`.
|
|
65
65
|
|
|
66
66
|
- Your original message and its English translation remain visible in the transcript.
|
|
67
|
+
- The first successful `$en` prompt includes a `Translation enabled:` confirmation with the language and translator model.
|
|
67
68
|
- English assistant text streams normally; a translated Markdown section is appended when the text segment completes.
|
|
68
69
|
- Both the **terminal and web UI** display the same persisted bilingual assistant text. No UI-specific plugin is needed.
|
|
69
70
|
- Question forms are translated, with selected labels and custom answers converted back to English for the model.
|
|
70
71
|
- Translation activates only in root sessions. Its state survives plugin/server restarts.
|
|
71
72
|
|
|
72
73
|
Before model requests, the plugin removes its recorded display translations from context. It does not remove arbitrary
|
|
73
|
-
Markdown based on its appearance. On inbound translation failure, the
|
|
74
|
-
|
|
74
|
+
Markdown based on its appearance. On inbound translation failure, the transcript shows `Translation failed:` and the
|
|
75
|
+
error reason. The main model receives the original text without `$en` or the diagnostic; failed first-time activation
|
|
76
|
+
remains inactive, so retry with `$en` after fixing the error. On outbound failure, the English response is retained with
|
|
77
|
+
a translation-unavailable notice.
|
|
78
|
+
|
|
79
|
+
### If `$en` has no effect
|
|
80
|
+
|
|
81
|
+
`$en` is a plugin control keyword, not an instruction for the main model. If the model comments on `$en`, inbound
|
|
82
|
+
translation did not complete. In version 2.0.0, an inbound failure was only logged on the server and left `$en` in the
|
|
83
|
+
prompt, making it indistinguishable from an unloaded plugin in the transcript.
|
|
84
|
+
|
|
85
|
+
Check the active server's plugin list and configuration, including the selected project location. It must load the v2
|
|
86
|
+
package, not an old pinned `opencode-translate@1.x`. Also check the configured **translation** model and variant; changing
|
|
87
|
+
the main-chat model does not change the translator. An API-key login or OAuth connection must exist on that server.
|
|
88
|
+
The server log message `[opencode-translate] inbound translation failed` contains the underlying generation error.
|
|
89
|
+
|
|
90
|
+
OpenCode 2.0.3 also normalizes the legacy `"plugin": [["package", { ...options }]]` tuple syntax; that syntax alone
|
|
91
|
+
does not prevent this plugin from loading. The `plugins` object form shown above is the recommended v2 format.
|
|
92
|
+
Make sure the configuration is a complete JSON/JSONC object, including its opening `{`.
|
|
93
|
+
|
|
94
|
+
If there is neither an activation confirmation nor a failure notice, inspect the running server's plugin state for the
|
|
95
|
+
same directory as the session:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
opencode api v2.plugin.awaitActivation --param 'location[directory]=/absolute/path/to/project'
|
|
99
|
+
opencode api v2.plugin.list --param 'location[directory]=/absolute/path/to/project'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Find `opencode-translate`, check `source.version` and `state.status`, and inspect `state.error` if setup failed. If the UI
|
|
103
|
+
uses a remote or explicitly selected server, pass `--server` with that same server URL. A client version or a globally
|
|
104
|
+
installed npm version alone does not establish what the session's server has loaded.
|
|
75
105
|
|
|
76
106
|
## Authentication
|
|
77
107
|
|
|
78
108
|
Connect the translation model's provider in **OpenCode itself**. Translation uses the public `ctx.generate.text()` API,
|
|
79
|
-
|
|
109
|
+
with a `ctx.session.generate()` fallback for providers that require an OpenCode session. OpenCode owns provider
|
|
110
|
+
selection, model variants, API keys, SQLite credentials, and OAuth refresh/persistence.
|
|
80
111
|
The plugin does not read `auth.json`/`auth-v2.json`, query the credential database, or maintain separate tokens.
|
|
81
112
|
Provider and OAuth support for the translation model is the support available in your OpenCode installation.
|
|
82
113
|
|
|
114
|
+
Verified on OpenCode 2.0.3 using the host's saved credentials:
|
|
115
|
+
|
|
116
|
+
| Translation model | Result |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| `openai/gpt-5.6-luna` | Inbound, outbound, and follow-up translation passed through stateless generation with ChatGPT OAuth. |
|
|
119
|
+
| `opencode/muse-spark-1.3-contributor-free` | Passed through the session-aware fallback described below. |
|
|
120
|
+
| `anthropic/claude-sonnet-5` with `@henadev/opencode-anthropic-auth@0.2.0` | Works as the main chat model, but not as the translator in this release: its auth plugin depends on session HTTP hooks that stateless generation skips. |
|
|
121
|
+
|
|
122
|
+
Anthropic translation succeeded in a session-aware experiment, but the automatic fallback in this release is limited
|
|
123
|
+
to the explicit OpenCode free-tier rejection. It does not retry generic authentication errors through another path.
|
|
124
|
+
|
|
125
|
+
### OpenCode free-tier translation models
|
|
126
|
+
|
|
127
|
+
OpenCode 2.0.3 can reject stateless generation for free-tier models with `OpenCode's free tier can only be used in
|
|
128
|
+
OpenCode.` This was reproduced with `opencode/muse-spark-1.3-contributor-free`: normal session generation succeeds,
|
|
129
|
+
but `ctx.generate.text()` lacks the session request metadata accepted by that provider.
|
|
130
|
+
|
|
131
|
+
On this specific rejection, the plugin switches to public session-aware generation using a reusable **Translation
|
|
132
|
+
helper** session for the configured model and location. The helper may appear in the session list. Translation prompts
|
|
133
|
+
are transient: they do not append messages to either the helper or your chat, they cannot execute tools, and each
|
|
134
|
+
generation receives only the current translation prompt plus OpenCode's system instructions. The helper ID survives
|
|
135
|
+
plugin/server restarts. Other authentication or model-selection failures still report their original error.
|
|
136
|
+
|
|
83
137
|
## Inline reply support
|
|
84
138
|
|
|
85
139
|
V2 has no `experimental.text.complete` hook or public display-only message append operation. Inline translations use
|
|
@@ -114,11 +168,22 @@ bun run test:package
|
|
|
114
168
|
|
|
115
169
|
# Requires Node 24 and an OpenCode v2 binary; uses an isolated server and fake provider.
|
|
116
170
|
OPENCODE_BINARY=/path/to/opencode bun run test:host
|
|
171
|
+
|
|
172
|
+
# Exercise a registry-installed package through OpenCode's actual package loader.
|
|
173
|
+
OPENCODE_BINARY=/path/to/opencode OPENCODE_TRANSLATE_PACKAGE=opencode-translate@latest bun run test:host
|
|
174
|
+
|
|
175
|
+
# Verify OpenCode's normalization of legacy plugin tuples as well.
|
|
176
|
+
OPENCODE_BINARY=/path/to/opencode OPENCODE_TRANSLATE_LEGACY_CONFIG=1 bun run test:host
|
|
177
|
+
|
|
178
|
+
# Verify providers that support ordinary stateless generation.
|
|
179
|
+
OPENCODE_BINARY=/path/to/opencode OPENCODE_TRANSLATE_REQUIRE_SESSION=0 bun run test:host
|
|
117
180
|
```
|
|
118
181
|
|
|
119
182
|
Tests include OpenCode's actual native protocol parsers. The real-host smoke test loads the built plugin, creates and
|
|
120
183
|
rotates a test credential in an isolated SQLite database, checks bilingual persisted messages and English-only model
|
|
121
184
|
requests, and restarts the server to verify recovery. It does not use your live server, credentials, or paid models.
|
|
185
|
+
CI covers both configuration formats and both generation paths. The publish workflow tests the candidate before
|
|
186
|
+
publishing, then installs the exact version from npm in OpenCode before creating its GitHub release.
|
|
122
187
|
|
|
123
188
|
The old `opencode2 v0.0.0-dev-18322` binary does not pass this migration's host smoke test. Use the verified 2.0.3 release
|
|
124
189
|
rather than assuming that any binary named `opencode2` exposes the current plugin API.
|
package/dist/index.js
CHANGED
|
@@ -636,7 +636,7 @@ function readMetadata(value) {
|
|
|
636
636
|
return;
|
|
637
637
|
const item = value;
|
|
638
638
|
if (typeof item.lang === "string" && typeof item.english === "string" && typeof item.display === "string") {
|
|
639
|
-
return { lang: item.lang, english: item.english, display: item.display };
|
|
639
|
+
return { lang: item.lang, english: item.english, display: item.display, enabled: item.enabled !== false };
|
|
640
640
|
}
|
|
641
641
|
}
|
|
642
642
|
function createState(ctx) {
|
|
@@ -667,7 +667,7 @@ function createState(ctx) {
|
|
|
667
667
|
const messages = await ctx.session.context({ sessionID });
|
|
668
668
|
for (const message of messages) {
|
|
669
669
|
const data = readMetadata(message.metadata?.[METADATA_KEY]);
|
|
670
|
-
if (message.type === "user" && data) {
|
|
670
|
+
if (message.type === "user" && data?.enabled) {
|
|
671
671
|
await ctx.storage.set(`sessions/${sessionID}`, data.lang);
|
|
672
672
|
return data.lang;
|
|
673
673
|
}
|
|
@@ -713,9 +713,62 @@ function createState(ctx) {
|
|
|
713
713
|
}
|
|
714
714
|
|
|
715
715
|
// src/translator.ts
|
|
716
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
716
717
|
function createTranslator(ctx, options, signal) {
|
|
717
718
|
const { providerID, modelID } = parseTranslatorModel(options.model);
|
|
718
719
|
const model = { providerID, id: modelID, ...options.variant ? { variant: options.variant } : {} };
|
|
720
|
+
let sessionRequired = false;
|
|
721
|
+
let helper;
|
|
722
|
+
function helperSession() {
|
|
723
|
+
if (helper)
|
|
724
|
+
return helper;
|
|
725
|
+
helper = (async () => {
|
|
726
|
+
const key = `translator-session/${createHash2("sha256").update(JSON.stringify({ directory: ctx.location.directory, workspaceID: ctx.location.workspaceID ?? null, model })).digest("hex")}`;
|
|
727
|
+
const saved = await ctx.storage.get(key);
|
|
728
|
+
const previous = typeof saved === "string" ? await ctx.session.get({ sessionID: saved }).catch((error) => {
|
|
729
|
+
if (error && typeof error === "object" && "_tag" in error && /NotFound/.test(String(error._tag)))
|
|
730
|
+
return;
|
|
731
|
+
throw error;
|
|
732
|
+
}) : undefined;
|
|
733
|
+
const session = previous ?? await ctx.session.create({
|
|
734
|
+
title: `Translation helper (${options.model})`,
|
|
735
|
+
model,
|
|
736
|
+
location: {
|
|
737
|
+
directory: ctx.location.directory,
|
|
738
|
+
...ctx.location.workspaceID ? { workspaceID: ctx.location.workspaceID } : {}
|
|
739
|
+
},
|
|
740
|
+
metadata: { "opencode-translate": { helper: true } }
|
|
741
|
+
});
|
|
742
|
+
await ctx.storage.set(key, session.id);
|
|
743
|
+
await ctx.session.hook(Number.parseInt(ctx.app.version, 10) >= 2 ? "generate" : "context", (event) => {
|
|
744
|
+
if (event.sessionID !== session.id)
|
|
745
|
+
return;
|
|
746
|
+
event.messages = event.messages.slice(-1);
|
|
747
|
+
event.tools = {};
|
|
748
|
+
});
|
|
749
|
+
return session.id;
|
|
750
|
+
})().catch((error) => {
|
|
751
|
+
helper = undefined;
|
|
752
|
+
throw error;
|
|
753
|
+
});
|
|
754
|
+
return helper;
|
|
755
|
+
}
|
|
756
|
+
async function request(prompt, abort) {
|
|
757
|
+
if (!sessionRequired) {
|
|
758
|
+
try {
|
|
759
|
+
return await ctx.generate.text({ model, prompt }, { signal: abort });
|
|
760
|
+
} catch (error) {
|
|
761
|
+
const message = error && typeof error === "object" && "message" in error ? String(error.message) : String(error);
|
|
762
|
+
if (!message.includes("free tier can only be used in OpenCode"))
|
|
763
|
+
throw error;
|
|
764
|
+
abort.throwIfAborted();
|
|
765
|
+
sessionRequired = true;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const sessionID = await helperSession();
|
|
769
|
+
abort.throwIfAborted();
|
|
770
|
+
return ctx.session.generate({ sessionID, prompt }, { signal: abort });
|
|
771
|
+
}
|
|
719
772
|
async function generate(prompt, requestSignal) {
|
|
720
773
|
const started = Date.now();
|
|
721
774
|
const abort = AbortSignal.any([signal, AbortSignal.timeout(180000), ...requestSignal ? [requestSignal] : []]);
|
|
@@ -727,7 +780,7 @@ function createTranslator(ctx, options, signal) {
|
|
|
727
780
|
const stop = () => rejectCancelled(abort.reason);
|
|
728
781
|
abort.addEventListener("abort", stop, { once: true });
|
|
729
782
|
try {
|
|
730
|
-
const result = await Promise.race([
|
|
783
|
+
const result = await Promise.race([request(prompt, abort), cancelled]);
|
|
731
784
|
if (options.verbose)
|
|
732
785
|
console.info(`[${PLUGIN_NAME}] translated with ${options.model} in ${Date.now() - started}ms`);
|
|
733
786
|
return result.text;
|
|
@@ -784,13 +837,7 @@ async function setup(ctx) {
|
|
|
784
837
|
if (source === undefined)
|
|
785
838
|
return;
|
|
786
839
|
const userLanguage = lang ?? options.lang;
|
|
787
|
-
|
|
788
|
-
const english = await translator.text(source, userLanguage, LLM_LANGUAGE);
|
|
789
|
-
const display = source === english ? source : `${source}
|
|
790
|
-
|
|
791
|
-
→ EN: ${english}`;
|
|
792
|
-
await state.remember(event.sessionID, display, english);
|
|
793
|
-
await ctx.storage.set(`sessions/${event.sessionID}`, userLanguage);
|
|
840
|
+
function apply(display, english, enabled) {
|
|
794
841
|
event.prompt.text = display;
|
|
795
842
|
for (const attachment of [
|
|
796
843
|
...event.prompt.files ?? [],
|
|
@@ -799,9 +846,26 @@ async function setup(ctx) {
|
|
|
799
846
|
]) {
|
|
800
847
|
delete attachment.mention;
|
|
801
848
|
}
|
|
802
|
-
event.metadata = { ...event.metadata, [METADATA_KEY]: { lang: userLanguage, english, display } };
|
|
849
|
+
event.metadata = { ...event.metadata, [METADATA_KEY]: { lang: userLanguage, english, display, enabled } };
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
852
|
+
const english = await translator.text(source, userLanguage, LLM_LANGUAGE);
|
|
853
|
+
const content = source === english ? source : `${source}
|
|
854
|
+
|
|
855
|
+
→ EN: ${english}`;
|
|
856
|
+
const display = lang ? content : `${content}
|
|
857
|
+
|
|
858
|
+
\uD83C\uDF10 Translation enabled: ${userLanguage} ↔ ${LLM_LANGUAGE} (${options.model})`;
|
|
859
|
+
await state.remember(event.sessionID, display, english);
|
|
860
|
+
await ctx.storage.set(`sessions/${event.sessionID}`, userLanguage);
|
|
861
|
+
apply(display, english, true);
|
|
803
862
|
} catch (error) {
|
|
804
863
|
console.error(`[${PLUGIN_NAME}] inbound translation failed; sending original text`, error);
|
|
864
|
+
const reason = error && typeof error === "object" && "message" in error && typeof error.message === "string" ? error.message : String(error);
|
|
865
|
+
const display = `${source}
|
|
866
|
+
|
|
867
|
+
⚠️ Translation failed: ${reason}. Original text was sent to the model.`;
|
|
868
|
+
apply(display, source, Boolean(lang));
|
|
805
869
|
}
|
|
806
870
|
});
|
|
807
871
|
async function context(event) {
|
package/package.json
CHANGED