opencode-image 0.1.3 → 2.0.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/README.md CHANGED
@@ -7,9 +7,31 @@
7
7
  - 兼容任何 OpenAI 风格的 `/images/generations` 与 `/images/edits` 接口
8
8
  - 跨平台缩放(基于 [sharp](https://sharp.pixelplumbing.com)),不依赖 `sips` 等系统命令
9
9
 
10
+ ## 版本与兼容(重要)
11
+
12
+ | 版本 | 目标 opencode | 插件 API | npm 安装 |
13
+ |---|---|---|---|
14
+ | **2.x**(当前线,`next` tag) | opencode **2.0+** | `@opencode/plugin@2`(`Plugin.define` + `ctx.tool` / `ctx.session`) | `opencode-image@next` |
15
+ | 1.x(`latest` tag) | opencode **1.x** | `@opencode-ai/plugin` | `opencode-image@latest` |
16
+
17
+ - v2 的插件运行时**只认** `export default { id, effect | setup }`,v1 形态的插件在 v2 里加载不了,所以这是**大版本切换**。
18
+ - 本仓库 `publishConfig.tag` 固定为 `next`,直接 `npm publish` **不会**顶掉 `latest`,因此 **v1 老用户不会被自动升级到 2.x**。
19
+ - v1 线要出新补丁:`bun run release:legacy`(打 patch 到 `latest`)。
20
+
10
21
  ## 安装
11
22
 
12
- 在 `opencode.json` / `opencode.jsonc` 里配置:
23
+ opencode **2.0+**(v2 插件 API):
24
+
25
+ ```jsonc
26
+ {
27
+ "$schema": "https://opencode.ai/config.json",
28
+ "plugin": [
29
+ ["opencode-image@next", { "apiKey": "sk-..." }]
30
+ ]
31
+ }
32
+ ```
33
+
34
+ opencode **1.x**(保持老版本):
13
35
 
14
36
  ```jsonc
15
37
  {
@@ -20,7 +42,8 @@
20
42
  }
21
43
  ```
22
44
 
23
- opencode 启动时会自动从 npm 安装插件到 `~/.cache/opencode/packages/opencode-image/node_modules/`。
45
+ opencode 启动时会自动从 npm 安装插件到 `~/.cache/opencode/packages/opencode-image.../node_modules/`。
46
+
24
47
 
25
48
  ## 配置项
26
49
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { type Plugin } from "@opencode-ai/plugin";
2
- export declare const OpenCodeImagePlugin: Plugin;
1
+ import { Plugin } from "@opencode/plugin";
2
+ export declare const OpenCodeImagePlugin: Plugin.Plugin;
3
3
  export default OpenCodeImagePlugin;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/index.ts
2
- import { tool } from "@opencode-ai/plugin";
2
+ import { Plugin } from "@opencode/plugin";
3
+ import { Schema } from "effect";
3
4
  import * as fs5 from "node:fs";
4
5
  import * as path5 from "node:path";
5
6
 
@@ -407,9 +408,9 @@ class ImageServer {
407
408
  return Buffer.from(relative2.split(path4.sep).join("/"), "utf8").toString("base64url");
408
409
  }
409
410
  }
410
- for (const [id2, registered] of this.extra) {
411
+ for (const [id, registered] of this.extra) {
411
412
  if (registered === file)
412
- return id2;
413
+ return id;
413
414
  }
414
415
  const id = randomUUID().replace(/-/g, "").slice(0, 16);
415
416
  this.extra.set(id, file);
@@ -461,9 +462,9 @@ class ImageServer {
461
462
  const port = this.configuredPort || derivePort(path4.resolve(opts.outDir));
462
463
  const server = http.createServer((req, res) => void this.handle(req, res));
463
464
  server.unref();
464
- const failure = await new Promise((resolve3) => {
465
- server.once("error", resolve3);
466
- server.listen(port, "127.0.0.1", () => resolve3(undefined));
465
+ const failure = await new Promise((resolve) => {
466
+ server.once("error", resolve);
467
+ server.listen(port, "127.0.0.1", () => resolve(undefined));
467
468
  });
468
469
  if (!failure) {
469
470
  this.server = server;
@@ -515,15 +516,15 @@ class ImageServer {
515
516
  const root = this.root;
516
517
  if (!root)
517
518
  return;
518
- let relative2;
519
+ let relative;
519
520
  try {
520
- relative2 = Buffer.from(id, "base64url").toString("utf8");
521
+ relative = Buffer.from(id, "base64url").toString("utf8");
521
522
  } catch {
522
523
  return;
523
524
  }
524
- if (!relative2 || relative2.includes("\x00") || path4.isAbsolute(relative2))
525
+ if (!relative || relative.includes("\x00") || path4.isAbsolute(relative))
525
526
  return;
526
- const target = path4.resolve(root, relative2);
527
+ const target = path4.resolve(root, relative);
527
528
  if (target === root || !target.startsWith(root + path4.sep))
528
529
  return;
529
530
  try {
@@ -574,9 +575,25 @@ class ImageServer {
574
575
  }
575
576
 
576
577
  // src/index.ts
577
- var z = tool.schema;
578
- function resolveDestination(filename, outDir, ctx) {
579
- const dir = resolveOutDir(ctx.directory, outDir);
578
+ var PLUGIN_ID = "opencode-image";
579
+ var GENERATE_DESCRIPTION = "Generate an image from a text prompt. This is the only supported way to create images in this session: " + "the endpoint and API key are already configured inside this plugin, so NEVER call the image API yourself " + "(no curl, no scripts) and never read the API key out of opencode.json. " + "The first line of the output is a Markdown image line (plus a separate 查看原图 link when displayMaxSize > 0); " + "you MUST copy those lines verbatim into your reply text, otherwise the user cannot see the picture. " + "If the output contains a line starting with [请转述给用户], relay that line to the user as well. " + "To refine a result, call image_edit with the returned path instead of generating a new image.";
580
+ var EDIT_DESCRIPTION = "Edit an existing image with a text instruction (image-to-image). Prefer this over generating a new image " + "when the user wants to adjust a picture: pass the exact path returned by a previous image_generate or " + "image_edit call to keep refining the same one. " + "Same rules: never call the image API yourself and never read the API key; copy the Markdown image line " + "(and the 查看原图 link, if present) from the output verbatim into your reply, and relay any line starting " + "with [请转述给用户].";
581
+ var CACHE_DESCRIPTION = "Inspect or clear the preview-thumbnail cache used for rendering images in the conversation. " + 'Use action="status" to report the cache directory, file count, total size and age of the oldest entry; ' + 'action="clean" to delete every cached preview (the generated images in outDir are NOT touched). ' + "This returns text only — do not turn it into a Markdown image line.";
582
+ var SYSTEM_RULE = "Image generation/editing: always use the `image_generate` / `image_edit` tools provided by the " + "opencode-image plugin — the endpoint and API key are already configured there. Never call the image API " + "directly, never read the API key from config, and do not use the imagegen skill or its CLI. Copy the " + "Markdown image line from that tool's output verbatim into your reply so the user can see the picture.";
583
+ var GenerateInput = Schema.Struct({
584
+ prompt: Schema.String,
585
+ filename: Schema.optional(Schema.String)
586
+ });
587
+ var EditInput = Schema.Struct({
588
+ image: Schema.String,
589
+ prompt: Schema.String,
590
+ filename: Schema.optional(Schema.String)
591
+ });
592
+ var CacheInput = Schema.Struct({
593
+ action: Schema.Literals(["status", "clean"])
594
+ });
595
+ function resolveDestination(filename, options, directory) {
596
+ const dir = resolveOutDir(directory, options.outDir);
580
597
  if (filename) {
581
598
  const target = path5.isAbsolute(filename) ? filename : path5.join(dir, filename);
582
599
  return `${target.replace(/\.[A-Za-z0-9]+$/, "")}.png`;
@@ -586,123 +603,119 @@ function resolveDestination(filename, outDir, ctx) {
586
603
  return path5.join(dir, `image-${stamp}-${suffix}.png`);
587
604
  }
588
605
  function asAttachment(file) {
589
- return [
590
- {
591
- type: "file",
592
- mime: "image/png",
593
- filename: path5.basename(file),
594
- url: `data:image/png;base64,${fs5.readFileSync(file).toString("base64")}`
595
- }
596
- ];
606
+ return {
607
+ type: "file",
608
+ mime: "image/png",
609
+ name: path5.basename(file),
610
+ uri: `data:image/png;base64,${fs5.readFileSync(file).toString("base64")}`
611
+ };
597
612
  }
598
- var OpenCodeImagePlugin = async (_input, rawOptions) => {
599
- const options = resolveOptions(rawOptions);
600
- const cache = new PreviewCache(options.cacheDir, options.cacheLimit);
601
- const server = new ImageServer(options.serverIdleMs, options.serverPort, cache);
602
- const selfVersion = readOwnVersion();
603
- let updateHint;
604
- let updateChecked = false;
605
- const upgradeNotice = async () => {
606
- if (!options.updateCheck || updateChecked || !selfVersion)
613
+ var OpenCodeImagePlugin = Plugin.define({
614
+ id: PLUGIN_ID,
615
+ setup: async (ctx) => {
616
+ const options = resolveOptions(ctx.options);
617
+ const directory = ctx.location.directory;
618
+ const outDir = resolveOutDir(directory, options.outDir);
619
+ const cache = new PreviewCache(options.cacheDir, options.cacheLimit);
620
+ const server = new ImageServer(options.serverIdleMs, options.serverPort, cache);
621
+ const selfVersion = readOwnVersion();
622
+ let updateHint;
623
+ let updateChecked = false;
624
+ const upgradeNotice = async () => {
625
+ if (!options.updateCheck || updateChecked || !selfVersion)
626
+ return updateHint;
627
+ updateChecked = true;
628
+ updateHint = await checkForUpdate(selfVersion);
607
629
  return updateHint;
608
- updateChecked = true;
609
- updateHint = await checkForUpdate(selfVersion);
610
- return updateHint;
611
- };
612
- const result = async (label, prompt, file, ctx, usage) => {
613
- const { width, height } = await imageDimensions(file);
614
- let display;
615
- try {
616
- await server.start({ outDir: resolveOutDir(ctx.directory, options.outDir) });
617
- const alt = prompt.slice(0, 60);
618
- const lines = [`![${alt}](${server.url(file, options.displayMaxSize)})`];
619
- if (options.displayMaxSize > 0)
620
- lines.push(`[查看原图](${server.url(file, 0)})`);
621
- display = lines.join(`
630
+ };
631
+ const render = async (label, prompt, file, progress, usage) => {
632
+ const { width, height } = await imageDimensions(file);
633
+ let display;
634
+ try {
635
+ await server.start({ outDir });
636
+ const alt = prompt.slice(0, 60);
637
+ const lines = [`![${alt}](${server.url(file, options.displayMaxSize)})`];
638
+ if (options.displayMaxSize > 0)
639
+ lines.push(`[查看原图](${server.url(file, 0)})`);
640
+ display = lines.join(`
622
641
  `);
623
- } catch (error) {
624
- display = `[opencode-image: local image server unavailable, cannot render inline: ${String(error)}]`;
625
- }
626
- const notice = await upgradeNotice();
627
- const output = [
628
- display,
629
- `${label} saved to ${file} (${width}x${height})`,
630
- `Refine it by calling image_edit with image="${file}" and a prompt describing the change.`,
631
- `IMPORTANT: copy the Markdown image line above verbatim into your reply text, otherwise the user cannot see the picture.`
632
- ];
633
- if (notice)
634
- output.push(notice);
635
- const cacheNotice = server.consumeCacheNotice();
636
- if (cacheNotice)
637
- output.push(cacheNotice);
638
- return {
639
- title: `${label}: ${prompt.slice(0, 60)}`,
640
- output: output.join(`
641
- `),
642
- metadata: { file, prompt, usage },
643
- attachments: asAttachment(file)
642
+ } catch (error) {
643
+ display = `[opencode-image: local image server unavailable, cannot render inline: ${String(error)}]`;
644
+ }
645
+ const notice = await upgradeNotice();
646
+ const output = [
647
+ display,
648
+ `${label} saved to ${file} (${width}x${height})`,
649
+ `Refine it by calling image_edit with image="${file}" and a prompt describing the change.`,
650
+ `IMPORTANT: copy the Markdown image line above verbatim into your reply text, otherwise the user cannot see the picture.`
651
+ ];
652
+ if (notice)
653
+ output.push(notice);
654
+ const cacheNotice = server.consumeCacheNotice();
655
+ if (cacheNotice)
656
+ output.push(cacheNotice);
657
+ progress(`${label}: ${prompt.slice(0, 60)}`);
658
+ return {
659
+ content: [{ type: "text", text: output.join(`
660
+ `) }, asAttachment(file)],
661
+ metadata: { file, prompt, usage }
662
+ };
644
663
  };
645
- };
646
- const generate = async (args, ctx) => {
647
- ctx.metadata({ title: `Generating image: ${args.prompt.slice(0, 60)}` });
648
- const { signal, clear } = withTimeout(ctx.abort, options.timeoutMs);
649
- try {
650
- const remote = await generateImage(options, args.prompt, signal);
651
- const raw = await fetchImageBytes(remote, signal);
652
- const destination = resolveDestination(args.filename, options.outDir, ctx);
653
- await persistImage(raw, destination, options.maxSize);
654
- return result("Image generated", args.prompt, destination, ctx, remote.usage);
655
- } finally {
656
- clear();
657
- }
658
- };
659
- const edit = async (args, ctx) => {
660
- const source = path5.isAbsolute(args.image) ? args.image : path5.resolve(ctx.directory, args.image);
661
- if (!fs5.existsSync(source))
662
- throw new Error(`image_edit: source image not found: ${source}`);
663
- ctx.metadata({ title: `Editing image: ${args.prompt.slice(0, 60)}` });
664
- const { signal, clear } = withTimeout(ctx.abort, options.timeoutMs);
665
- try {
666
- const upload = await toPngBuffer(source, options.maxSize);
667
- const remote = await editImage(options, upload, `${path5.basename(source, path5.extname(source))}.png`, args.prompt, signal);
668
- const raw = await fetchImageBytes(remote, signal);
669
- const destination = resolveDestination(args.filename, options.outDir, ctx);
670
- await persistImage(raw, destination, options.maxSize);
671
- return result("Image edited", args.prompt, destination, ctx, remote.usage);
672
- } finally {
673
- clear();
674
- }
675
- };
676
- return {
677
- tool: {
678
- image_generate: tool({
679
- description: "Generate an image from a text prompt. This is the only supported way to create images in this session: " + "the endpoint and API key are already configured inside this plugin, so NEVER call the image API yourself " + "(no curl, no scripts) and never read the API key out of opencode.json. " + "The first line of the output is a Markdown image line (plus a separate 查看原图 link when displayMaxSize > 0); " + "you MUST copy those lines verbatim into your reply text, otherwise the user cannot see the picture. " + "If the output contains a line starting with [请转述给用户], relay that line to the user as well. " + "To refine a result, call image_edit with the returned path instead of generating a new image.",
680
- args: {
681
- prompt: z.string().describe("Detailed description of the image to generate"),
682
- filename: z.string().optional().describe("Optional output file name or path; defaults to a timestamped name")
683
- },
684
- execute: generate
685
- }),
686
- image_edit: tool({
687
- description: "Edit an existing image with a text instruction (image-to-image). Prefer this over generating a new image " + "when the user wants to adjust a picture: pass the exact path returned by a previous image_generate or " + "image_edit call to keep refining the same one. " + "Same rules: never call the image API yourself and never read the API key; copy the Markdown image line " + "(and the 查看原图 link, if present) from the output verbatim into your reply, and relay any line starting " + "with [请转述给用户].",
688
- args: {
689
- image: z.string().describe("Path to the source image, normally the file returned by image_generate/image_edit"),
690
- prompt: z.string().describe("What to change, e.g. 'make the hat red, keep everything else identical'"),
691
- filename: z.string().optional().describe("Optional output file name or path; defaults to a timestamped name")
692
- },
693
- execute: edit
694
- }),
695
- image_cache: tool({
696
- description: "Inspect or clear the preview-thumbnail cache used for rendering images in the conversation. " + 'Use action="status" to report the cache directory, file count, total size and age of the oldest entry; ' + 'action="clean" to delete every cached preview (the generated images in outDir are NOT touched). ' + "This returns text only — do not turn it into a Markdown image line.",
697
- args: {
698
- action: z.enum(["status", "clean"]).describe("status = 查看缓存;clean = 清空缓存")
699
- },
664
+ await ctx.tool.transform((tools) => {
665
+ tools.add({
666
+ name: "image_generate",
667
+ description: GENERATE_DESCRIPTION,
668
+ input: GenerateInput,
669
+ options: { codemode: false },
670
+ execute: async (args, toolCtx) => {
671
+ const title = `Generating image: ${args.prompt.slice(0, 60)}`;
672
+ await toolCtx.progress({ title });
673
+ const { signal, clear } = withTimeout(toolCtx.signal, options.timeoutMs);
674
+ try {
675
+ const remote = await generateImage(options, args.prompt, signal);
676
+ const raw = await fetchImageBytes(remote, signal);
677
+ const destination = resolveDestination(args.filename, options, directory);
678
+ await persistImage(raw, destination, options.maxSize);
679
+ return await render("Image generated", args.prompt, destination, (m) => void toolCtx.progress({ title: m }), remote.usage);
680
+ } finally {
681
+ clear();
682
+ }
683
+ }
684
+ });
685
+ tools.add({
686
+ name: "image_edit",
687
+ description: EDIT_DESCRIPTION,
688
+ input: EditInput,
689
+ options: { codemode: false },
690
+ execute: async (args, toolCtx) => {
691
+ const source = path5.isAbsolute(args.image) ? args.image : path5.resolve(directory, args.image);
692
+ if (!fs5.existsSync(source))
693
+ throw new Error(`image_edit: source image not found: ${source}`);
694
+ const title = `Editing image: ${args.prompt.slice(0, 60)}`;
695
+ await toolCtx.progress({ title });
696
+ const { signal, clear } = withTimeout(toolCtx.signal, options.timeoutMs);
697
+ try {
698
+ const upload = await toPngBuffer(source, options.maxSize);
699
+ const remote = await editImage(options, upload, `${path5.basename(source, path5.extname(source))}.png`, args.prompt, signal);
700
+ const raw = await fetchImageBytes(remote, signal);
701
+ const destination = resolveDestination(args.filename, options, directory);
702
+ await persistImage(raw, destination, options.maxSize);
703
+ return await render("Image edited", args.prompt, destination, (m) => void toolCtx.progress({ title: m }), remote.usage);
704
+ } finally {
705
+ clear();
706
+ }
707
+ }
708
+ });
709
+ tools.add({
710
+ name: "image_cache",
711
+ description: CACHE_DESCRIPTION,
712
+ input: CacheInput,
713
+ options: { codemode: false },
700
714
  execute: async (args) => {
701
715
  if (args.action === "clean") {
702
716
  const removed = server.cleanCache();
703
717
  return {
704
- title: `已清理图片缓存(${removed.count} 个 / ${humanBytes(removed.bytes)})`,
705
- output: [
718
+ content: [
706
719
  `已清理 ${removed.count} 个预览文件,共 ${humanBytes(removed.bytes)}。`,
707
720
  `缓存目录:${removed.dir}`,
708
721
  `注意:这不影响 outDir 里的原图,会话里的图片仍能正常显示(需要时会重新生成缩略图)。`
@@ -713,8 +726,7 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
713
726
  }
714
727
  const stats = server.cacheStats();
715
728
  return {
716
- title: `图片缓存:${stats.count} 个 / ${humanBytes(stats.bytes)}`,
717
- output: [
729
+ content: [
718
730
  `缓存目录:${stats.dir}`,
719
731
  `文件数量:${stats.count}`,
720
732
  `占用大小:${humanBytes(stats.bytes)}`,
@@ -725,20 +737,17 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
725
737
  metadata: stats
726
738
  };
727
739
  }
728
- })
729
- },
730
- "experimental.chat.system.transform": async (_input2, output) => {
731
- if (!options.injectPrompt)
732
- return;
733
- output.system.push("Image generation/editing: always use the `image_generate` / `image_edit` tools provided by the " + "opencode-image plugin — the endpoint and API key are already configured there. Never call the image API " + "directly, never read the API key from config, and do not use the imagegen skill or its CLI. Copy the " + "Markdown image line from that tool's output verbatim into your reply so the user can see the picture.");
734
- },
735
- dispose: async () => {
736
- server.stop();
737
- }
738
- };
739
- };
740
+ });
741
+ });
742
+ if (options.injectPrompt)
743
+ await ctx.session.hook("context", (event) => {
744
+ event.system.push({ type: "text", text: SYSTEM_RULE });
745
+ });
746
+ return () => server.stop();
747
+ }
748
+ });
740
749
  var src_default = OpenCodeImagePlugin;
741
750
  export {
742
- src_default as default,
743
- OpenCodeImagePlugin
751
+ OpenCodeImagePlugin,
752
+ src_default as default
744
753
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-image",
3
- "version": "0.1.3",
4
- "description": "Image generation and editing tools for opencode, with inline rendering in the conversation.",
3
+ "version": "2.0.0",
4
+ "description": "Image generation and editing tools for opencode (v2 plugin API), with inline rendering in the conversation.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -23,7 +23,8 @@
23
23
  },
24
24
  "publishConfig": {
25
25
  "registry": "https://registry.npmjs.org/",
26
- "access": "public"
26
+ "access": "public",
27
+ "tag": "next"
27
28
  },
28
29
  "exports": {
29
30
  ".": {
@@ -39,25 +40,31 @@
39
40
  ],
40
41
  "scripts": {
41
42
  "build": "bun run build:js && bun run build:types",
42
- "build:js": "bun build src/index.ts --target=node --format=esm --external sharp --external @opencode-ai/plugin --outfile=dist/index.js",
43
+ "build:js": "bun build src/index.ts --target=node --format=esm --external sharp --external @opencode/plugin --external effect --outfile=dist/index.js",
43
44
  "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
44
45
  "typecheck": "tsc --noEmit",
45
46
  "test": "bun test",
46
47
  "prepublishOnly": "bun run build && bun test",
47
- "release": "npm version patch && npm publish && rm -rf ~/.cache/opencode/packages/opencode-image*"
48
+ "release": "npm publish && rm -rf ~/.cache/opencode/packages/opencode-image*",
49
+ "release:legacy": "npm version patch && npm publish --tag latest"
48
50
  },
49
51
  "engines": {
50
52
  "node": ">=18"
51
53
  },
52
54
  "dependencies": {
53
- "@opencode-ai/plugin": "^1.18.30",
54
55
  "sharp": "^0.35.4"
55
56
  },
57
+ "peerDependencies": {
58
+ "@opencode/plugin": ">=2.0.27",
59
+ "effect": "^4.0.0"
60
+ },
56
61
  "devDependencies": {
62
+ "@opencode/plugin": "2.0.27",
57
63
  "@types/node": "^24.12.2",
64
+ "effect": "4.0.1",
58
65
  "typescript": "^5.8.2"
59
66
  },
60
67
  "bin": {
61
- "opencode-image": "./bin/cli.js"
68
+ "opencode-image": "bin/cli.js"
62
69
  }
63
70
  }