@azatakmyradov/opencode-save-md-plugin 0.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 ADDED
@@ -0,0 +1,54 @@
1
+ # OpenCode save Markdown plugin
2
+
3
+ Save the latest assistant response from an OpenCode V2 session as a Markdown file in the server workspace.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ opencode2 plugin add @azatakmyradov/opencode-save-md-plugin
9
+ opencode2 service restart
10
+ ```
11
+
12
+ The package exposes server, TUI, and portable RPC entrypoints. OpenCode V2 loads the TUI entrypoint automatically when the package is installed.
13
+
14
+ ## Use
15
+
16
+ ```text
17
+ /save-md design
18
+ /save-md notes/design.md
19
+ ```
20
+
21
+ The first command writes `design.md`; the second keeps the supplied `.md` suffix. The command waits for an active response to finish, then saves only the latest assistant message from the active context. Text parts are preserved and joined with a blank line; reasoning and tool parts are excluded.
22
+
23
+ Writes happen in the server process, so a remote TUI saves into the server workspace. Absolute paths and relative paths that escape the current location are rejected. Existing files are never overwritten.
24
+
25
+ ## Local Development
26
+
27
+ Install the workspace and verify the package:
28
+
29
+ ```bash
30
+ bun install
31
+ bun run --filter @azatakmyradov/opencode-save-md-plugin check
32
+ bun run --filter @azatakmyradov/opencode-save-md-plugin test
33
+ bun run --filter @azatakmyradov/opencode-save-md-plugin build
34
+ ```
35
+
36
+ Load the package directory by absolute path in a project `opencode.jsonc`:
37
+
38
+ ```jsonc
39
+ {
40
+ "$schema": "https://opencode.ai/config.json",
41
+ "plugins": ["/absolute/path/to/opencode-plugins/packages/save-md"],
42
+ }
43
+ ```
44
+
45
+ The package's root development entrypoints load `src/index.ts` and `src/tui.tsx`, so the server and matching TUI plugin are both discovered. Run the package build first only when testing the npm `dist` exports.
46
+
47
+ ## Publish
48
+
49
+ Create a changeset for normal releases. To bootstrap npm trusted publishing for this package:
50
+
51
+ ```bash
52
+ npm publish --workspace @azatakmyradov/opencode-save-md-plugin
53
+ npm trust github @azatakmyradov/opencode-save-md-plugin --file release.yml --repo azatakmyradov/opencode-plugins --allow-publish
54
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,188 @@
1
+ // @bun
2
+ // src/rpc.ts
3
+ import { Rpc } from "@opencode-ai/plugin/rpc";
4
+ import { z } from "zod";
5
+ var NoDetails = z.object({});
6
+ var InvalidPathDetails = z.object({ name: z.string() });
7
+ var DestinationDetails = z.object({ path: z.string() });
8
+ var SaveMdRpc = Rpc.define({
9
+ id: "save-md",
10
+ methods: {
11
+ save: {
12
+ input: z.object({
13
+ sessionID: z.string().min(1),
14
+ name: z.string().min(1)
15
+ }),
16
+ output: z.object({ path: z.string() }),
17
+ errors: {
18
+ no_assistant_response: NoDetails,
19
+ no_markdown_text: NoDetails,
20
+ invalid_path: InvalidPathDetails,
21
+ destination_exists: DestinationDetails,
22
+ filesystem_write_failed: DestinationDetails
23
+ }
24
+ }
25
+ },
26
+ events: {}
27
+ });
28
+ var SaveMdRpcFailure = z.discriminatedUnion("type", [
29
+ z.object({ type: z.literal("no_assistant_response"), message: z.string(), data: NoDetails }),
30
+ z.object({ type: z.literal("no_markdown_text"), message: z.string(), data: NoDetails }),
31
+ z.object({ type: z.literal("invalid_path"), message: z.string(), data: InvalidPathDetails }),
32
+ z.object({
33
+ type: z.literal("destination_exists"),
34
+ message: z.string(),
35
+ data: DestinationDetails
36
+ }),
37
+ z.object({
38
+ type: z.literal("filesystem_write_failed"),
39
+ message: z.string(),
40
+ data: DestinationDetails
41
+ })
42
+ ]);
43
+
44
+ // src/index.ts
45
+ import { Effect as Effect2 } from "effect";
46
+ import { Plugin } from "@opencode-ai/plugin/effect";
47
+
48
+ // src/core.ts
49
+ import { writeFile } from "fs/promises";
50
+ import { isAbsolute, relative, resolve, sep, win32 } from "path";
51
+ import { DateTime, Effect, Schema } from "effect";
52
+
53
+ class NoAssistantResponseError extends Schema.TaggedError()("NoAssistantResponseError", { message: Schema.String }) {
54
+ }
55
+
56
+ class NoMarkdownTextError extends Schema.TaggedError()("NoMarkdownTextError", { message: Schema.String }) {
57
+ }
58
+
59
+ class InvalidPathError extends Schema.TaggedError()("InvalidPathError", {
60
+ name: Schema.String,
61
+ message: Schema.String
62
+ }) {
63
+ }
64
+
65
+ class DestinationExistsError extends Schema.TaggedError()("DestinationExistsError", {
66
+ path: Schema.String,
67
+ message: Schema.String
68
+ }) {
69
+ }
70
+
71
+ class FileSystemWriteError extends Schema.TaggedError()("FileSystemWriteError", {
72
+ path: Schema.String,
73
+ message: Schema.String,
74
+ cause: Schema.Defect()
75
+ }) {
76
+ }
77
+ var ExistingFileSystemError = Schema.Struct({ code: Schema.Literal("EEXIST") });
78
+ var isExistingFileSystemError = Schema.is(ExistingFileSystemError);
79
+ var selectLatestAssistant = Effect.fn("selectLatestAssistant")(function* (messages) {
80
+ let latest;
81
+ for (const message of messages) {
82
+ if (message.type !== "assistant")
83
+ continue;
84
+ if (!latest || DateTime.toEpochMillis(message.time.created) >= DateTime.toEpochMillis(latest.time.created)) {
85
+ latest = message;
86
+ }
87
+ }
88
+ if (!latest) {
89
+ return yield* new NoAssistantResponseError({
90
+ message: "No assistant response is available in the active session context."
91
+ });
92
+ }
93
+ return latest;
94
+ });
95
+ var extractMarkdown = Effect.fn("extractMarkdown")(function* (message) {
96
+ const markdown = message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join(`
97
+
98
+ `);
99
+ if (!markdown.trim()) {
100
+ return yield* new NoMarkdownTextError({
101
+ message: "The latest assistant response contains no Markdown text."
102
+ });
103
+ }
104
+ return markdown;
105
+ });
106
+ var extractLatestMarkdown = Effect.fn("extractLatestMarkdown")(function* (messages) {
107
+ const assistant = yield* selectLatestAssistant(messages);
108
+ return yield* extractMarkdown(assistant);
109
+ });
110
+ var resolveMarkdownPath = Effect.fn("resolveMarkdownPath")(function* (directory, name) {
111
+ const requested = name.trim();
112
+ const invalid = requested.length === 0 || requested.includes("\x00") || isAbsolute(requested) || win32.isAbsolute(requested);
113
+ if (invalid) {
114
+ return yield* new InvalidPathError({
115
+ name,
116
+ message: "The destination must be a non-empty relative path inside the current location."
117
+ });
118
+ }
119
+ const root = resolve(directory);
120
+ const filename = requested.endsWith(".md") ? requested : `${requested}.md`;
121
+ const destination = resolve(root, filename);
122
+ const contained = relative(root, destination);
123
+ if (contained.length === 0 || contained === ".." || contained.startsWith(`..${sep}`) || isAbsolute(contained)) {
124
+ return yield* new InvalidPathError({
125
+ name,
126
+ message: "The destination path escapes the current location."
127
+ });
128
+ }
129
+ return destination;
130
+ });
131
+ var saveMarkdown = Effect.fn("saveMarkdown")(function* (directory, name, markdown) {
132
+ const destination = yield* resolveMarkdownPath(directory, name);
133
+ const content = markdown.endsWith(`
134
+ `) ? markdown : `${markdown}
135
+ `;
136
+ yield* Effect.tryPromise({
137
+ try: (signal) => writeFile(destination, content, {
138
+ encoding: "utf8",
139
+ flag: "wx",
140
+ signal
141
+ }),
142
+ catch: (cause) => {
143
+ if (isExistingFileSystemError(cause)) {
144
+ return new DestinationExistsError({
145
+ path: destination,
146
+ message: `Destination already exists: ${destination}`
147
+ });
148
+ }
149
+ return new FileSystemWriteError({
150
+ path: destination,
151
+ cause,
152
+ message: `Could not write Markdown to ${destination}: ${cause instanceof Error ? cause.message : String(cause)}`
153
+ });
154
+ }
155
+ });
156
+ return destination;
157
+ });
158
+ var saveLatestAssistant = Effect.fn("saveLatestAssistant")(function* (directory, name, messages) {
159
+ const markdown = yield* extractLatestMarkdown(messages);
160
+ return yield* saveMarkdown(directory, name, markdown);
161
+ });
162
+
163
+ // src/index.ts
164
+ var src_default = Plugin.define({
165
+ id: "save-md",
166
+ effect: (ctx) => Effect2.gen(function* () {
167
+ yield* ctx.rpc.register(SaveMdRpc, {
168
+ save: ({ sessionID, name }, rpc) => {
169
+ const id = sessionID;
170
+ return Effect2.gen(function* () {
171
+ yield* ctx.session.wait({ sessionID: id }).pipe(Effect2.orDie);
172
+ const messages = yield* ctx.session.context({ sessionID: id }).pipe(Effect2.orDie);
173
+ const path = yield* saveLatestAssistant(ctx.location.directory, name, messages);
174
+ return { path };
175
+ }).pipe(Effect2.catchTags({
176
+ NoAssistantResponseError: (error) => Effect2.fail(rpc.error("no_assistant_response", error.message, {})),
177
+ NoMarkdownTextError: (error) => Effect2.fail(rpc.error("no_markdown_text", error.message, {})),
178
+ InvalidPathError: (error) => Effect2.fail(rpc.error("invalid_path", error.message, { name: error.name })),
179
+ DestinationExistsError: (error) => Effect2.fail(rpc.error("destination_exists", error.message, { path: error.path })),
180
+ FileSystemWriteError: (error) => Effect2.fail(rpc.error("filesystem_write_failed", error.message, { path: error.path }))
181
+ }));
182
+ }
183
+ }).pipe(Effect2.orDie);
184
+ })
185
+ });
186
+ export {
187
+ src_default as default
188
+ };
package/dist/rpc.js ADDED
@@ -0,0 +1,46 @@
1
+ // @bun
2
+ // src/rpc.ts
3
+ import { Rpc } from "@opencode-ai/plugin/rpc";
4
+ import { z } from "zod";
5
+ var NoDetails = z.object({});
6
+ var InvalidPathDetails = z.object({ name: z.string() });
7
+ var DestinationDetails = z.object({ path: z.string() });
8
+ var SaveMdRpc = Rpc.define({
9
+ id: "save-md",
10
+ methods: {
11
+ save: {
12
+ input: z.object({
13
+ sessionID: z.string().min(1),
14
+ name: z.string().min(1)
15
+ }),
16
+ output: z.object({ path: z.string() }),
17
+ errors: {
18
+ no_assistant_response: NoDetails,
19
+ no_markdown_text: NoDetails,
20
+ invalid_path: InvalidPathDetails,
21
+ destination_exists: DestinationDetails,
22
+ filesystem_write_failed: DestinationDetails
23
+ }
24
+ }
25
+ },
26
+ events: {}
27
+ });
28
+ var SaveMdRpcFailure = z.discriminatedUnion("type", [
29
+ z.object({ type: z.literal("no_assistant_response"), message: z.string(), data: NoDetails }),
30
+ z.object({ type: z.literal("no_markdown_text"), message: z.string(), data: NoDetails }),
31
+ z.object({ type: z.literal("invalid_path"), message: z.string(), data: InvalidPathDetails }),
32
+ z.object({
33
+ type: z.literal("destination_exists"),
34
+ message: z.string(),
35
+ data: DestinationDetails
36
+ }),
37
+ z.object({
38
+ type: z.literal("filesystem_write_failed"),
39
+ message: z.string(),
40
+ data: DestinationDetails
41
+ })
42
+ ]);
43
+ export {
44
+ SaveMdRpc,
45
+ SaveMdRpcFailure
46
+ };
package/dist/tui.js ADDED
@@ -0,0 +1,163 @@
1
+ // @bun
2
+ // src/rpc.ts
3
+ import { Rpc } from "@opencode-ai/plugin/rpc";
4
+ import { z } from "zod";
5
+ var NoDetails = z.object({});
6
+ var InvalidPathDetails = z.object({ name: z.string() });
7
+ var DestinationDetails = z.object({ path: z.string() });
8
+ var SaveMdRpc = Rpc.define({
9
+ id: "save-md",
10
+ methods: {
11
+ save: {
12
+ input: z.object({
13
+ sessionID: z.string().min(1),
14
+ name: z.string().min(1)
15
+ }),
16
+ output: z.object({ path: z.string() }),
17
+ errors: {
18
+ no_assistant_response: NoDetails,
19
+ no_markdown_text: NoDetails,
20
+ invalid_path: InvalidPathDetails,
21
+ destination_exists: DestinationDetails,
22
+ filesystem_write_failed: DestinationDetails
23
+ }
24
+ }
25
+ },
26
+ events: {}
27
+ });
28
+ var SaveMdRpcFailure = z.discriminatedUnion("type", [
29
+ z.object({ type: z.literal("no_assistant_response"), message: z.string(), data: NoDetails }),
30
+ z.object({ type: z.literal("no_markdown_text"), message: z.string(), data: NoDetails }),
31
+ z.object({ type: z.literal("invalid_path"), message: z.string(), data: InvalidPathDetails }),
32
+ z.object({
33
+ type: z.literal("destination_exists"),
34
+ message: z.string(),
35
+ data: DestinationDetails
36
+ }),
37
+ z.object({
38
+ type: z.literal("filesystem_write_failed"),
39
+ message: z.string(),
40
+ data: DestinationDetails
41
+ })
42
+ ]);
43
+
44
+ // src/tui.tsx
45
+ import { Plugin } from "@opencode-ai/plugin/tui";
46
+ import { Cause, Effect, Option, Semaphore } from "effect";
47
+ var TITLE = "Save Markdown";
48
+ var tui_default = Plugin.define({
49
+ id: "save-md",
50
+ setup(context) {
51
+ const rpc = context.client.rpc(SaveMdRpc);
52
+ const workflows = Semaphore.makeUnsafe(1);
53
+ function showFailure(error) {
54
+ const parsed = SaveMdRpcFailure.safeParse(error);
55
+ if (!parsed.success) {
56
+ context.ui.toast.show({
57
+ title: TITLE,
58
+ message: error instanceof Error ? error.message : String(error),
59
+ variant: "error"
60
+ });
61
+ return;
62
+ }
63
+ const failure = parsed.data;
64
+ const warning = failure.type === "no_assistant_response" || failure.type === "no_markdown_text" || failure.type === "invalid_path" || failure.type === "destination_exists";
65
+ context.ui.toast.show({
66
+ title: TITLE,
67
+ message: failure.message,
68
+ variant: warning ? "warning" : "error"
69
+ });
70
+ }
71
+ const runWorkflow = Effect.fn("SaveMd.runWorkflow")(function* (input) {
72
+ const route = yield* Effect.sync(() => context.ui.router.current());
73
+ if (route.type !== "session") {
74
+ yield* Effect.sync(() => context.ui.toast.show({
75
+ title: TITLE,
76
+ message: "Open a session before saving an assistant response.",
77
+ variant: "warning"
78
+ }));
79
+ return;
80
+ }
81
+ const name = input?.trim();
82
+ if (!name) {
83
+ yield* Effect.sync(() => context.ui.toast.show({
84
+ title: TITLE,
85
+ message: "Provide a destination name, for example: /save-md design",
86
+ variant: "warning"
87
+ }));
88
+ return;
89
+ }
90
+ const session = yield* Effect.sync(() => context.data.session.get(route.sessionID));
91
+ if (!session) {
92
+ yield* Effect.sync(() => context.ui.toast.show({
93
+ title: TITLE,
94
+ message: "The active session is not available yet.",
95
+ variant: "warning"
96
+ }));
97
+ return;
98
+ }
99
+ const location = {
100
+ directory: session.location.directory,
101
+ workspace: session.location.workspaceID
102
+ };
103
+ const result = yield* Effect.tryPromise({
104
+ try: (signal) => rpc.save({
105
+ sessionID: route.sessionID,
106
+ name
107
+ }, {
108
+ location,
109
+ signal
110
+ }),
111
+ catch: (error) => error
112
+ });
113
+ yield* Effect.sync(() => context.ui.toast.show({
114
+ title: TITLE,
115
+ message: `Saved ${context.ui.format.path(result.path)}`,
116
+ variant: "success"
117
+ }));
118
+ });
119
+ function run(input) {
120
+ return Effect.runPromise(workflows.withPermitsIfAvailable(1)(runWorkflow(input)).pipe(Effect.flatMap((result) => {
121
+ if (Option.isSome(result))
122
+ return Effect.void;
123
+ return Effect.sync(() => context.ui.toast.show({
124
+ title: TITLE,
125
+ message: "A save workflow is already running.",
126
+ variant: "warning"
127
+ }));
128
+ }), Effect.catchCause((cause) => {
129
+ if (Cause.hasInterruptsOnly(cause))
130
+ return Effect.void;
131
+ return Effect.sync(() => showFailure(Cause.squash(cause)));
132
+ })));
133
+ }
134
+ function AppExtensions() {
135
+ context.keymap.layer(() => ({
136
+ mode: "global",
137
+ priority: 10,
138
+ commands: [{
139
+ id: "save-md.save",
140
+ title: "Save assistant response as Markdown",
141
+ group: "Markdown",
142
+ description: "Save the latest assistant response in the server workspace.",
143
+ palette: true,
144
+ suggested: true,
145
+ bind: false,
146
+ slash: {
147
+ name: "save-md",
148
+ arguments: true
149
+ },
150
+ run
151
+ }]
152
+ }));
153
+ return [];
154
+ }
155
+ return context.ui.slot({
156
+ append: "app",
157
+ render: AppExtensions
158
+ });
159
+ }
160
+ });
161
+ export {
162
+ tui_default as default
163
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@azatakmyradov/opencode-save-md-plugin",
3
+ "version": "0.0.0",
4
+ "description": "Save the latest OpenCode assistant response as Markdown",
5
+ "keywords": [
6
+ "markdown",
7
+ "opencode",
8
+ "opencode-plugin",
9
+ "tui"
10
+ ],
11
+ "homepage": "https://github.com/azatakmyradov/opencode-plugins/tree/main/packages/save-md",
12
+ "bugs": "https://github.com/azatakmyradov/opencode-plugins/issues",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/azatakmyradov/opencode-plugins.git",
17
+ "directory": "packages/save-md"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "type": "module",
24
+ "exports": {
25
+ ".": "./dist/index.js",
26
+ "./tui": "./dist/tui.js",
27
+ "./rpc": "./dist/rpc.js"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "build": "bun ../../scripts/build.ts .",
34
+ "check": "tsc --noEmit",
35
+ "prepack": "bun run build",
36
+ "test": "vp test run"
37
+ },
38
+ "dependencies": {
39
+ "@opencode-ai/client": "0.0.0-beta-18721",
40
+ "@opencode-ai/plugin": "0.0.0-beta-18721",
41
+ "@opencode-ai/schema": "0.0.0-beta-18721",
42
+ "effect": "4.0.0-rc.112",
43
+ "zod": "4.1.8"
44
+ },
45
+ "peerDependencies": {
46
+ "@opentui/core": ">=0.5.9",
47
+ "@opentui/solid": ">=0.5.9",
48
+ "solid-js": ">=1.9.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "@opentui/core": {
52
+ "optional": true
53
+ },
54
+ "@opentui/solid": {
55
+ "optional": true
56
+ },
57
+ "solid-js": {
58
+ "optional": true
59
+ }
60
+ }
61
+ }