@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/.opencode/lib/delegation-context.js +202 -0
- package/.opencode/lib/host-contract.js +90 -0
- package/.opencode/lib/opencode-v2-adapter.js +739 -0
- package/.opencode/lib/runtime.js +5381 -0
- package/.opencode/package.json +1 -1
- package/.opencode/plugins/codemem.js +141 -4677
- package/README.md +37 -3
- package/index.d.ts +19 -0
- package/index.js +11 -2
- package/package.json +16 -5
- package/src/opencode-v2-contract-fixture.ts +367 -0
- package/v2-contract-fixture/index.ts +1 -0
- package/v2-contract-fixture/package.json +8 -0
- package/.opencode/plugins/runtime.js +0 -1
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
|
-
|
|
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
|
|
29
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
26
|
+
"@opencode-ai/plugin": "1.18.29"
|
|
22
27
|
},
|
|
23
28
|
"devDependencies": {
|
|
24
|
-
"@
|
|
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
|
|
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";
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { default } from "./codemem.js";
|