@eleboucher/opencode-memini 0.7.19 → 0.7.20
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 +49 -11
- package/memini-v2.js +265 -104
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,27 +38,65 @@ opencode installs it from npm with Bun at startup.
|
|
|
38
38
|
opencode's v2 preview (`opencode2`) uses a different plugin system: a `plugins`
|
|
39
39
|
(plural) config array and a `Plugin.define({ id, setup })` module — the v1
|
|
40
40
|
`{ id, server }` plugin above does not load under it. memini ships a v2 sibling
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
that wires the same recall / capture / `memini_status` behaviour to v2's
|
|
42
|
+
session hooks, public event stream, and tool transforms.
|
|
43
|
+
|
|
44
|
+
The v2 loader treats a `plugins` entry as either an npm package specifier or a
|
|
45
|
+
path. It cannot install this package's `/v2` export subpath as an npm package,
|
|
46
|
+
so v2 ships under its own package name:
|
|
44
47
|
|
|
45
48
|
```jsonc
|
|
46
49
|
{
|
|
47
50
|
"$schema": "https://opencode.ai/config.json",
|
|
48
|
-
"plugins": [
|
|
51
|
+
"plugins": ["@eleboucher/opencode-memini-v2"],
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A bare entry resolves to the `latest` dist-tag, so that is where this package
|
|
56
|
+
is published. `opencode2 plugin add @eleboucher/opencode-memini-v2` writes the
|
|
57
|
+
entry for you, using whatever specifier you pass it.
|
|
58
|
+
|
|
59
|
+
> **Why a second package rather than one with two entrypoints?** opencode
|
|
60
|
+
> resolves a plugin through `exports["./server"]` before falling back to
|
|
61
|
+
> `main`, and it uses that same `server` kind for v1-style plugins. Adding a
|
|
62
|
+
> `./server` key to `@eleboucher/opencode-memini` would therefore hand the v2
|
|
63
|
+
> module to v1 hosts, which cannot load it.
|
|
64
|
+
|
|
65
|
+
To run from a checkout, use the local entrypoint. opencode does not install
|
|
66
|
+
dependencies for path-loaded plugins; this one has none, so nothing else is
|
|
67
|
+
needed:
|
|
68
|
+
|
|
69
|
+
```jsonc
|
|
70
|
+
{
|
|
71
|
+
"plugins": ["/absolute/path/to/memini/integrations/opencode/plugin/memini-v2.js"],
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
For options, use the documented object form:
|
|
76
|
+
|
|
77
|
+
```jsonc
|
|
78
|
+
{
|
|
79
|
+
"plugins": [
|
|
80
|
+
{
|
|
81
|
+
"package": "@eleboucher/opencode-memini-v2",
|
|
82
|
+
"options": { "namespace": "my-project" },
|
|
83
|
+
},
|
|
84
|
+
],
|
|
49
85
|
}
|
|
50
86
|
```
|
|
51
87
|
|
|
52
88
|
The same options and env vars below apply. Recall injects into the request's
|
|
53
89
|
`system` prompt (rather than a synthetic message part).
|
|
54
90
|
|
|
55
|
-
> **Beta status.**
|
|
56
|
-
>
|
|
57
|
-
>
|
|
58
|
-
>
|
|
59
|
-
>
|
|
60
|
-
>
|
|
61
|
-
>
|
|
91
|
+
> **Beta status.** Verified against `opencode2 v0.0.0-next-16502` and re-checked
|
|
92
|
+
> through `v0.0.0-beta-18050`: the working request hook is `context` (the docs
|
|
93
|
+
> call it `request`); user/assistant turns are captured from
|
|
94
|
+
> `session.inbox.enqueued`, `session.text.ended`, and `session.execution.*`
|
|
95
|
+
> because these builds emit no `session.idle` and expose no session-message list
|
|
96
|
+
> method. `next-17444` renamed the first event from `session.input.admitted` and
|
|
97
|
+
> moved its text from `input.data` to `item.payload`; the plugin reads both
|
|
98
|
+
> shapes, and registers both hook names so it remains compatible with the
|
|
99
|
+
> documented spelling.
|
|
62
100
|
|
|
63
101
|
The v2 plugin sends diagnostics through the structured `ctx.app.log` logger when
|
|
64
102
|
available. Logging is best-effort and remains silent when that logger is absent
|
package/memini-v2.js
CHANGED
|
@@ -1,17 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* memini memory plugin for opencode v2 (the Plugin.define / `setup` API).
|
|
3
3
|
*
|
|
4
|
-
* This is the v2 sibling of memini.js. It targets the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
4
|
+
* This is the v2 sibling of memini.js. It targets the v2 plugin contract as
|
|
5
|
+
* verified against opencode2 v0.0.0-next-16502 and re-checked through
|
|
6
|
+
* v0.0.0-beta-18050 (@opencode-ai/plugin `next` / `beta`), which differs from
|
|
7
|
+
* the docs at https://opencode.ai/v2/docs/build/plugins in several places —
|
|
8
|
+
* every shape below is feature-detected and was confirmed against the live
|
|
9
|
+
* runtime:
|
|
10
|
+
*
|
|
11
|
+
* - RECALL — ctx.session.hook("context", …): the docs call this hook
|
|
12
|
+
* "request", but this build fires "context" (unknown names register without
|
|
13
|
+
* error and simply never fire). The event is
|
|
14
|
+
* { sessionID, agent, model, system, messages, tools }, where `system` is an
|
|
15
|
+
* array of { type: "text", text } parts and `messages` are AI-SDK-shaped
|
|
16
|
+
* ({ id, role, content: [{ type: "text", text } …] }). The hook fires once
|
|
17
|
+
* per model DISPATCH — i.e. again on every tool-loop continuation step — so
|
|
18
|
+
* recall is deduplicated per TURN (last user message id): the search runs
|
|
19
|
+
* once per user message and the rendered block is re-injected from cache on
|
|
20
|
+
* continuation dispatches of the same turn. This is the v2 equivalent of
|
|
21
|
+
* v1's `chat.message`.
|
|
22
|
+
* - CAPTURE — ctx.event.subscribe() (no type argument; the whole public
|
|
23
|
+
* server event stream, across every project the service hosts). There is no
|
|
24
|
+
* `session.idle` in v2: a turn's boundary is the
|
|
25
|
+
* `session.execution.succeeded` / `.failed` / `.interrupted` event. There is
|
|
26
|
+
* also no message-listing method on ctx.session (create/get/prompt/command/
|
|
27
|
+
* synthetic/generate/interrupt only; session.get returns SessionInfo with
|
|
28
|
+
* no messages). Capture is therefore event-driven: the user text comes from
|
|
29
|
+
* `session.inbox.enqueued` (data.item.payload.text) and the assistant text
|
|
30
|
+
* from `session.text.ended` parts (keyed by assistantMessageID + ordinal),
|
|
31
|
+
* flushed to memini when the execution terminal event arrives. v1's
|
|
32
|
+
* failed-turn marking maps to execution.failed / execution.interrupted.
|
|
33
|
+
* Builds up to next-16502 named that event `session.input.admitted` and
|
|
34
|
+
* nested the same Prompt.fields under `data.input.data`; both shapes are
|
|
35
|
+
* read, so one plugin covers either build.
|
|
36
|
+
* - STATUS TOOL — ctx.tool.transform(t => t.add(…)): `add` takes ONE
|
|
37
|
+
* structural Tool.Info object ({ name, input, description, execute,
|
|
38
|
+
* options? }) — not the docs' three-argument add(name, def, options). The
|
|
39
|
+
* input schema field is `input` (raw JSON Schema is fine) and the result is
|
|
40
|
+
* { content: [{ type: "text", text }] }.
|
|
15
41
|
*
|
|
16
42
|
* Everything that is not opencode-contract-specific — config/namespace
|
|
17
43
|
* resolution, the handshake, formatting, token budgeting, status rendering, the
|
|
@@ -26,7 +52,16 @@
|
|
|
26
52
|
* The v2 plugin API is beta and its ctx is still gaining hooks upstream. Every
|
|
27
53
|
* capability below is feature-detected: on a build where `ctx.session.hook`,
|
|
28
54
|
* `ctx.event.subscribe`, or `ctx.tool.transform` is absent, that capability logs
|
|
29
|
-
* once and no-ops rather than throwing and taking down plugin activation.
|
|
55
|
+
* once and no-ops rather than throwing and taking down plugin activation. Note
|
|
56
|
+
* that ctx.app has no `log` method on current builds (it is { name, version,
|
|
57
|
+
* channel }), so diagnostics stay silent — they are best-effort by design.
|
|
58
|
+
*
|
|
59
|
+
* KNOWN LIMITATION (beta): the v2 service hosts every project in ONE process
|
|
60
|
+
* with ONE plugin instance, and hooks/events are service-global. The namespace
|
|
61
|
+
* is resolved from the plugin process's cwd (typically the service's start
|
|
62
|
+
* directory), so sessions from other projects would recall/capture against the
|
|
63
|
+
* same namespace. Pin `MEMINI_NAMESPACE` if needed and re-check the upstream
|
|
64
|
+
* ctx as it gains per-location routing.
|
|
30
65
|
*/
|
|
31
66
|
|
|
32
67
|
import {
|
|
@@ -40,8 +75,6 @@ import {
|
|
|
40
75
|
formatResults,
|
|
41
76
|
fitByTokens,
|
|
42
77
|
labelsEnv,
|
|
43
|
-
extractLastTurn,
|
|
44
|
-
lastAssistantFailed,
|
|
45
78
|
describeSettings,
|
|
46
79
|
renderStatus,
|
|
47
80
|
createClient,
|
|
@@ -55,6 +88,25 @@ const INJECT_PREAMBLE =
|
|
|
55
88
|
"Relevant long-term memory from memini (background context — prefer " +
|
|
56
89
|
"current workspace state and the user's instructions):";
|
|
57
90
|
const BUDGET_EXPIRED = Symbol("memini-recall-budget-expired");
|
|
91
|
+
// OpenCode activates a plugin per location while event.subscribe() is global.
|
|
92
|
+
// Assistant message IDs are globally unique, so this prevents duplicate writes.
|
|
93
|
+
const capturedAssistantIDs = new Set();
|
|
94
|
+
const MAX_CAPTURED_ASSISTANT_IDS = 1000;
|
|
95
|
+
|
|
96
|
+
function claimCapturedAssistant(id) {
|
|
97
|
+
if (capturedAssistantIDs.has(id)) return false;
|
|
98
|
+
capturedAssistantIDs.add(id);
|
|
99
|
+
while (capturedAssistantIDs.size > MAX_CAPTURED_ASSISTANT_IDS) {
|
|
100
|
+
const oldest = capturedAssistantIDs.keys().next().value;
|
|
101
|
+
if (oldest === undefined) break;
|
|
102
|
+
capturedAssistantIDs.delete(oldest);
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function resetForTests() {
|
|
108
|
+
capturedAssistantIDs.clear();
|
|
109
|
+
}
|
|
58
110
|
|
|
59
111
|
// messageText pulls the plain text out of one v2 request message, tolerating the
|
|
60
112
|
// shapes the beta may hand us: `content` as a string, `content` as an array of
|
|
@@ -72,18 +124,16 @@ function messageText(msg) {
|
|
|
72
124
|
return "";
|
|
73
125
|
}
|
|
74
126
|
|
|
75
|
-
// extractQueryFromRequest returns the latest user text from a
|
|
127
|
+
// extractQueryFromRequest returns the latest user text from a context event's
|
|
76
128
|
// `messages`, falling back to the last non-empty message so a recall still fires
|
|
77
129
|
// on unusual message layouts. Exported for testing.
|
|
78
130
|
export function extractQueryFromRequest(event) {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
const text = messageText(messages[i]);
|
|
84
|
-
if (text) return text;
|
|
85
|
-
}
|
|
131
|
+
const lastUser = lastUserMessage(event);
|
|
132
|
+
if (lastUser) {
|
|
133
|
+
const text = messageText(lastUser);
|
|
134
|
+
if (text) return text;
|
|
86
135
|
}
|
|
136
|
+
const messages = Array.isArray(event && event.messages) ? event.messages : [];
|
|
87
137
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
88
138
|
const text = messageText(messages[i]);
|
|
89
139
|
if (text) return text;
|
|
@@ -91,14 +141,34 @@ export function extractQueryFromRequest(event) {
|
|
|
91
141
|
return "";
|
|
92
142
|
}
|
|
93
143
|
|
|
144
|
+
// lastUserMessage returns the most recent user-role message, or null.
|
|
145
|
+
function lastUserMessage(event) {
|
|
146
|
+
const messages = Array.isArray(event && event.messages) ? event.messages : [];
|
|
147
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
148
|
+
const role = messages[i] && (messages[i].role || (messages[i].info && messages[i].info.role));
|
|
149
|
+
if (role === "user") return messages[i];
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// turnKey identifies a user TURN (as opposed to one model dispatch): the
|
|
155
|
+
// context hook re-fires on every tool-loop continuation with the same last
|
|
156
|
+
// user message. Exported for testing.
|
|
157
|
+
export function turnKey(event, query) {
|
|
158
|
+
const sessionID = event && event.sessionID ? String(event.sessionID) : "";
|
|
159
|
+
const lastUser = lastUserMessage(event);
|
|
160
|
+
const id = lastUser && (lastUser.id || (lastUser.info && lastUser.info.id));
|
|
161
|
+
return `${sessionID}|${id || query}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
94
164
|
// injectContext places the rendered memory block where the model will see it:
|
|
95
|
-
// the
|
|
96
|
-
// message
|
|
97
|
-
// it found a slot. Exported for testing.
|
|
165
|
+
// the event's `system` array first (an array of { type: "text", text } parts,
|
|
166
|
+
// transient and not persisted as a message), falling back to prepending a
|
|
167
|
+
// system message. Returns true when it found a slot. Exported for testing.
|
|
98
168
|
export function injectContext(event, block) {
|
|
99
169
|
if (!event || !block) return false;
|
|
100
170
|
if (Array.isArray(event.system)) {
|
|
101
|
-
event.system.push(block);
|
|
171
|
+
event.system.push({ type: "text", text: block });
|
|
102
172
|
return true;
|
|
103
173
|
}
|
|
104
174
|
if (Array.isArray(event.messages)) {
|
|
@@ -108,34 +178,11 @@ export function injectContext(event, block) {
|
|
|
108
178
|
return false;
|
|
109
179
|
}
|
|
110
180
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
async function fetchSessionMessages(ctx, sessionID) {
|
|
117
|
-
const session = ctx && ctx.session;
|
|
118
|
-
if (!session) return [];
|
|
119
|
-
const unwrap = (res) =>
|
|
120
|
-
Array.isArray(res) ? res
|
|
121
|
-
: Array.isArray(res && res.data) ? res.data
|
|
122
|
-
: Array.isArray(res && res.messages) ? res.messages
|
|
123
|
-
: [];
|
|
124
|
-
const attempts = [
|
|
125
|
-
() => session.messages && session.messages({ path: { id: sessionID } }),
|
|
126
|
-
() => session.message && session.message({ path: { id: sessionID } }),
|
|
127
|
-
() => session.messages && session.messages(sessionID),
|
|
128
|
-
];
|
|
129
|
-
for (const attempt of attempts) {
|
|
130
|
-
try {
|
|
131
|
-
const res = await attempt();
|
|
132
|
-
const arr = unwrap(res);
|
|
133
|
-
if (arr.length) return arr;
|
|
134
|
-
} catch {
|
|
135
|
-
/* try the next accessor shape */
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return [];
|
|
181
|
+
function contextAlreadyInjected(event) {
|
|
182
|
+
return Array.isArray(event && event.system) && event.system.some((part) => {
|
|
183
|
+
const text = typeof part === "string" ? part : part && part.text;
|
|
184
|
+
return typeof text === "string" && text.startsWith(INJECT_PREAMBLE);
|
|
185
|
+
});
|
|
139
186
|
}
|
|
140
187
|
|
|
141
188
|
export async function setup(ctx) {
|
|
@@ -177,7 +224,6 @@ export async function setup(ctx) {
|
|
|
177
224
|
// unchanged match is suppressed while inside EITHER window, re-served once
|
|
178
225
|
// BOTH lapse, and re-served immediately when its content changed (h
|
|
179
226
|
// mismatch). Both maps bounded for a long-lived host.
|
|
180
|
-
const captured = new Set();
|
|
181
227
|
const injectedBySession = new Map(); // session -> { n, ids: Map<id, {h, at, n}> }
|
|
182
228
|
const MAX_TRACKED_SESSIONS = 200;
|
|
183
229
|
const MAX_INJECTED_PER_SESSION = 200;
|
|
@@ -217,27 +263,49 @@ export async function setup(ctx) {
|
|
|
217
263
|
else if (reg && typeof reg.dispose === "function") cleanups.push(() => reg.dispose());
|
|
218
264
|
};
|
|
219
265
|
|
|
220
|
-
// --- RECALL: ctx.session.hook("
|
|
266
|
+
// --- RECALL: ctx.session.hook("context") --------------------------------
|
|
267
|
+
//
|
|
268
|
+
// opencode awaits this hook immediately before model dispatch (the doc's "a
|
|
269
|
+
// hook failure fails the operation it intercepts"), so the callback swallows
|
|
270
|
+
// its own errors and races the search against recall_budget_ms: if memini is
|
|
271
|
+
// slow, the turn proceeds without memory rather than freezing for the full
|
|
272
|
+
// timeout_ms.
|
|
221
273
|
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
274
|
+
// The build verified against fires "context"; the docs name the same hook
|
|
275
|
+
// "request". Unknown names register without error and never fire, so both
|
|
276
|
+
// are registered: on any given build only the implemented one runs, and a
|
|
277
|
+
// build that ever fires both for one dispatch is neutralized by the
|
|
278
|
+
// per-turn dedup plus the injected-event WeakSet below.
|
|
227
279
|
if (ctx && ctx.session && typeof ctx.session.hook === "function") {
|
|
228
|
-
|
|
280
|
+
// Events whose system/messages this hook generation already injected —
|
|
281
|
+
// guards against a build that fires both names for one dispatch.
|
|
282
|
+
const injectedEvents = new WeakSet();
|
|
283
|
+
const handleRequest = async (event) => {
|
|
229
284
|
try {
|
|
230
285
|
const live = await currentConfig();
|
|
231
286
|
if (!live.recall) return;
|
|
232
287
|
const query = extractQueryFromRequest(event);
|
|
233
288
|
if (!query) return;
|
|
234
289
|
const sessionID = event.sessionID || event.sessionId || (event.session && event.session.id) || "";
|
|
235
|
-
// One request-hook fire == one prompt for the cooldown's prompt
|
|
236
|
-
// dimension (the v2 beta's closest per-turn signal): bump before any
|
|
237
|
-
// gate, so the window measures prompts-since-injection even on turns
|
|
238
|
-
// that inject nothing.
|
|
239
290
|
const seen = sessionID ? sessionSeen(sessionID) : null;
|
|
240
|
-
|
|
291
|
+
|
|
292
|
+
// One USER TURN == one prompt for the cooldown's prompt dimension. The
|
|
293
|
+
// hook re-fires per tool-loop step of the same turn, so dedupe on the
|
|
294
|
+
// last user message: a repeat turnKey skips the search and the counter,
|
|
295
|
+
// but re-injects this turn's cached block into the new dispatch's
|
|
296
|
+
// system (each dispatch rebuilds its request from scratch).
|
|
297
|
+
const key = turnKey(event, query);
|
|
298
|
+
if (seen && seen.lastTurnKey === key) {
|
|
299
|
+
if (seen.block && !contextAlreadyInjected(event) && !injectedEvents.has(event) && injectContext(event, seen.block)) {
|
|
300
|
+
injectedEvents.add(event);
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (seen) {
|
|
305
|
+
seen.lastTurnKey = key;
|
|
306
|
+
seen.block = undefined;
|
|
307
|
+
seen.n += 1;
|
|
308
|
+
}
|
|
241
309
|
const cooldownOpts = () => ({
|
|
242
310
|
now: Date.now(),
|
|
243
311
|
counter: seen ? seen.n : 0,
|
|
@@ -323,21 +391,35 @@ export async function setup(ctx) {
|
|
|
323
391
|
}
|
|
324
392
|
if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
|
|
325
393
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
394
|
+
const block = lines.join("\n");
|
|
395
|
+
if (!contextAlreadyInjected(event) && injectContext(event, block)) {
|
|
396
|
+
injectedEvents.add(event);
|
|
397
|
+
if (seen) {
|
|
398
|
+
// Cache for re-injection on this turn's continuation dispatches,
|
|
399
|
+
// and record only the slice formatResults actually renders,
|
|
400
|
+
// stamped {h, at, n} so the windowed cooldown can judge later.
|
|
401
|
+
seen.block = block;
|
|
402
|
+
rememberInjected(seen, filtered.slice(0, live.recall_limit || 3));
|
|
403
|
+
}
|
|
330
404
|
}
|
|
331
405
|
} catch (error) {
|
|
332
406
|
log.warn(`request hook failed: ${String(error)}`);
|
|
333
407
|
}
|
|
334
|
-
}
|
|
335
|
-
|
|
408
|
+
};
|
|
409
|
+
for (const name of ["context", "request"]) {
|
|
410
|
+
const reg = await ctx.session.hook(name, handleRequest);
|
|
411
|
+
disposeReg(reg);
|
|
412
|
+
}
|
|
336
413
|
} else if (cfg.recall) {
|
|
337
414
|
log.warn("recall unavailable: ctx.session.hook is not present on this opencode build");
|
|
338
415
|
}
|
|
339
416
|
|
|
340
|
-
// --- STATUS TOOL: ctx.tool.transform(t => t.add(
|
|
417
|
+
// --- STATUS TOOL: ctx.tool.transform(t => t.add(info)) ------------------
|
|
418
|
+
//
|
|
419
|
+
// `add` takes ONE structural Tool.Info: { name, input, description, execute,
|
|
420
|
+
// options? } (verified against next-16502; the docs' three-argument
|
|
421
|
+
// add(name, def, options) is wrong for this build). `input` is a raw JSON
|
|
422
|
+
// Schema; the result is { content: [{ type: "text", text }] }.
|
|
341
423
|
if (ctx && ctx.tool && typeof ctx.tool.transform === "function") {
|
|
342
424
|
const reg = await ctx.tool.transform((tools) => {
|
|
343
425
|
tools.add({
|
|
@@ -349,7 +431,7 @@ export async function setup(ctx) {
|
|
|
349
431
|
"would be without the env/option pin, and any misconfiguration worth flagging. Read-only; " +
|
|
350
432
|
"secrets are redacted. Call it when the user asks what memini is doing, why a memory " +
|
|
351
433
|
"cannot be recalled, or which namespace is in use.",
|
|
352
|
-
|
|
434
|
+
input: { type: "object", properties: {}, additionalProperties: false },
|
|
353
435
|
options: { codemode: false },
|
|
354
436
|
execute: async () => {
|
|
355
437
|
try {
|
|
@@ -365,13 +447,7 @@ export async function setup(ctx) {
|
|
|
365
447
|
report.memory.recall_max_tokens = live.recall_max_tokens;
|
|
366
448
|
report.memory.recall_min_score = live.recall_min_score;
|
|
367
449
|
const text = renderStatus(report);
|
|
368
|
-
return {
|
|
369
|
-
structured: {
|
|
370
|
-
namespace: report.namespace.effective,
|
|
371
|
-
source: report.namespace.source,
|
|
372
|
-
},
|
|
373
|
-
content: [{ type: "text", text }],
|
|
374
|
-
};
|
|
450
|
+
return { content: [{ type: "text", text }] };
|
|
375
451
|
} catch (error) {
|
|
376
452
|
return { content: [{ type: "text", text: `memini status failed: ${String(error)}` }] };
|
|
377
453
|
}
|
|
@@ -383,45 +459,122 @@ export async function setup(ctx) {
|
|
|
383
459
|
log.warn("memini_status unavailable: ctx.tool.transform is not present on this opencode build");
|
|
384
460
|
}
|
|
385
461
|
|
|
386
|
-
// --- CAPTURE: ctx.event.subscribe(
|
|
462
|
+
// --- CAPTURE: ctx.event.subscribe() -------------------------------------
|
|
463
|
+
//
|
|
464
|
+
// Detached: the docs say not to await an infinite stream inside setup. We
|
|
465
|
+
// spawn the consumer and hand back an AbortController-based cleanup.
|
|
387
466
|
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
467
|
+
// There is no `session.idle` and no message list accessor in v2, so a turn
|
|
468
|
+
// is reconstructed from the public event stream:
|
|
469
|
+
// session.inbox.enqueued -> user text (data.item.payload.text;
|
|
470
|
+
// session.input.admitted / data.input.data
|
|
471
|
+
// on builds up to next-16502)
|
|
472
|
+
// session.text.ended -> assistant part (data: sessionID,
|
|
473
|
+
// assistantMessageID, ordinal, text)
|
|
474
|
+
// session.execution.succeeded -> flush (failed / interrupted flush
|
|
475
|
+
// with metadata.failed = true)
|
|
476
|
+
// An execution event with no pending user text (compaction, synthetic
|
|
477
|
+
// input, a turn that started before this plugin generation loaded) captures
|
|
478
|
+
// nothing — same as v1's skip on an empty turn.
|
|
479
|
+
const MAX_TRACKED_CAPTURE = 200;
|
|
480
|
+
const pendingBySession = new Map(); // sessionID -> { userText, texts: Map<aid, Map<ordinal, text>>, lastAid }
|
|
481
|
+
const pendingState = (sessionID) => {
|
|
482
|
+
let state = pendingBySession.get(sessionID);
|
|
483
|
+
if (!state) {
|
|
484
|
+
state = { userText: "", texts: new Map(), lastAid: "" };
|
|
485
|
+
pendingBySession.set(sessionID, state);
|
|
486
|
+
while (pendingBySession.size > MAX_TRACKED_CAPTURE) {
|
|
487
|
+
const oldest = pendingBySession.keys().next().value;
|
|
488
|
+
if (oldest === undefined) break;
|
|
489
|
+
pendingBySession.delete(oldest);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return state;
|
|
493
|
+
};
|
|
494
|
+
const flushTurn = async (sessionID, failed) => {
|
|
391
495
|
const live = await currentConfig();
|
|
392
496
|
if (!live.capture) return;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
497
|
+
const pending = pendingBySession.get(sessionID);
|
|
498
|
+
pendingBySession.delete(sessionID);
|
|
499
|
+
if (!pending || !pending.userText || !pending.lastAid) return;
|
|
500
|
+
const parts = pending.texts.get(pending.lastAid);
|
|
501
|
+
const assistantText = parts
|
|
502
|
+
? [...parts.entries()].sort((a, b) => a[0] - b[0]).map(([, text]) => text).join("\n").trim()
|
|
503
|
+
: "";
|
|
504
|
+
if (!assistantText) return;
|
|
505
|
+
const assistantID = pending.lastAid;
|
|
506
|
+
if (!claimCapturedAssistant(assistantID)) return;
|
|
507
|
+
const ancestry = await resolveSessionAncestry(
|
|
508
|
+
{
|
|
509
|
+
get: (input) => {
|
|
510
|
+
const id = typeof input === "string" ? input : input?.sessionID || input?.path?.id;
|
|
511
|
+
return ctx.session.get({ sessionID: id });
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
sessionID,
|
|
515
|
+
);
|
|
516
|
+
if (ancestry.session_type !== "root" && !live.capture_child_sessions) {
|
|
517
|
+
capturedAssistantIDs.delete(assistantID);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
405
520
|
const metadata = { source: "opencode", session_id: sessionID, format: "turn", ...ancestry };
|
|
406
|
-
if (
|
|
521
|
+
if (failed) metadata.failed = true;
|
|
407
522
|
const stored = await rest.postJson(
|
|
408
523
|
"/v1/memories",
|
|
409
524
|
{
|
|
410
|
-
content: buildTurnCapture(userText, assistantText, live.capture_user_max_chars, live.capture_assistant_max_chars),
|
|
525
|
+
content: buildTurnCapture(pending.userText, assistantText, live.capture_user_max_chars, live.capture_assistant_max_chars),
|
|
411
526
|
tags: ["opencode"],
|
|
412
527
|
metadata,
|
|
413
528
|
},
|
|
414
529
|
live.namespace,
|
|
415
530
|
);
|
|
416
|
-
if (stored
|
|
531
|
+
if (stored === null) capturedAssistantIDs.delete(assistantID);
|
|
532
|
+
};
|
|
533
|
+
const handleEvent = async (event) => {
|
|
534
|
+
const type = event && event.type;
|
|
535
|
+
const data = (event && event.data) || {};
|
|
536
|
+
const sessionID = data.sessionID || (data.properties && data.properties.sessionID);
|
|
537
|
+
if (!sessionID) return;
|
|
538
|
+
if (type === "session.inbox.enqueued" || type === "session.input.admitted") {
|
|
539
|
+
// next-17444 renamed the event and its payload: session.input.admitted
|
|
540
|
+
// { input: { type, data } } became session.inbox.enqueued
|
|
541
|
+
// { item: { type, payload } }. Both carry Prompt.fields, so `text` is
|
|
542
|
+
// read the same way once the wrapper is unwrapped. Synthetic and
|
|
543
|
+
// compaction items are skipped, as the "user" check always did.
|
|
544
|
+
const item = data.item || data.input;
|
|
545
|
+
const payload = item && item.type === "user" ? item.payload || item.data : null;
|
|
546
|
+
const text = payload && payload.text;
|
|
547
|
+
const pending = pendingState(sessionID);
|
|
548
|
+
pending.userText = typeof text === "string" ? text : "";
|
|
549
|
+
pending.texts = new Map();
|
|
550
|
+
pending.lastAid = "";
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (type === "session.text.ended") {
|
|
554
|
+
if (!data.assistantMessageID || typeof data.text !== "string") return;
|
|
555
|
+
const pending = pendingState(sessionID);
|
|
556
|
+
let parts = pending.texts.get(data.assistantMessageID);
|
|
557
|
+
if (!parts) {
|
|
558
|
+
parts = new Map();
|
|
559
|
+
pending.texts.set(data.assistantMessageID, parts);
|
|
560
|
+
}
|
|
561
|
+
parts.set(data.ordinal ?? parts.size, data.text);
|
|
562
|
+
pending.lastAid = data.assistantMessageID;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (type === "session.execution.succeeded") return flushTurn(sessionID, false);
|
|
566
|
+
if (type === "session.execution.failed" || type === "session.execution.interrupted") {
|
|
567
|
+
return flushTurn(sessionID, true);
|
|
568
|
+
}
|
|
417
569
|
};
|
|
418
570
|
|
|
419
571
|
if (cfg.capture && ctx && ctx.event && typeof ctx.event.subscribe === "function") {
|
|
420
572
|
const controller = new AbortController();
|
|
573
|
+
let iterator;
|
|
421
574
|
const task = (async () => {
|
|
422
575
|
let stream;
|
|
423
576
|
try {
|
|
424
|
-
stream = ctx.event.subscribe(
|
|
577
|
+
stream = ctx.event.subscribe();
|
|
425
578
|
} catch (error) {
|
|
426
579
|
log.warn(`capture unavailable: ctx.event.subscribe threw: ${String(error)}`);
|
|
427
580
|
return;
|
|
@@ -431,10 +584,13 @@ export async function setup(ctx) {
|
|
|
431
584
|
return;
|
|
432
585
|
}
|
|
433
586
|
try {
|
|
434
|
-
|
|
587
|
+
iterator = stream[Symbol.asyncIterator]();
|
|
588
|
+
for (;;) {
|
|
435
589
|
if (controller.signal.aborted) break;
|
|
590
|
+
const { value: event, done } = await iterator.next();
|
|
591
|
+
if (done) break;
|
|
436
592
|
try {
|
|
437
|
-
await
|
|
593
|
+
await handleEvent(event);
|
|
438
594
|
} catch (error) {
|
|
439
595
|
log.warn(`event hook failed: ${String(error)}`);
|
|
440
596
|
}
|
|
@@ -445,6 +601,11 @@ export async function setup(ctx) {
|
|
|
445
601
|
})();
|
|
446
602
|
cleanups.push(async () => {
|
|
447
603
|
controller.abort();
|
|
604
|
+
try {
|
|
605
|
+
if (iterator && typeof iterator.return === "function") await iterator.return();
|
|
606
|
+
} catch {
|
|
607
|
+
/* closing the event stream is best-effort */
|
|
608
|
+
}
|
|
448
609
|
await task.catch(() => {});
|
|
449
610
|
});
|
|
450
611
|
} else if (cfg.capture) {
|