opencode-translate 2.0.1 → 2.0.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.
Files changed (3) hide show
  1. package/README.md +55 -1
  2. package/dist/index.js +61 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -64,6 +64,7 @@ $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
+ - Web composer presentation metadata is synchronized with the translated prompt, including failure notices; review-comment cards are preserved.
67
68
  - The first successful `$en` prompt includes a `Translation enabled:` confirmation with the language and translator model.
68
69
  - English assistant text streams normally; a translated Markdown section is appended when the text segment completes.
69
70
  - Both the **terminal and web UI** display the same persisted bilingual assistant text. No UI-specific plugin is needed.
@@ -87,13 +88,58 @@ package, not an old pinned `opencode-translate@1.x`. Also check the configured *
87
88
  the main-chat model does not change the translator. An API-key login or OAuth connection must exist on that server.
88
89
  The server log message `[opencode-translate] inbound translation failed` contains the underlying generation error.
89
90
 
91
+ If replies translate but your own prompt still looks untranslated in the **Web UI**, inspect the stored user message's
92
+ `text` and `metadata.displayText`. The Web UI prefers `displayText`, which versions up to 2.0.2 left at the original
93
+ composer input even when `text` contained the English translation. The prompt hook now synchronizes that presentation
94
+ field. This applies to newly admitted prompts; it does not rewrite presentation metadata on older messages.
95
+
96
+ OpenCode 2.0.3 also normalizes the legacy `"plugin": [["package", { ...options }]]` tuple syntax; that syntax alone
97
+ does not prevent this plugin from loading. The `plugins` object form shown above is the recommended v2 format.
98
+ Make sure the configuration is a complete JSON/JSONC object, including its opening `{`.
99
+
100
+ If there is neither an activation confirmation nor a failure notice, inspect the running server's plugin state for the
101
+ same directory as the session:
102
+
103
+ ```sh
104
+ opencode api v2.plugin.awaitActivation --param 'location[directory]=/absolute/path/to/project'
105
+ opencode api v2.plugin.list --param 'location[directory]=/absolute/path/to/project'
106
+ ```
107
+
108
+ Find `opencode-translate`, check `source.version` and `state.status`, and inspect `state.error` if setup failed. If the UI
109
+ uses a remote or explicitly selected server, pass `--server` with that same server URL. A client version or a globally
110
+ installed npm version alone does not establish what the session's server has loaded.
111
+
90
112
  ## Authentication
91
113
 
92
114
  Connect the translation model's provider in **OpenCode itself**. Translation uses the public `ctx.generate.text()` API,
93
- so OpenCode owns provider selection, model variants, API keys, SQLite credentials, and OAuth refresh/persistence.
115
+ with a `ctx.session.generate()` fallback for providers that require an OpenCode session. OpenCode owns provider
116
+ selection, model variants, API keys, SQLite credentials, and OAuth refresh/persistence.
94
117
  The plugin does not read `auth.json`/`auth-v2.json`, query the credential database, or maintain separate tokens.
95
118
  Provider and OAuth support for the translation model is the support available in your OpenCode installation.
96
119
 
120
+ Verified on OpenCode 2.0.3 using the host's saved credentials:
121
+
122
+ | Translation model | Result |
123
+ | --- | --- |
124
+ | `openai/gpt-5.6-luna` | Inbound, outbound, and follow-up translation passed through stateless generation with ChatGPT OAuth. |
125
+ | `opencode/muse-spark-1.3-contributor-free` | Passed through the session-aware fallback described below. |
126
+ | `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. |
127
+
128
+ Anthropic translation succeeded in a session-aware experiment, but the automatic fallback in this release is limited
129
+ to the explicit OpenCode free-tier rejection. It does not retry generic authentication errors through another path.
130
+
131
+ ### OpenCode free-tier translation models
132
+
133
+ OpenCode 2.0.3 can reject stateless generation for free-tier models with `OpenCode's free tier can only be used in
134
+ OpenCode.` This was reproduced with `opencode/muse-spark-1.3-contributor-free`: normal session generation succeeds,
135
+ but `ctx.generate.text()` lacks the session request metadata accepted by that provider.
136
+
137
+ On this specific rejection, the plugin switches to public session-aware generation using a reusable **Translation
138
+ helper** session for the configured model and location. The helper may appear in the session list. Translation prompts
139
+ are transient: they do not append messages to either the helper or your chat, they cannot execute tools, and each
140
+ generation receives only the current translation prompt plus OpenCode's system instructions. The helper ID survives
141
+ plugin/server restarts. Other authentication or model-selection failures still report their original error.
142
+
97
143
  ## Inline reply support
98
144
 
99
145
  V2 has no `experimental.text.complete` hook or public display-only message append operation. Inline translations use
@@ -131,11 +177,19 @@ OPENCODE_BINARY=/path/to/opencode bun run test:host
131
177
 
132
178
  # Exercise a registry-installed package through OpenCode's actual package loader.
133
179
  OPENCODE_BINARY=/path/to/opencode OPENCODE_TRANSLATE_PACKAGE=opencode-translate@latest bun run test:host
180
+
181
+ # Verify OpenCode's normalization of legacy plugin tuples as well.
182
+ OPENCODE_BINARY=/path/to/opencode OPENCODE_TRANSLATE_LEGACY_CONFIG=1 bun run test:host
183
+
184
+ # Verify providers that support ordinary stateless generation.
185
+ OPENCODE_BINARY=/path/to/opencode OPENCODE_TRANSLATE_REQUIRE_SESSION=0 bun run test:host
134
186
  ```
135
187
 
136
188
  Tests include OpenCode's actual native protocol parsers. The real-host smoke test loads the built plugin, creates and
137
189
  rotates a test credential in an isolated SQLite database, checks bilingual persisted messages and English-only model
138
190
  requests, and restarts the server to verify recovery. It does not use your live server, credentials, or paid models.
191
+ CI covers both configuration formats and both generation paths. The publish workflow tests the candidate before
192
+ publishing, then installs the exact version from npm in OpenCode before creating its GitHub release.
139
193
 
140
194
  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
141
195
  rather than assuming that any binary named `opencode2` exposes the current plugin API.
package/dist/index.js CHANGED
@@ -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([ctx.generate.text({ model, prompt }, { signal: abort }), cancelled]);
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,7 +837,8 @@ async function setup(ctx) {
784
837
  if (source === undefined)
785
838
  return;
786
839
  const userLanguage = lang ?? options.lang;
787
- function apply(display, english, enabled) {
840
+ const apply = (display, english, enabled) => {
841
+ const presentation = event.metadata?.displayText;
788
842
  event.prompt.text = display;
789
843
  for (const attachment of [
790
844
  ...event.prompt.files ?? [],
@@ -794,7 +848,11 @@ async function setup(ctx) {
794
848
  delete attachment.mention;
795
849
  }
796
850
  event.metadata = { ...event.metadata, [METADATA_KEY]: { lang: userLanguage, english, display, enabled } };
797
- }
851
+ if (typeof presentation === "string") {
852
+ const visible = lang ? presentation : stripTrigger(presentation, options.trigger) ?? presentation;
853
+ event.metadata.displayText = `${visible}${display.slice(source.length)}`;
854
+ }
855
+ };
798
856
  try {
799
857
  const english = await translator.text(source, userLanguage, LLM_LANGUAGE);
800
858
  const content = source === english ? source : `${source}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
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
6
  "main": "dist/index.js",