open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
125 lines (113 loc) • 4.33 kB
text/typescript
import { z } from "zod";
import {
PLUGIN_ID,
bootstrapPlugin,
captureFromText,
captureHandoff,
injectOnce,
type PluginState,
} from "./plugin-core.ts";
import { makeTools } from "./tools/memory.ts";
/**
* OpenCode 2 adapter (D60). The v2 host structurally decodes the plugin's
* default export as `{ id, setup }` — `Plugin.define` is an identity
* wrapper, so a plain object with no runtime import of `@opencode/plugin`
* loads identically. Importing the SDK at runtime would repeat the D58
* failure (devDependency, unresolvable from a global install, silent
* skip); the context is duck-typed here from the 2.0.x surface instead.
*
* v1/v2 hook mapping used below:
* tool map → ctx.tool.transform (JSON Schema in,
* { content } out)
* chat.message (keyword capture) → ctx.session.hook("prompt") — fires
* once per submitted prompt
* experimental.chat.system.transform → ctx.session.hook("context") — runs
* before each model request; the
* system array is mutable
*
* Tool registration must opt out of code mode (`options.codemode: false`,
* verified on 2.0.1): by default the host exposes plugin tools only
* through an `execute` meta-tool that runs model-written JS, and a model
* that calls the tool by name instead gets "Unknown tool" — flaky,
* model-dependent behavior. With the opt-out the five tools are plain
* function calls, same as on v1 and MCP.
*/
interface V2ToolInfo {
name: string;
description: string;
input: unknown;
options?: { codemode?: boolean };
execute: (input: Record<string, unknown>) => Promise<{ content: string }>;
}
interface V2Registration {
dispose?: () => Promise<void>;
}
export interface V2PluginContext {
app?: { version?: string };
location?: { directory?: string; project?: { directory?: string } };
tool: {
transform: (cb: (editor: { add: (tool: V2ToolInfo) => void }) => void) => Promise<unknown>;
};
session: {
hook: (
name: "prompt" | "context",
cb: (event: never) => void | Promise<void>,
) => Promise<V2Registration>;
};
}
interface V2PromptEvent {
sessionID: string;
prompt?: { text?: string };
}
interface V2ContextEvent {
sessionID: string;
system: unknown[];
}
/** Register the five memory tools on a v2 host. */
async function registerTools(ctx: V2PluginContext, state: PluginState): Promise<void> {
const tools = makeTools(() => state.scope, state.cfg);
await ctx.tool.transform((editor) => {
for (const [name, t] of Object.entries(tools)) {
editor.add({
name,
description: t.description,
input: z.toJSONSchema(z.object(t.args)),
options: { codemode: false },
execute: async (input) => {
const r = await t.execute(input as never, undefined as never);
const content =
typeof r === "string" ? r : ((r as { output?: string }).output ?? JSON.stringify(r));
return { content };
},
});
}
});
}
export const v2Plugin = {
id: PLUGIN_ID,
async setup(ctx: V2PluginContext) {
const root = ctx.location?.directory ?? ctx.location?.project?.directory ?? process.cwd();
const state = bootstrapPlugin(root);
const registrations: V2Registration[] = [];
await registerTools(ctx, state);
registrations.push(
await ctx.session.hook("prompt", (ev: V2PromptEvent) => {
captureFromText(ev.prompt?.text ?? "", state, ev.sessionID ?? "");
}),
);
registrations.push(
await ctx.session.hook("context", (ev: V2ContextEvent) => {
const sessionID = ev.sessionID ?? "";
injectOnce(sessionID, state, (block) => {
ev.system.push({ type: "text", text: block });
});
// D73: tell the agent what the keyword hook already stored this turn.
const handoff = captureHandoff(sessionID, state);
if (handoff) ev.system.push({ type: "text", text: handoff });
}),
);
return () => {
for (const r of registrations) void r.dispose?.();
};
},
};