Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,16 @@ If a shard becomes incompatible (for example after changing `embeddingDimensions

## Getting Started

Add to your OpenCode configuration at `~/.config/opencode/opencode.json`:
For OpenCode v2, use the native v2 entrypoint:

```jsonc
{
"plugins": ["opencode-mem/v2"],
}
```

For OpenCode v1, add the default entrypoint to your configuration at
`~/.config/opencode/opencode.json`:

```jsonc
{
Expand Down
503 changes: 500 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"import": "./dist/plugin.js",
"types": "./dist/index.d.ts"
},
"./v2": {
"import": "./dist/v2/plugin.js",
"types": "./dist/v2/plugin.d.ts"
},
"./tags": {
"import": "./dist/services/tags.js",
"types": "./dist/services/tags.d.ts"
Expand Down Expand Up @@ -66,6 +70,7 @@
"bun-types": "1.3.14"
},
"devDependencies": {
"@opencode/plugin": "^2.0.3",
"@types/bun": "^1.4.0",
"husky": "^9.1.7",
"lint-staged": "^17.3.0",
Expand Down
133 changes: 133 additions & 0 deletions src/v2/legacy-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { Context } from "@opencode/plugin/promise/plugin";
import { randomUUID } from "node:crypto";
import { resolve } from "node:path";

function textFromParts(parts: Array<{ type?: string; text?: string }> = []): string {
return parts.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n");
}

function parseJson(text: string): unknown {
const trimmed = text.trim();
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1];
const candidate = fenced ?? trimmed;
try {
return JSON.parse(candidate);
} catch {
const start = candidate.indexOf("{");
const end = candidate.lastIndexOf("}");
if (start >= 0 && end > start) return JSON.parse(candidate.slice(start, end + 1));
throw new Error("Model did not return a JSON object");
}
}

function legacyMessage(message: any, sessionID: string): any {
if (message.type === "user" || message.type === "synthetic" || message.type === "system") {
return {
info: { id: message.id, sessionID, role: "user", agent: message.agent },
parts: [{ id: `${message.id}-text`, sessionID, messageID: message.id, type: "text", text: message.text ?? "", synthetic: message.type !== "user" }],
};
}
if (message.type === "assistant") {
return {
info: { id: message.id, sessionID, role: "assistant", agent: message.agent, mode: message.agent },
parts: (message.content ?? []).map((part: any, index: number) => ({
id: part.id ?? `${message.id}-${index}`,
sessionID,
messageID: message.id,
...part,
})),
};
}
if (message.type === "compaction") {
return { info: { id: message.id, sessionID, role: "assistant", summary: true, mode: "compaction" }, parts: [] };
}
return { info: { id: message.id ?? randomUUID(), sessionID, role: "assistant" }, parts: [] };
}

export function createLegacyClient(ctx: Context) {
const ephemeral = new Set<string>();
return {
app: { log: async () => ({ data: true }) },
provider: { list: async () => ({ data: { connected: [] } }) },
tui: {
showToast: async () => ({ data: false }),
appendPrompt: async () => ({ data: false }),
submitPrompt: async () => ({ data: false }),
},
session: {
get: async ({ path }: any) => ({ data: await ctx.session.get({ sessionID: path.id }) }),
messages: async ({ path }: any) => ({
data: (await ctx.session.context({ sessionID: path.id })).map((message: any) =>
legacyMessage(message, path.id)
),
}),
create: async (input: any = {}) => {
const id = randomUUID();
ephemeral.add(id);
return { data: { id, parentID: input.body?.parentID } };
},
prompt: async ({ path, body }: any) => {
const sessionID = path.id;
if (ephemeral.has(sessionID)) {
const model = body?.model?.providerID && body?.model?.modelID
? { providerID: body.model.providerID, id: body.model.modelID }
: undefined;
const prompt = [body?.system, textFromParts(body?.parts)].filter(Boolean).join("\n\n");
const generated = await ctx.generate.text({ prompt, ...(model ? { model } : {}) });
const text = generated?.text ?? "";
const structured = body?.format?.type === "json_schema" ? parseJson(text) : undefined;
return { data: { info: { id: randomUUID(), role: "assistant", structured_output: structured }, parts: [{ type: "text", text }] } };
}
const text = textFromParts(body?.parts);
if (body?.noReply) {
const data = await ctx.session.synthetic({ sessionID, text, description: "memory context", metadata: body?.parts?.[0]?.metadata });
return { data, response: new Response(null, { status: 200 }) };
}
const data = await ctx.session.prompt({ sessionID, text, delivery: "queue", metadata: body?.parts?.[0]?.metadata });
return { data, response: new Response(null, { status: 200 }) };
},
abort: async () => ({ data: true }),
delete: async ({ path }: any) => {
ephemeral.delete(path.id);
return { data: true };
},
},
} as any;
}

export function legacyToolResult(value: unknown): { content: string; metadata?: unknown } {
if (typeof value === "string") return { content: value };
if (value && typeof value === "object" && typeof (value as any).output === "string") {
return { content: (value as any).output, metadata: (value as any).metadata };
}
return { content: JSON.stringify(value ?? null) };
}

export function toLegacyEvent(raw: any): { type: string; properties: any } {
const envelope = raw?.payload ?? raw;
const source = envelope?.type === "sync" && envelope.syncEvent ? envelope.syncEvent : envelope;
const type = typeof source?.type === "string" ? source.type.replace(/\.1$/, "") : source?.type;
const data = source?.data ?? {};
if (source && typeof source === "object" && "properties" in source) {
return { type, properties: source.properties };
}
if (type === "session.created" || type === "session.updated") {
return { type, properties: { info: data.session ?? data.info ?? data } };
}
return { type, properties: data };
}

export async function eventBelongsToLocation(ctx: Context, raw: any): Promise<boolean> {
const directory = raw?.directory ?? raw?.payload?.directory ?? raw?.data?.info?.directory;
if (typeof directory === "string") return resolve(directory) === resolve(ctx.location.directory);
const data = raw?.data ?? raw?.payload?.data ?? raw?.properties;
const sessionID = data?.sessionID ?? data?.session?.id ?? data?.info?.id;
if (!sessionID) return false;
try {
const session: any = await ctx.session.get({ sessionID });
const sessionDirectory = session?.directory ?? session?.data?.directory;
return typeof sessionDirectory === "string" && resolve(sessionDirectory) === resolve(ctx.location.directory);
} catch {
return false;
}
}
107 changes: 107 additions & 0 deletions src/v2/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Plugin } from "@opencode/plugin/promise/plugin";
import { OpenCodeMemPlugin } from "../index.js";
import {
createLegacyClient,
eventBelongsToLocation,
legacyToolResult,
toLegacyEvent,
} from "./legacy-client.js";

