@codemem/opencode-plugin 0.44.2 → 0.45.0-rc.1

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
@@ -2,6 +2,10 @@
2
2
 
3
3
  Persistent memory plugin for [OpenCode](https://opencode.ai).
4
4
 
5
+ Requires OpenCode 1.18.29 or newer. The same package also runs on OpenCode 2,
6
+ validated against the exact stable `@opencode/cli@2.0.2` and
7
+ `@opencode/plugin@2.0.2` releases. Codemem's OpenCode 2 integration is beta.
8
+
5
9
  ## Install
6
10
 
7
11
  Recommended:
@@ -21,12 +25,42 @@ Manual config also works. Add the package name to your OpenCode config:
21
25
 
22
26
  OpenCode installs npm plugins automatically with Bun at startup.
23
27
 
28
+ Setup keeps the singular `plugin` key because OpenCode 1 requires it and
29
+ OpenCode 2 translates it into its native `plugins` configuration. Keep one Codemem
30
+ entry: if OpenCode loads Codemem twice for one project (for example a configured
31
+ npm plugin plus a checkout-local `.opencode/plugins/` copy), the first
32
+ registration wins and later copies skip their hooks with a warning in
33
+ `~/.codemem/plugin.log`.
34
+
35
+ ## Troubleshooting and rollback
36
+
37
+ - No automatic recall on OpenCode 2: recall requires a non-empty latest
38
+ user-message ID in `session.context`. When the ID is missing or blank, the
39
+ turn skips recall instead of guessing. Capture and the manual tools still work.
40
+ - Duplicate registration warning: remove either the configured npm entry or the
41
+ project-local copy so only one Codemem plugin loads.
42
+ - Stop the OpenCode 2 path: set `CODEMEM_PLUGIN_IGNORE=1` in the environment that
43
+ launches OpenCode 2, or remove the plugin entry from that host's config.
44
+ OpenCode 1 keeps working from the same package.
45
+ - Return to OpenCode 1: both hosts share one raw-event stream and SQLite
46
+ database, so switching hosts needs no storage migration.
47
+
24
48
  ## Exports
25
49
 
26
- `CodememPlugin` is the canonical named export and the package default export.
50
+ The package default export is one dual-host object. OpenCode 1 calls its
51
+ `server()` function, while OpenCode 2 calls its `setup()` function. The OpenCode 2
52
+ setup captures conversation, tool, usage, and lifecycle activity and disposes its
53
+ host registrations on unload. It exposes the manual `mem-status`, `mem-recent`,
54
+ and `mem-stats` tools through `tool.transform` with `codemode: false`, keeping
55
+ the hyphenated IDs. OpenCode 2.0.2 automatic recall runs through
56
+ `session.context` when the latest user message has a non-empty ID. Each identified turn performs one fresh retrieval, while
57
+ retries and tool continuations replay retained context. Missing or blank identity
58
+ skips recall safely, and auxiliary hooks remain isolated.
59
+
60
+ `CodememPlugin` remains the canonical named OpenCode 1 function export.
27
61
  `OpencodeMemPlugin` remains available as a deprecated, reference-identical alias
28
- for compatibility with integrations created before the Codemem rename. No removal
29
- version is currently scheduled.
62
+ for integrations created before the Codemem rename. No removal version is
63
+ currently scheduled.
30
64
 
31
65
  ## Documentation
32
66
 
package/index.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Plugin as OpenCodeV1Plugin } from "@opencode-ai/plugin";
2
+
3
+ export type CodememV2Setup = (
4
+ context: unknown,
5
+ ) => Promise<(() => Promise<void> | void) | void> | (() => Promise<void> | void) | void;
6
+
7
+ export type CodememDualPlugin = {
8
+ readonly id: string;
9
+ readonly server: OpenCodeV1Plugin;
10
+ readonly setup: CodememV2Setup;
11
+ };
12
+
13
+ export declare const CodememPlugin: OpenCodeV1Plugin;
14
+
15
+ /** @deprecated Use CodememPlugin. */
16
+ export declare const OpencodeMemPlugin: typeof CodememPlugin;
17
+
18
+ declare const plugin: CodememDualPlugin;
19
+ export default plugin;
package/index.js CHANGED
@@ -1,5 +1,14 @@
1
- export {
2
- CodememPlugin as default,
1
+ import {
3
2
  CodememPlugin,
4
3
  OpencodeMemPlugin,
5
4
  } from "./.opencode/plugins/codemem.js";
5
+ import { setupOpenCodeV2 } from "./.opencode/lib/opencode-v2-adapter.js";
6
+
7
+ const CodememDualPlugin = {
8
+ id: "codemem",
9
+ server: CodememPlugin,
10
+ setup: setupOpenCodeV2,
11
+ };
12
+
13
+ export default CodememDualPlugin;
14
+ export { CodememPlugin, OpencodeMemPlugin };
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "@codemem/opencode-plugin",
3
- "version": "0.44.2",
3
+ "version": "0.45.0-rc.1",
4
4
  "description": "CodeMem plugin for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
+ "types": "./index.d.ts",
7
8
  "exports": {
8
9
  ".": {
10
+ "types": "./index.d.ts",
9
11
  "import": "./index.js"
10
12
  }
11
13
  },
12
14
  "files": [
13
15
  "index.js",
16
+ "index.d.ts",
17
+ "src/opencode-v2-contract-fixture.ts",
18
+ "v2-contract-fixture/",
14
19
  "README.md",
15
20
  "LICENSE",
16
21
  ".opencode/package.json",
@@ -18,10 +23,13 @@
18
23
  ".opencode/lib/"
19
24
  ],
20
25
  "dependencies": {
21
- "@opencode-ai/plugin": "1.18.25"
26
+ "@opencode-ai/plugin": "1.18.29"
22
27
  },
23
28
  "devDependencies": {
24
- "@types/node": "^24.12.4"
29
+ "@opencode/cli": "2.0.2",
30
+ "@opencode/plugin": "2.0.2",
31
+ "@types/node": "^24.12.4",
32
+ "vitest": "^4.1.11"
25
33
  },
26
34
  "repository": {
27
35
  "type": "git",
@@ -33,7 +41,8 @@
33
41
  "url": "https://github.com/kunickiaj/codemem/issues"
34
42
  },
35
43
  "engines": {
36
- "node": ">=24.15.0"
44
+ "node": ">=24.15.0",
45
+ "opencode": ">=1.18.29"
37
46
  },
38
47
  "license": "MIT",
39
48
  "publishConfig": {
@@ -49,6 +58,8 @@
49
58
  "plugin"
50
59
  ],
51
60
  "scripts": {
52
- "test:packed-artifact": "node ./scripts/packed-artifact-smoke.mjs"
61
+ "test": "pnpm exec vitest run",
62
+ "test:opencode-v2-contract": "node ./scripts/packed-v2-host-smoke.mjs",
63
+ "test:packed-artifact": "pnpm --filter codemem build && node ./scripts/packed-artifact-smoke.mjs"
53
64
  }
54
65
  }
