@codemem/opencode-plugin 0.44.1 → 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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const MAX_BINDINGS = 128;
|
|
4
|
+
const MAX_BRIEF_CHARS = 64_000;
|
|
5
|
+
const BINDING_TTL_MS = 10 * 60_000;
|
|
6
|
+
const LOOKUP_TIMEOUT_MS = 200;
|
|
7
|
+
const digest = (text) => createHash("sha256").update(text).digest("hex");
|
|
8
|
+
const validId = (value) => typeof value === "string" && value.length > 0 && value.length <= 256;
|
|
9
|
+
const normalizedPromptText = (value) => typeof value === "string" ? value.trim() : null;
|
|
10
|
+
|
|
11
|
+
// Only this V1 adapter consumes the verified task metadata contract. A session's
|
|
12
|
+
// parent alone says nothing about who authored a particular user-role message.
|
|
13
|
+
// SDK-only callers must supply a complete message snapshot. The V1 adapter also
|
|
14
|
+
// requires the chat hook because its SDK can expose partially persisted parts.
|
|
15
|
+
export const createDelegationContext = ({
|
|
16
|
+
readSession, readMessage, now = Date.now, requirePromptSnapshot = false,
|
|
17
|
+
}) => {
|
|
18
|
+
const bindings = new Map();
|
|
19
|
+
const activeLookups = new Map();
|
|
20
|
+
const initializedAt = now();
|
|
21
|
+
let disposed = false;
|
|
22
|
+
const prune = () => {
|
|
23
|
+
for (const [key, binding] of bindings) {
|
|
24
|
+
if (now() - binding.observedAt > BINDING_TTL_MS) bindings.delete(key);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const observe = (event) => {
|
|
28
|
+
if (disposed) return;
|
|
29
|
+
prune();
|
|
30
|
+
if (event?.type === "session.deleted") {
|
|
31
|
+
const id = event.properties?.info?.id;
|
|
32
|
+
for (const binding of bindings.values()) {
|
|
33
|
+
if (binding.child === id || binding.parent === id) binding.closed = true;
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const part = event?.properties?.part;
|
|
38
|
+
if (event?.type !== "message.part.updated" || part?.type !== "tool" || part.tool !== "task") return;
|
|
39
|
+
const state = part.state;
|
|
40
|
+
const metadata = state?.metadata;
|
|
41
|
+
const input = state?.input;
|
|
42
|
+
const brief = normalizedPromptText(input?.prompt);
|
|
43
|
+
if (!validId(part.callID) || !validId(part.sessionID)) return;
|
|
44
|
+
const key = JSON.stringify([part.sessionID, part.callID]);
|
|
45
|
+
const previous = bindings.get(key);
|
|
46
|
+
if (["completed", "error"].includes(state?.status)) {
|
|
47
|
+
if (previous) previous.closed = true;
|
|
48
|
+
else if (bindings.size < MAX_BINDINGS) bindings.set(key, { observedAt: now(), closed: true });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (state?.status !== "running") return;
|
|
52
|
+
if (!validId(metadata?.sessionId) || metadata.parentSessionId !== part.sessionID
|
|
53
|
+
|| metadata.sessionId === part.sessionID || !validId(input?.subagent_type)
|
|
54
|
+
|| !brief || input.prompt.length > MAX_BRIEF_CHARS
|
|
55
|
+
|| typeof input.description !== "string" || !Number.isFinite(state.time?.start)) {
|
|
56
|
+
if (previous) previous.ambiguous = true;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const fingerprint = digest(JSON.stringify([metadata.sessionId, input.subagent_type, brief, state.time.start]));
|
|
60
|
+
if (previous) {
|
|
61
|
+
// A mutated/reused call is ambiguous; never reset its consumed message.
|
|
62
|
+
if (previous.fingerprint !== fingerprint) previous.ambiguous = true;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Do not evict a consumed call and then accept its replay as a fresh binding.
|
|
66
|
+
if (bindings.size >= MAX_BINDINGS || state.time.start < initializedAt
|
|
67
|
+
|| now() - state.time.start > BINDING_TTL_MS) return;
|
|
68
|
+
bindings.set(key, {
|
|
69
|
+
fingerprint, parent: part.sessionID, child: metadata.sessionId, call: part.callID,
|
|
70
|
+
agent: input.subagent_type, brief, startedAt: state.time.start,
|
|
71
|
+
observedAt: now(), messageID: null, ambiguous: false,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
const matchSnapshot = (snapshot, { sessionID, messageID, text }, candidates) => {
|
|
75
|
+
if (!snapshot || disposed) return null;
|
|
76
|
+
const [session, message] = snapshot;
|
|
77
|
+
const info = message?.info;
|
|
78
|
+
const parts = message?.parts;
|
|
79
|
+
if (session?.id !== sessionID || info?.id !== messageID || info.sessionID !== sessionID
|
|
80
|
+
|| info.role !== "user" || !validId(info.agent) || !Number.isFinite(info.time?.created)
|
|
81
|
+
|| !Array.isArray(parts) || parts.length !== 1) return null;
|
|
82
|
+
const part = parts[0];
|
|
83
|
+
const partText = normalizedPromptText(part?.text);
|
|
84
|
+
if (part?.type !== "text" || part.synthetic || part.ignored || typeof part.text !== "string"
|
|
85
|
+
|| part.sessionID !== sessionID || part.messageID !== messageID || partText !== text) return null;
|
|
86
|
+
prune();
|
|
87
|
+
const reservedCandidates = new Set(candidates);
|
|
88
|
+
const hasNewMatch = [...bindings.values()].some((binding) => !reservedCandidates.has(binding)
|
|
89
|
+
&& !binding.closed && binding.child === sessionID && binding.parent === session.parentID
|
|
90
|
+
&& binding.agent === info.agent && binding.brief === partText
|
|
91
|
+
&& info.time.created >= binding.startedAt);
|
|
92
|
+
if (hasNewMatch) return null;
|
|
93
|
+
const matches = candidates.filter((binding) => binding.child === sessionID
|
|
94
|
+
&& binding.parent === session.parentID
|
|
95
|
+
&& binding.agent === info.agent && binding.brief === partText
|
|
96
|
+
&& info.time.created >= binding.startedAt);
|
|
97
|
+
if (matches.length !== 1) return null;
|
|
98
|
+
const binding = matches[0];
|
|
99
|
+
if (binding.ambiguous || (binding.messageID && binding.messageID !== messageID)) return null;
|
|
100
|
+
if (requirePromptSnapshot) {
|
|
101
|
+
const draft = binding.prompt;
|
|
102
|
+
const draftPart = draft?.parts?.[0];
|
|
103
|
+
if (draft?.message?.id !== messageID || draft.message.sessionID !== sessionID
|
|
104
|
+
|| draft.message.role !== info.role || draft.message.agent !== info.agent
|
|
105
|
+
|| draft.message.time?.created !== info.time.created || draft.parts?.length !== 1
|
|
106
|
+
|| draftPart?.id !== part.id || draftPart?.sessionID !== sessionID
|
|
107
|
+
|| draftPart?.messageID !== messageID || draftPart?.type !== "text"
|
|
108
|
+
|| draftPart.synthetic || draftPart.ignored
|
|
109
|
+
|| normalizedPromptText(draftPart.text) !== text) return null;
|
|
110
|
+
}
|
|
111
|
+
binding.messageID = messageID;
|
|
112
|
+
return Object.freeze({
|
|
113
|
+
version: 1, host: "opencode-v1", origin: "delegated_brief",
|
|
114
|
+
parent_session_id: binding.parent, child_session_id: sessionID,
|
|
115
|
+
task_call_id: binding.call, message_id: messageID,
|
|
116
|
+
requested_agent: binding.agent, current_agent: info.agent,
|
|
117
|
+
brief_sha256: digest(text),
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
// V1 invokes this with all resolved parts before saving them individually.
|
|
121
|
+
// Keep the message/parts references until lookup so later hooks' in-place
|
|
122
|
+
// changes are checked too. The host persists these original objects, even if
|
|
123
|
+
// a hook replaces output.message/parts. Do not fetch inside this hook.
|
|
124
|
+
const observePrompt = (input, output) => {
|
|
125
|
+
if (disposed) return;
|
|
126
|
+
prune();
|
|
127
|
+
const info = output?.message;
|
|
128
|
+
if (!validId(info?.id) || info.sessionID !== input?.sessionID || info.role !== "user"
|
|
129
|
+
|| (input.messageID && input.messageID !== info.id)
|
|
130
|
+
|| (input.agent && input.agent !== info.agent)
|
|
131
|
+
|| !Number.isFinite(info.time?.created) || !validId(info.agent)) return;
|
|
132
|
+
for (const binding of bindings.values()) {
|
|
133
|
+
if (binding.closed || binding.child !== info.sessionID || binding.agent !== info.agent
|
|
134
|
+
|| info.time.created < binding.startedAt) continue;
|
|
135
|
+
if (binding.messageID) continue;
|
|
136
|
+
const part = output.parts?.[0];
|
|
137
|
+
const promptMatches = output.parts?.length === 1 && part?.type === "text"
|
|
138
|
+
&& !part.synthetic && !part.ignored
|
|
139
|
+
&& normalizedPromptText(part.text) === binding.brief;
|
|
140
|
+
if (!requirePromptSnapshot || promptMatches) binding.messageID = info.id;
|
|
141
|
+
if (promptMatches) {
|
|
142
|
+
binding.prompt = { message: info, parts: output.parts };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const boundChildren = (parentSessionID) => {
|
|
147
|
+
if (disposed || !validId(parentSessionID)) return [];
|
|
148
|
+
prune();
|
|
149
|
+
return [...new Set([...bindings.values()]
|
|
150
|
+
.filter((binding) => !binding.closed && binding.parent === parentSessionID)
|
|
151
|
+
.map((binding) => binding.child))];
|
|
152
|
+
};
|
|
153
|
+
const boundChild = (parentSessionID, callID) => {
|
|
154
|
+
if (disposed || !validId(parentSessionID) || !validId(callID)) return null;
|
|
155
|
+
prune();
|
|
156
|
+
const binding = bindings.get(JSON.stringify([parentSessionID, callID]));
|
|
157
|
+
if (binding?.closed || binding?.parent !== parentSessionID || !validId(binding?.child)) return null;
|
|
158
|
+
return binding.child;
|
|
159
|
+
};
|
|
160
|
+
const resolve = (prompt) => {
|
|
161
|
+
const { sessionID, messageID } = prompt;
|
|
162
|
+
const text = normalizedPromptText(prompt.text);
|
|
163
|
+
if (disposed || !validId(sessionID) || !validId(messageID) || !text) return Promise.resolve(null);
|
|
164
|
+
prune();
|
|
165
|
+
// Reserve candidates now; later closure or TTL pruning must not invalidate
|
|
166
|
+
// snapshot work admitted while the binding was current.
|
|
167
|
+
const candidates = [...bindings.values()].filter((binding) => binding.child === sessionID && !binding.closed
|
|
168
|
+
&& (!requirePromptSnapshot || (binding.messageID === messageID && binding.prompt)));
|
|
169
|
+
if (!readSession || !readMessage
|
|
170
|
+
|| !candidates.some((binding) => !binding.messageID || binding.messageID === messageID)) return Promise.resolve(null);
|
|
171
|
+
const key = JSON.stringify([sessionID, messageID]);
|
|
172
|
+
// Duplicate requests share a lookup; distinct requests cannot outgrow the binding cache.
|
|
173
|
+
const existing = activeLookups.get(key);
|
|
174
|
+
if (existing) return existing.text === text ? existing.promise : Promise.resolve(null);
|
|
175
|
+
if (activeLookups.size >= bindings.size) return Promise.resolve(null);
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const completion = Promise.withResolvers();
|
|
178
|
+
let finished = false;
|
|
179
|
+
const finish = (context) => {
|
|
180
|
+
if (finished) return;
|
|
181
|
+
finished = true;
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
activeLookups.delete(key);
|
|
184
|
+
controller.abort();
|
|
185
|
+
completion.resolve(context);
|
|
186
|
+
};
|
|
187
|
+
const timer = setTimeout(() => finish(null), LOOKUP_TIMEOUT_MS);
|
|
188
|
+
activeLookups.set(key, { text, promise: completion.promise, cancel: () => finish(null) });
|
|
189
|
+
void Promise.all([
|
|
190
|
+
Promise.resolve().then(() => controller.signal.aborted ? null : readSession(sessionID, controller.signal)),
|
|
191
|
+
Promise.resolve().then(() => controller.signal.aborted ? null : readMessage(sessionID, messageID, controller.signal)),
|
|
192
|
+
]).then((snapshot) => {
|
|
193
|
+
if (!finished) finish(matchSnapshot(snapshot, { ...prompt, text }, candidates));
|
|
194
|
+
}).catch(() => finish(null));
|
|
195
|
+
return completion.promise;
|
|
196
|
+
};
|
|
197
|
+
return { boundChild, boundChildren, observe, observePrompt, resolve, dispose: () => {
|
|
198
|
+
disposed = true;
|
|
199
|
+
for (const lookup of activeLookups.values()) lookup.cancel();
|
|
200
|
+
bindings.clear();
|
|
201
|
+
} };
|
|
202
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} CodememLocation
|
|
3
|
+
* @property {unknown} project
|
|
4
|
+
* @property {string | undefined} directory
|
|
5
|
+
* @property {string | undefined} worktree
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const V2_ADAPTER_DIAGNOSTICS = Object.freeze({
|
|
9
|
+
contextCleanupTimeout: "v2_context_cleanup_timeout",
|
|
10
|
+
contextRecallFailed: "v2_context_recall_failed",
|
|
11
|
+
eventCaptureFailed: "v2_event_capture_failed",
|
|
12
|
+
eventStreamCleanupTimeout: "v2_event_stream_cleanup_timeout",
|
|
13
|
+
eventStreamEndedUnexpectedly: "v2_event_stream_ended_unexpectedly",
|
|
14
|
+
eventStreamFailed: "v2_event_stream_failed",
|
|
15
|
+
registrationCleanupTimeout: "v2_registration_cleanup_timeout",
|
|
16
|
+
runtimeCleanupTimeout: "v2_runtime_cleanup_timeout",
|
|
17
|
+
toolCaptureFailed: "v2_tool_capture_failed",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} CodememHost
|
|
22
|
+
* @property {(entry: {service: string, level: string, message: string, extra: object}) => Promise<unknown>} log
|
|
23
|
+
* @property {((notice: {message: string, variant: string}) => Promise<unknown>) | null} notify
|
|
24
|
+
* @property {((prompt: {sessionID: string, messageID: string, text: string}) => Promise<object | null>) | null} resolveCaptureContext
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {object} CodememCapturedEvent
|
|
29
|
+
* Event types use the OpenCode 1 vocabulary as the runtime's canonical wire names.
|
|
30
|
+
* Adapters retain the host-native event in `raw` for bounded diagnostic logging.
|
|
31
|
+
* @property {string} type
|
|
32
|
+
* @property {string | null} sessionID
|
|
33
|
+
* @property {unknown} messageInfo
|
|
34
|
+
* @property {unknown} part
|
|
35
|
+
* @property {unknown} usage
|
|
36
|
+
* @property {unknown} raw
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} CodememPromptContext
|
|
41
|
+
* @property {string | null} sessionID
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} CodememMessageTransformOptions
|
|
46
|
+
* @property {boolean} [deferDeliveryConfirmation] Let the adapter confirm whether host mutation succeeded.
|
|
47
|
+
* @property {boolean} [enableSystemSurface] Route V2 context through the message cache before adapting it to system parts.
|
|
48
|
+
* @property {boolean} [pruneAbsentCacheEntries] Remove replay entries absent from this transform's history.
|
|
49
|
+
* @property {boolean} [requireLatestUserMessageID] Skip recall unless the latest user message has a host ID.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {object} CodememMessageTransformResult
|
|
54
|
+
* @property {boolean} applied
|
|
55
|
+
* @property {"message" | "system"} surface Adapters must route injected output to this surface.
|
|
56
|
+
* @property {((status?: "handed_off" | "failed") => void) | undefined} [completeDelivery]
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} CodememToolResult
|
|
61
|
+
* @property {string | null} [id] Stable host tool-call identity; OpenCode V1 omits it.
|
|
62
|
+
* @property {string | null} sessionID
|
|
63
|
+
* @property {string} tool
|
|
64
|
+
* @property {object} args
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} CodememRuntime
|
|
69
|
+
* @property {() => void} deactivate
|
|
70
|
+
* @property {() => Promise<void>} dispose
|
|
71
|
+
* @property {(context: CodememPromptContext) => Promise<void>} handleCompacting
|
|
72
|
+
* @property {(input: CodememPromptContext, output: object, options?: CodememMessageTransformOptions) => Promise<CodememMessageTransformResult>} transformMessages
|
|
73
|
+
* @property {(input: CodememPromptContext, output: object) => Promise<void>} transformSystem
|
|
74
|
+
* @property {(event: CodememCapturedEvent) => Promise<void>} handleEvent
|
|
75
|
+
* @property {(input: CodememToolResult, output: object) => Promise<void>} handleToolResult
|
|
76
|
+
* @property {(code: string) => Promise<void>} reportDiagnostic
|
|
77
|
+
* @property {Record<string, {description: string, args: object, execute: Function}>} tools
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
export const createRuntimeLocation = ({ project, directory, worktree }) => ({
|
|
81
|
+
project,
|
|
82
|
+
directory,
|
|
83
|
+
worktree,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const createRuntimeHost = ({ log, notify = null, resolveCaptureContext = null }) => ({
|
|
87
|
+
log,
|
|
88
|
+
notify,
|
|
89
|
+
resolveCaptureContext,
|
|
90
|
+
});
|