const memoryInput = {
type: "object",
properties: {
mode: { type: "string", enum: ["add", "search", "profile", "list", "forget", "help", "migrate", "list-shards", "export", "import"] },
content: { type: "string" },
query: { type: "string" },
tags: { type: "string" },
type: { type: "string" },
memoryId: { type: "string" },
limit: { type: "number" },
scope: { type: "string", enum: ["project", "all-projects"] },
fromPath: { type: "string" },
fromHash: { type: "string" },
outputPath: { type: "string" },
inputPath: { type: "string" },
dryRun: { type: "boolean" },
allowLinkedSource: { type: "boolean" },
},
additionalProperties: false,
} as const;

const OpenCodeMemPluginV2: Plugin = {
id: "opencode-mem",
async setup(ctx) {
const legacy = await OpenCodeMemPlugin({
client: createLegacyClient(ctx),
directory: ctx.location.directory,
worktree: ctx.location.project.directory,
project: ctx.location.project,
serverUrl: undefined,
} as any) as any;
const contexts = new Map<string, string>();
const messageIDs = new Map<string, string>();

await ctx.tool.transform((editor) => editor.add({
name: "memory",
description: legacy.tool.memory.description,
input: memoryInput as any,
execute: async (args: any, toolContext: any) => legacyToolResult(await legacy.tool.memory.execute(args, {
sessionID: toolContext.sessionID,
messageID: toolContext.messageID,
agent: toolContext.agent,
directory: ctx.location.directory,
worktree: ctx.location.project.directory,
abort: new AbortController().signal,
metadata() {},
async ask() {},
})) as any,
} as any));

await ctx.session.hook("prompt", async (event) => {
messageIDs.set(event.sessionID, event.messageID);
contexts.delete(event.sessionID);
if (!legacy["chat.message"]) return;
const original = { type: "text", text: event.prompt.text };
const output = { message: { id: event.messageID }, parts: [original] };
await legacy["chat.message"]({ sessionID: event.sessionID }, output);
const injected = output.parts
.filter((part: any) => part !== original && part?.type === "text")
.map((part: any) => part.text)
.join("\n");
if (injected) contexts.set(event.sessionID, injected);
});

await ctx.session.hook("context", async (event) => {
const injected = contexts.get(event.sessionID);
if (injected) event.system.push({ type: "text", text: injected });
if (legacy["chat.params"]) {
await legacy["chat.params"]({
message: { id: messageIDs.get(event.sessionID) ?? event.sessionID },
model: { providerID: event.model.providerID, id: event.model.id },
});
}
});

const controller = new AbortController();
const watcher = (async () => {
if (!legacy.event) return;
try {
for await (const raw of ctx.event.subscribe({ signal: controller.signal })) {
if (await eventBelongsToLocation(ctx, raw)) {
await legacy.event({ event: toLegacyEvent(raw) });
}
}
} catch (error) {
if (!controller.signal.aborted) console.error("opencode-mem event bridge failed", error);
}
})();

return async () => {
controller.abort();
await watcher;
await legacy.dispose?.();
};
},
};

export default OpenCodeMemPluginV2;
9 changes: 9 additions & 0 deletions tests/plugin-v2-loader-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { describe, expect, it } from "bun:test";

describe("OpenCode v2 plugin-loader contract", () => {
it("exports a native v2 plugin definition", async () => {
const mod = await import(new URL("../dist/v2/plugin.js", import.meta.url).href);
expect(mod.default.id).toBe("opencode-mem");
expect(typeof mod.default.setup).toBe("function");
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@
"noPropertyAccessFromIndexSignature": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/v2"]
"exclude": ["node_modules", "dist"]
}