@@ -0,0 +1,367 @@
1
+ import { appendFile } from "node:fs/promises";
2
+ import { Plugin } from "@opencode/plugin";
3
+
4
+ const SYSTEM_PART_CONTRACT_TEXT = "codemem-v2-system-part-contract";
5
+ const MESSAGE_PART_CONTRACT_TEXT = "codemem-v2-message-part-contract";
6
+
7
+ export const OPEN_CODE_V2_CONTRACT_VERSION = "2.0.2";
8
+ export const OPEN_CODE_V2_CONTEXT_MARKER = "codemem-v2-context-hook-applied";
9
+
10
+ export type ContractRecord = Readonly<Record<string, unknown>>;
11
+ export type ContractReporter = (record: ContractRecord) => Promise<void> | void;
12
+
13
+ type Disposable = { readonly dispose: () => Promise<void> };
14
+
15
+ function identity(input: {
16
+ readonly sessionID: unknown;
17
+ readonly agent: unknown;
18
+ readonly model: unknown;
19
+ }) {
20
+ return JSON.stringify([input.sessionID, input.agent, input.model]);
21
+ }
22
+
23
+ export function userMessageIDs(messages: readonly unknown[]) {
24
+ return messages.flatMap((message) => {
25
+ if (message == null || typeof message !== "object") return [];
26
+ const candidate = message as Record<string, unknown>;
27
+ if (candidate.role !== "user" || typeof candidate.id !== "string") return [];
28
+ return candidate.id.trim().length > 0 ? [candidate.id] : [];
29
+ });
30
+ }
31
+
32
+ export function latestUserMessageID(messages: readonly unknown[]) {
33
+ const latestUser = messages.findLast((message) => {
34
+ if (message == null || typeof message !== "object") return false;
35
+ return (message as Record<string, unknown>).role === "user";
36
+ });
37
+ if (latestUser == null || typeof latestUser !== "object") return null;
38
+ const id = (latestUser as Record<string, unknown>).id;
39
+ return typeof id === "string" && id.trim().length > 0 ? id : null;
40
+ }
41
+
42
+ export function summarizeEvent(event: unknown): ContractRecord {
43
+ if (event == null || typeof event !== "object") return { family: "generic", type: "unknown" };
44
+ const candidate = event as Record<string, unknown>;
45
+ const type = typeof candidate.type === "string" ? candidate.type : "unknown";
46
+ const family = ["session", "message", "tool"].find((prefix) => type.startsWith(`${prefix}.`));
47
+ const summary: ContractRecord = {
48
+ family: family ?? "generic",
49
+ type,
50
+ hasProperties: candidate.properties != null,
51
+ };
52
+ const data = candidate.data as Record<string, unknown> | undefined;
53
+ if (type === "session.inbox.enqueued") {
54
+ const item = data?.item as Record<string, unknown> | undefined;
55
+ const payload = item?.payload as Record<string, unknown> | undefined;
56
+ return {
57
+ ...summary,
58
+ hasInboxID: typeof data?.inboxID === "string",
59
+ hasSessionID: typeof data?.sessionID === "string",
60
+ hasTextPayload: typeof payload?.text === "string",
61
+ isUserItem: item?.type === "user",
62
+ };
63
+ }
64
+ if (type === "session.step.ended") {
65
+ return {
66
+ ...summary,
67
+ finish: typeof data?.finish === "string" ? data.finish : null,
68
+ hasAssistantMessageID: typeof data?.assistantMessageID === "string",
69
+ hasFinish: typeof data?.finish === "string",
70
+ hasSessionID: typeof data?.sessionID === "string",
71
+ hasTokens: data?.tokens != null && typeof data.tokens === "object",
72
+ };
73
+ }
74
+ if (type.startsWith("session.execution.")) {
75
+ return { ...summary, hasSessionID: typeof data?.sessionID === "string" };
76
+ }
77
+ return summary;
78
+ }
79
+
80
+ function defaultReporter(record: ContractRecord) {
81
+ const reportPath = process.env.CODEMEM_OPENCODE_V2_CONTRACT_REPORT;
82
+ if (!reportPath) return;
83
+ return appendFile(reportPath, `${JSON.stringify(record)}\n`, "utf8");
84
+ }
85
+
86
+ async function probeStorage(context: Plugin.Context, report: ContractReporter) {
87
+ const key = "codemem-contract/probe";
88
+ const expected = { version: OPEN_CODE_V2_CONTRACT_VERSION };
89
+ await context.storage.set(key, expected);
90
+ const value = await context.storage.get(key);
91
+ await context.storage.remove(key);
92
+ const removed = await context.storage.get(key);
93
+ await report({
94
+ phase: "storage",
95
+ removed: removed == null,
96
+ valueMatches: JSON.stringify(value) === JSON.stringify(expected),
97
+ });
98
+ }
99
+
100
+ async function consumeEvents(
101
+ context: Plugin.Context,
102
+ signal: AbortSignal,
103
+ report: ContractReporter,
104
+ ) {
105
+ try {
106
+ for await (const event of context.event.subscribe({ signal })) {
107
+ await report({ phase: "event", ...summarizeEvent(event) });
108
+ }
109
+ await report({ phase: "event.end", aborted: signal.aborted });
110
+ } catch (error) {
111
+ if (signal.aborted && error instanceof Error && error.name === "AbortError") {
112
+ await report({ phase: "event.end", aborted: true });
113
+ return;
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ async function registerContextHook(context: Plugin.Context, report: ContractReporter) {
120
+ const seenSystems = new WeakSet<object>();
121
+ const seenMessages = new WeakSet<object>();
122
+ const seenTools = new WeakSet<object>();
123
+ return context.session.hook("context", async (input) => {
124
+ const { system, messages, tools } = input;
125
+ const alreadyMarked = input.options.codememContract === true;
126
+ const systemAlreadyMarked = system.some(
127
+ (part) => part.type === "text" && part.text === OPEN_CODE_V2_CONTEXT_MARKER,
128
+ );
129
+ const systemReused = seenSystems.has(system);
130
+ const messagesReused = seenMessages.has(messages);
131
+ const toolsReused = seenTools.has(tools);
132
+ seenSystems.add(system);
133
+ seenMessages.add(messages);
134
+ seenTools.add(tools);
135
+ input.system = [
136
+ ...system,
137
+ { type: "text", text: OPEN_CODE_V2_CONTEXT_MARKER },
138
+ { type: "text", text: SYSTEM_PART_CONTRACT_TEXT },
139
+ ];
140
+ const latestUserIndex = messages.findLastIndex((message) => message.role === "user");
141
+ input.messages = messages.map((message, index) => {
142
+ if (index !== latestUserIndex || message.role !== "user") return { ...message };
143
+ return {
144
+ ...message,
145
+ content: [
146
+ ...message.content,
147
+ {
148
+ type: "text",
149
+ text: MESSAGE_PART_CONTRACT_TEXT,
150
+ metadata: { codememPart: { v: 1, synthetic: true } },
151
+ },
152
+ ],
153
+ };
154
+ });
155
+ const latestUser = input.messages.findLast((message) => message.role === "user");
156
+ input.tools = { ...tools };
157
+ input.options = { ...input.options, codememContract: true };
158
+ seenSystems.add(input.system);
159
+ seenMessages.add(input.messages);
160
+ seenTools.add(input.tools);
161
+ await report({
162
+ phase: "context",
163
+ agent: input.agent,
164
+ alreadyMarked,
165
+ hasAgent: Boolean(input.agent),
166
+ hasModel: Boolean(input.model),
167
+ hasSessionID: Boolean(input.sessionID),
168
+ latestUserMessageID: latestUserMessageID(input.messages),
169
+ messagesMutable: Array.isArray(input.messages),
170
+ messagesReused,
171
+ messagePartAccepted:
172
+ latestUser?.role === "user" &&
173
+ latestUser.content.some(
174
+ (part) => part.type === "text" && part.text === MESSAGE_PART_CONTRACT_TEXT,
175
+ ),
176
+ model: input.model,
177
+ optionsMutable: typeof input.options === "object",
178
+ sessionID: input.sessionID,
179
+ systemMutable: Array.isArray(input.system),
180
+ systemAlreadyMarked,
181
+ systemReused,
182
+ systemPartAccepted: input.system.some(
183
+ (part) => part.type === "text" && part.text === SYSTEM_PART_CONTRACT_TEXT,
184
+ ),
185
+ toolsMutable: typeof input.tools === "object",
186
+ toolsReused,
187
+ userMessageIDs: userMessageIDs(input.messages),
188
+ });
189
+ });
190
+ }
191
+
192
+ async function registerAuxiliaryHook(
193
+ context: Plugin.Context,
194
+ report: ContractReporter,
195
+ phase: "compaction" | "generate" | "title",
196
+ ) {
197
+ return context.session.hook(phase, async (input) => {
198
+ const alreadyMarked = input.options.codememContract === true;
199
+ input.system = [...input.system];
200
+ input.messages = [...input.messages];
201
+ input.options = { ...input.options, codememContract: true };
202
+ await report({
203
+ phase,
204
+ alreadyMarked,
205
+ hasAgent: "agent" in input && Boolean(input.agent),
206
+ hasModel: Boolean(input.model),
207
+ hasSessionID: Boolean(input.sessionID),
208
+ latestUserMessageID: latestUserMessageID(input.messages),
209
+ messagesMutable: Array.isArray(input.messages),
210
+ optionsMutable: typeof input.options === "object",
211
+ sessionID: input.sessionID,
212
+ systemMutable: Array.isArray(input.system),
213
+ });
214
+ });
215
+ }
216
+
217
+ async function registerSessionHooks(
218
+ context: Plugin.Context,
219
+ report: ContractReporter,
220
+ registrations: Disposable[],
221
+ ) {
222
+ registrations.push(
223
+ await context.session.hook("prompt", async (input) => {
224
+ await report({
225
+ phase: "prompt",
226
+ hasMessageID: Boolean(input.messageID),
227
+ hasSessionID: Boolean(input.sessionID),
228
+ messageID: input.messageID,
229
+ sessionID: input.sessionID,
230
+ });
231
+ }),
232
+ );
233
+ registrations.push(await registerContextHook(context, report));
234
+ registrations.push(await registerAuxiliaryHook(context, report, "compaction"));
235
+ registrations.push(await registerAuxiliaryHook(context, report, "generate"));
236
+ registrations.push(await registerAuxiliaryHook(context, report, "title"));
237
+ registrations.push(
238
+ await context.session.hook("model.request", async (input) => {
239
+ input.headers["x-codemem-contract-kind"] = input.kind;
240
+ await report({ phase: "model.request", kind: input.kind, identity: identity(input) });
241
+ }),
242
+ );
243
+ registrations.push(
244
+ await context.session.hook("http.request", async (input) => {
245
+ await report({
246
+ phase: "http.request",
247
+ kind: input.kind,
248
+ kindHeader: input.request.headers.get("x-codemem-contract-kind"),
249
+ });
250
+ }),
251
+ );
252
+ registrations.push(
253
+ await context.session.hook("http.response", async (input) => {
254
+ await report({
255
+ phase: "http.response",
256
+ kind: input.kind,
257
+ kindHeader: input.request.headers.get("x-codemem-contract-kind"),
258
+ });
259
+ }),
260
+ );
261
+ registrations.push(
262
+ await context.session.hook("retry", async (input) => {
263
+ await report({
264
+ phase: "retry",
265
+ attempt: input.attempt,
266
+ retry: input.decision.retry,
267
+ hasKind: "kind" in input,
268
+ hasRequestID: "requestID" in input,
269
+ });
270
+ }),
271
+ );
272
+ }
273
+
274
+ async function registerToolContracts(
275
+ context: Plugin.Context,
276
+ report: ContractReporter,
277
+ registrations: Disposable[],
278
+ ) {
279
+ const after = await context.tool.hook("execute.after", async (input) => {
280
+ await report({
281
+ phase: "tool.execute.after",
282
+ hasAgent: Boolean(input.agent),
283
+ status: input.status,
284
+ hasCallID: Boolean(input.id),
285
+ hasInput: input.input != null,
286
+ hasMessageID: Boolean(input.messageID),
287
+ hasSessionID: Boolean(input.sessionID),
288
+ hasTool: Boolean(input.tool),
289
+ });
290
+ });
291
+ registrations.push(after);
292
+ let effectiveID: string | null = null;
293
+ const declaredName = "contract-probe";
294
+ const transform = await context.tool.transform((editor) => {
295
+ editor.add({
296
+ name: declaredName,
297
+ description: "OpenCode 2 contract-only tool",
298
+ input: { type: "object", properties: {}, additionalProperties: false },
299
+ execute: async () => ({ content: "contract-ok" }),
300
+ });
301
+ const added = editor.list().find((tool) => tool.name === declaredName);
302
+ effectiveID = added?.id ?? null;
303
+ });
304
+ registrations.push(transform);
305
+ await report({ phase: "tool.transform", declaredName, effectiveID });
306
+ }
307
+
308
+ async function disposeAll(registrations: readonly Disposable[]) {
309
+ let firstError: unknown;
310
+ for (const registration of [...registrations].reverse()) {
311
+ try {
312
+ await registration.dispose();
313
+ } catch (error) {
314
+ firstError ??= error;
315
+ }
316
+ }
317
+ if (firstError) throw firstError;
318
+ }
319
+
320
+ export function defineOpenCodeV2ContractFixture(report: ContractReporter = defaultReporter) {
321
+ return Plugin.define({
322
+ id: "codemem-v2-contract",
323
+ async setup(context) {
324
+ const abortController = new AbortController();
325
+ const registrations: Disposable[] = [];
326
+ try {
327
+ await report({
328
+ phase: "setup",
329
+ appVersion: context.app.version,
330
+ directory: context.location.directory,
331
+ projectDirectory: context.location.project.directory,
332
+ projectCanonical: context.location.project.canonical,
333
+ hasWorkspaceID: Boolean(context.location.workspaceID),
334
+ optionKeys: Object.keys(context.options).sort(),
335
+ hasLog: "log" in context.app,
336
+ hasToast: "toast" in context || "tui" in context,
337
+ });
338
+ await probeStorage(context, report);
339
+ await registerSessionHooks(context, report, registrations);
340
+ await registerToolContracts(context, report, registrations);
341
+ let eventError: unknown;
342
+ const eventTask = consumeEvents(context, abortController.signal, report).catch((error) => {
343
+ eventError = error;
344
+ });
345
+ return async () => {
346
+ abortController.abort();
347
+ let disposalError: unknown;
348
+ try {
349
+ await disposeAll(registrations);
350
+ } catch (error) {
351
+ disposalError = error;
352
+ }
353
+ await eventTask;
354
+ await report({ phase: "cleanup", disposed: registrations.length });
355
+ if (disposalError) throw disposalError;
356
+ if (eventError) throw eventError;
357
+ };
358
+ } catch (error) {
359
+ abortController.abort();
360
+ await disposeAll(registrations);
361
+ throw error;
362
+ }
363
+ },
364
+ });
365
+ }
366
+
367
+ export default defineOpenCodeV2ContractFixture();
@@ -0,0 +1 @@
1
+ export { default } from "../src/opencode-v2-contract-fixture.ts";
@@ -0,0 +1,8 @@
1
+ {
2
+ "private": true,
3
+ "type": "module",
4
+ "main": "./index.ts",
5
+ "peerDependencies": {
6
+ "@opencode/plugin": "2.0.2"
7
+ }
8
+ }
@@ -1 +0,0 @@
1
- export { default } from "./codemem.js";