@eleboucher/opencode-memini 0.6.12 → 0.7.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 +58 -35
- package/memini-v2.js +384 -0
- package/memini.js +291 -274
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -33,6 +33,33 @@ for every project:
|
|
|
33
33
|
|
|
34
34
|
opencode installs it from npm with Bun at startup.
|
|
35
35
|
|
|
36
|
+
### opencode v2 (beta)
|
|
37
|
+
|
|
38
|
+
opencode's v2 preview (`opencode2`) uses a different plugin system: a `plugins`
|
|
39
|
+
(plural) config array and a `Plugin.define({ id, setup })` module — the v1
|
|
40
|
+
`{ id, server }` plugin above does not load under it. memini ships a v2 sibling
|
|
41
|
+
at the `/v2` subpath that wires the same recall / capture / `memini_status`
|
|
42
|
+
behaviour to the v2 `ctx.session.hook("request")`, `ctx.event.subscribe`, and
|
|
43
|
+
`ctx.tool.transform` API:
|
|
44
|
+
|
|
45
|
+
```jsonc
|
|
46
|
+
{
|
|
47
|
+
"$schema": "https://opencode.ai/config.json",
|
|
48
|
+
"plugins": [["@eleboucher/opencode-memini/v2", { "namespace": "my-project" }]],
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The same options and env vars below apply. Recall injects into the request's
|
|
53
|
+
`system` prompt (rather than a synthetic message part).
|
|
54
|
+
|
|
55
|
+
> **Beta status.** The v2 plugin `ctx` is still gaining these hooks upstream.
|
|
56
|
+
> On a build where `ctx.session.hook` / `ctx.event.subscribe` / `ctx.tool.transform`
|
|
57
|
+
> are absent, the plugin logs which capability is unavailable and no-ops that
|
|
58
|
+
> part rather than crashing — it activates cleanly and lights up automatically
|
|
59
|
+
> once opencode exposes the hook. Track the plugin `ctx` in opencode's
|
|
60
|
+
> `core/src/plugin/host.ts`. Until then, stay on the v1 entry above with the
|
|
61
|
+
> stable `opencode` binary.
|
|
62
|
+
|
|
36
63
|
### Configure
|
|
37
64
|
|
|
38
65
|
Pass options inline via the `[name, options]` form:
|
|
@@ -45,8 +72,8 @@ Pass options inline via the `[name, options]` form:
|
|
|
45
72
|
|
|
46
73
|
| Option | Env var | Default | Purpose |
|
|
47
74
|
| ------------------- | -------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
48
|
-
| `base_url` | `MEMINI_BASE_URL` | `http://localhost:8080` | memini REST base URL
|
|
49
|
-
| `namespace` | `MEMINI_NAMESPACE` |
|
|
75
|
+
| `base_url` | `MEMINI_BASE_URL` | `http://localhost:8080` | memini REST base URL |
|
|
76
|
+
| `namespace` | `MEMINI_NAMESPACE` | server handshake | project the memory is scoped to (`X-Memini-Namespace`) |
|
|
50
77
|
| `home` | `MEMINI_HOME` | unset | caller's personal namespace, sent as `X-Memini-Home`; unset = no home leg |
|
|
51
78
|
| `recall` | `MEMINI_RECALL` | on | `false` disables recall-before-turn |
|
|
52
79
|
| `capture` | `MEMINI_CAPTURE` | on | `false` disables capture-after-turn |
|
|
@@ -57,7 +84,7 @@ Pass options inline via the `[name, options]` form:
|
|
|
57
84
|
| `timeout_ms` | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout (recall past its budget keeps running in the background under this bound) |
|
|
58
85
|
| `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
|
|
59
86
|
| — | `MEMINI_INJECT_LABELS` | — | comma-separated label toggles for each bullet: `tier`, `confidence`, `age`, `reason` |
|
|
60
|
-
| — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret
|
|
87
|
+
| — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret) |
|
|
61
88
|
| — | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
|
|
62
89
|
|
|
63
90
|
opencode awaits `chat.message` before the model sees the message, so a slow or
|
|
@@ -76,49 +103,45 @@ Inline options win over the env vars. Secrets stay in the environment: set
|
|
|
76
103
|
opencode — not in `opencode.json`.
|
|
77
104
|
|
|
78
105
|
Every option is optional, `namespace` included: `["@eleboucher/opencode-memini"]`
|
|
79
|
-
runs with no config.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
`{tenant}/{project}/{agent}`): `{tenant}` from the `tenantRoots` entry whose
|
|
88
|
-
`path` contains the cwd, `{project}` from the git repo, `{agent}` from
|
|
89
|
-
`MEMINI_AGENT`; unresolved segments are dropped. The Hermes and Pi integrations
|
|
90
|
-
share this resolver, so one config file scopes them all identically.
|
|
106
|
+
runs with no config. On plugin load (and again every 10 minutes thereafter),
|
|
107
|
+
the plugin calls `POST /v1/handshake` with what it cheaply knows about the
|
|
108
|
+
project (the git remote/toplevel, when the worktree is a repo, plus the
|
|
109
|
+
worktree basename) and lets the server resolve the namespace and behavioral
|
|
110
|
+
settings (`recall`, `capture`, `recall_limit`, the recall-injection budget)
|
|
111
|
+
the same way every other memini client does. The call is fail-soft: any
|
|
112
|
+
error or a ~2.5s timeout falls back to purely local resolution below, so an
|
|
113
|
+
unreachable or older memini never breaks a turn.
|
|
91
114
|
|
|
92
115
|
### Namespace resolution
|
|
93
116
|
|
|
94
|
-
In full, in order:
|
|
95
|
-
|
|
96
|
-
`
|
|
117
|
+
In full, in order: the `namespace` option / `MEMINI_NAMESPACE` > the
|
|
118
|
+
server's handshake-resolved namespace > the git worktree basename > the
|
|
119
|
+
built-in default (`opencode`).
|
|
120
|
+
|
|
121
|
+
The option/env tier wins over the handshake outright and deliberately: a
|
|
122
|
+
`namespace` option in a global `~/.config/opencode/opencode.json`, or a
|
|
123
|
+
globally exported `MEMINI_NAMESPACE`, is this integration's own explicit pin
|
|
124
|
+
and is honored as such rather than second-guessed by the server. Absent
|
|
125
|
+
either, the server's resolution (which can draw on a pin, the git remote, or
|
|
126
|
+
an operator's per-key default) wins over this plugin's own git
|
|
127
|
+
worktree/default fallback, which only applies when the handshake itself is
|
|
128
|
+
unavailable.
|
|
97
129
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
`~/.config/opencode/opencode.json`), and if either won, setting an override would
|
|
102
|
-
silently do nothing on exactly the machines that need one. The file is keyed by
|
|
103
|
-
git toplevel, so an override set at the top of a repo applies from any
|
|
104
|
-
subdirectory; it is the same file the Claude Code plugin writes and `memini
|
|
105
|
-
doctor` reads; and a malformed one degrades to automatic resolution rather than
|
|
106
|
-
breaking a turn.
|
|
130
|
+
Each recall/capture setting follows the same shape: the plugin option beats
|
|
131
|
+
`MEMINI_NAMESPACE`'s sibling env vars above beats the server's resolved
|
|
132
|
+
`ClientSettings` beats the built-in default baked into this plugin.
|
|
107
133
|
|
|
108
134
|
### The `memini_status` tool
|
|
109
135
|
|
|
110
136
|
The plugin registers one tool, `memini_status`: read-only, no arguments. It
|
|
111
|
-
reports the namespace in force and where it came from
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
137
|
+
reports the namespace in force and where it came from (the namespace option,
|
|
138
|
+
`MEMINI_NAMESPACE`, the server's handshake, or the git worktree fallback),
|
|
139
|
+
what it would be without the env/option pin, the connection settings (the
|
|
140
|
+
API key fingerprinted, never printed), and warnings — a global
|
|
141
|
+
`MEMINI_NAMESPACE` pin, a bearer token crossing plaintext HTTP.
|
|
115
142
|
|
|
116
143
|
There is no `/memini:status` slash command: opencode's plugin contract registers
|
|
117
144
|
tools, not commands, and this plugin does not invent an API it does not have.
|
|
118
|
-
Setting or clearing an override is likewise not exposed here — declaring a tool
|
|
119
|
-
argument requires a zod schema, and this plugin ships dependency-free — so use
|
|
120
|
-
`/memini:namespace` from the Claude Code plugin, or edit `overrides.json`
|
|
121
|
-
directly; all harnesses read the same file.
|
|
122
145
|
|
|
123
146
|
### Tests
|
|
124
147
|
|
package/memini-v2.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memini memory plugin for opencode v2 (the Plugin.define / `setup` API).
|
|
3
|
+
*
|
|
4
|
+
* This is the v2 sibling of memini.js. It targets the documented v2 plugin
|
|
5
|
+
* contract (https://v2.opencode.ai — "Plugins"):
|
|
6
|
+
* - ctx.session.hook("request", …): recall memories relevant to the incoming
|
|
7
|
+
* turn and inject them into the model request (the `system` prompt) before
|
|
8
|
+
* dispatch. This is the v2 equivalent of v1's `chat.message`.
|
|
9
|
+
* - ctx.event.subscribe("session.idle"): once a session goes idle, capture the
|
|
10
|
+
* completed user/assistant turn into memini. The v2 equivalent of v1's
|
|
11
|
+
* `event` hook. Started detached (never awaited inside `setup`, per the docs)
|
|
12
|
+
* and torn down by the returned cleanup.
|
|
13
|
+
* - ctx.tool.transform(t => t.add(…)): register the read-only `memini_status`
|
|
14
|
+
* tool. The v2 equivalent of v1's `tool: { memini_status }`.
|
|
15
|
+
*
|
|
16
|
+
* Everything that is not opencode-contract-specific — config/namespace
|
|
17
|
+
* resolution, the handshake, formatting, token budgeting, status rendering, the
|
|
18
|
+
* REST client — is imported from memini.js so the two plugin generations stay
|
|
19
|
+
* byte-for-byte identical in behaviour. Only the host wiring differs.
|
|
20
|
+
*
|
|
21
|
+
* Dependency-free, like memini.js: `Plugin.define` is an identity function
|
|
22
|
+
* upstream (it returns its argument unchanged), so the module default is a plain
|
|
23
|
+
* `{ id, setup }` object. Importing @opencode-ai/plugin would add a runtime
|
|
24
|
+
* dependency this plugin has never needed and buys nothing.
|
|
25
|
+
*
|
|
26
|
+
* The v2 plugin API is beta and its ctx is still gaining hooks upstream. Every
|
|
27
|
+
* capability below is feature-detected: on a build where `ctx.session.hook`,
|
|
28
|
+
* `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.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
HANDSHAKE_TTL_MS,
|
|
34
|
+
resolveConfig,
|
|
35
|
+
buildFacts,
|
|
36
|
+
effectiveConfig,
|
|
37
|
+
memoizeAsync,
|
|
38
|
+
extractPartsText,
|
|
39
|
+
formatResults,
|
|
40
|
+
fitByTokens,
|
|
41
|
+
labelsEnv,
|
|
42
|
+
extractLastTurn,
|
|
43
|
+
lastAssistantFailed,
|
|
44
|
+
describeSettings,
|
|
45
|
+
renderStatus,
|
|
46
|
+
createClient,
|
|
47
|
+
} from "./memini.js";
|
|
48
|
+
|
|
49
|
+
const INJECT_PREAMBLE =
|
|
50
|
+
"Relevant long-term memory from memini (background context — prefer " +
|
|
51
|
+
"current workspace state and the user's instructions):";
|
|
52
|
+
|
|
53
|
+
// messageText pulls the plain text out of one v2 request message, tolerating the
|
|
54
|
+
// shapes the beta may hand us: `content` as a string, `content` as an array of
|
|
55
|
+
// `{ type: "text", text }` parts, or a v1-style `parts` array.
|
|
56
|
+
function messageText(msg) {
|
|
57
|
+
if (!msg) return "";
|
|
58
|
+
if (typeof msg.content === "string") return msg.content.trim();
|
|
59
|
+
if (Array.isArray(msg.content)) {
|
|
60
|
+
return msg.content
|
|
61
|
+
.map((p) => (p && typeof p.text === "string" ? p.text : ""))
|
|
62
|
+
.join("\n")
|
|
63
|
+
.trim();
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(msg.parts)) return extractPartsText(msg.parts);
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// extractQueryFromRequest returns the latest user text from a request event's
|
|
70
|
+
// `messages`, falling back to the last non-empty message so a recall still fires
|
|
71
|
+
// on unusual message layouts. Exported for testing.
|
|
72
|
+
export function extractQueryFromRequest(event) {
|
|
73
|
+
const messages = Array.isArray(event && event.messages) ? event.messages : [];
|
|
74
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
75
|
+
const role = messages[i] && (messages[i].role || (messages[i].info && messages[i].info.role));
|
|
76
|
+
if (role === "user") {
|
|
77
|
+
const text = messageText(messages[i]);
|
|
78
|
+
if (text) return text;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
82
|
+
const text = messageText(messages[i]);
|
|
83
|
+
if (text) return text;
|
|
84
|
+
}
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// injectContext places the rendered memory block where the model will see it:
|
|
89
|
+
// the request event's `system` array first (transient, not persisted as a
|
|
90
|
+
// message part), falling back to prepending a system message. Returns true when
|
|
91
|
+
// it found a slot. Exported for testing.
|
|
92
|
+
export function injectContext(event, block) {
|
|
93
|
+
if (!event || !block) return false;
|
|
94
|
+
if (Array.isArray(event.system)) {
|
|
95
|
+
event.system.push(block);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(event.messages)) {
|
|
99
|
+
event.messages.unshift({ role: "system", content: block });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// fetchSessionMessages reads a session's message list through whichever server
|
|
106
|
+
// client method the running build exposes ([{info, parts}, …] is what
|
|
107
|
+
// extractLastTurn expects). The v2 ctx is "essentially a server client", but the
|
|
108
|
+
// exact accessor for GET /api/session/{id}/message isn't pinned in the beta, so
|
|
109
|
+
// try the plausible names and unwrap the common envelope shapes.
|
|
110
|
+
async function fetchSessionMessages(ctx, sessionID) {
|
|
111
|
+
const session = ctx && ctx.session;
|
|
112
|
+
if (!session) return [];
|
|
113
|
+
const unwrap = (res) =>
|
|
114
|
+
Array.isArray(res) ? res
|
|
115
|
+
: Array.isArray(res && res.data) ? res.data
|
|
116
|
+
: Array.isArray(res && res.messages) ? res.messages
|
|
117
|
+
: [];
|
|
118
|
+
const attempts = [
|
|
119
|
+
() => session.messages && session.messages({ path: { id: sessionID } }),
|
|
120
|
+
() => session.message && session.message({ path: { id: sessionID } }),
|
|
121
|
+
() => session.messages && session.messages(sessionID),
|
|
122
|
+
];
|
|
123
|
+
for (const attempt of attempts) {
|
|
124
|
+
try {
|
|
125
|
+
const res = await attempt();
|
|
126
|
+
const arr = unwrap(res);
|
|
127
|
+
if (arr.length) return arr;
|
|
128
|
+
} catch {
|
|
129
|
+
/* try the next accessor shape */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function setup(ctx) {
|
|
136
|
+
const options = (ctx && ctx.options) || {};
|
|
137
|
+
// v2 setup(ctx) carries no worktree/directory the way v1's PluginInput did;
|
|
138
|
+
// opencode runs the plugin in the project root, so cwd is the project dir —
|
|
139
|
+
// the same input resolveConfig/deriveNamespace expect.
|
|
140
|
+
const dir = process.cwd();
|
|
141
|
+
const log = {
|
|
142
|
+
warn: (message) => {
|
|
143
|
+
// ctx is essentially a server client; mirror v1's structured logger and
|
|
144
|
+
// fall back to stderr.
|
|
145
|
+
try {
|
|
146
|
+
ctx?.app?.log?.({ body: { service: "memini", level: "warn", message } });
|
|
147
|
+
} catch {
|
|
148
|
+
/* ignore logging failures */
|
|
149
|
+
}
|
|
150
|
+
console.error(`[memini] ${message}`);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const cfg = resolveConfig(process.env, options, dir);
|
|
155
|
+
const rest = createClient(cfg, log);
|
|
156
|
+
|
|
157
|
+
// Handshake memoized per plugin instance (10-minute TTL), identical to the v1
|
|
158
|
+
// plugin: a null handshake (fail-soft) falls back to cfg's local resolution.
|
|
159
|
+
const getHandshake = memoizeAsync(
|
|
160
|
+
() => rest.handshake(buildFacts(dir, process.env)),
|
|
161
|
+
HANDSHAKE_TTL_MS,
|
|
162
|
+
);
|
|
163
|
+
const currentConfig = async () => effectiveConfig(cfg, await getHandshake());
|
|
164
|
+
|
|
165
|
+
// Assistant ids already captured, so repeated idle events for one turn don't
|
|
166
|
+
// write duplicates. Memory ids already injected per session, so an unchanged
|
|
167
|
+
// match isn't re-injected turn after turn. Both bounded for a long-lived host.
|
|
168
|
+
const captured = new Set();
|
|
169
|
+
const injectedBySession = new Map();
|
|
170
|
+
const MAX_TRACKED_SESSIONS = 200;
|
|
171
|
+
const rememberInjected = (session, ids) => {
|
|
172
|
+
let seen = injectedBySession.get(session);
|
|
173
|
+
if (!seen) {
|
|
174
|
+
seen = new Set();
|
|
175
|
+
injectedBySession.set(session, seen);
|
|
176
|
+
while (injectedBySession.size > MAX_TRACKED_SESSIONS) {
|
|
177
|
+
const oldest = injectedBySession.keys().next().value;
|
|
178
|
+
if (oldest === undefined) break;
|
|
179
|
+
injectedBySession.delete(oldest);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const id of ids) if (id) seen.add(id);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const cleanups = [];
|
|
186
|
+
const disposeReg = (reg) => {
|
|
187
|
+
if (typeof reg === "function") cleanups.push(reg);
|
|
188
|
+
else if (reg && typeof reg.dispose === "function") cleanups.push(() => reg.dispose());
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// --- RECALL: ctx.session.hook("request") -------------------------------
|
|
192
|
+
//
|
|
193
|
+
// opencode awaits this hook immediately before model dispatch (a throw fails
|
|
194
|
+
// the turn — the doc's "a hook failure fails the operation it intercepts"), so
|
|
195
|
+
// the callback swallows its own errors and races the search against
|
|
196
|
+
// recall_budget_ms: if memini is slow, the turn proceeds without memory rather
|
|
197
|
+
// than freezing for the full timeout_ms.
|
|
198
|
+
if (ctx && ctx.session && typeof ctx.session.hook === "function") {
|
|
199
|
+
const reg = await ctx.session.hook("request", async (event) => {
|
|
200
|
+
try {
|
|
201
|
+
const live = await currentConfig();
|
|
202
|
+
if (!live.recall) return;
|
|
203
|
+
const query = extractQueryFromRequest(event);
|
|
204
|
+
if (!query) return;
|
|
205
|
+
const sessionID = event.sessionID || event.sessionId || (event.session && event.session.id) || "";
|
|
206
|
+
|
|
207
|
+
const body = { query, limit: live.recall_limit };
|
|
208
|
+
// Exclude this session's own captured turns: they're still in the live
|
|
209
|
+
// context, so recalling them just echoes the conversation back a turn
|
|
210
|
+
// behind. Past sessions still recall.
|
|
211
|
+
if (sessionID) body.exclude_metadata = { session_id: sessionID };
|
|
212
|
+
if (live.recall_min_score > 0) body.min_score = live.recall_min_score;
|
|
213
|
+
|
|
214
|
+
// Blocking, like v1's chat.message: opencode awaits this hook before
|
|
215
|
+
// dispatch. postJson is bounded by cfg.timeout_ms and fail-soft, so a
|
|
216
|
+
// slow/unreachable memini degrades to no memory this turn, never a throw.
|
|
217
|
+
const result = await rest.postJson("/v1/search", body, live.namespace);
|
|
218
|
+
|
|
219
|
+
const floor = live.recall_min_score > 0 ? live.recall_min_score : 0;
|
|
220
|
+
let rawHits = Array.isArray(result && result.results) ? result.results : [];
|
|
221
|
+
if (sessionID) {
|
|
222
|
+
const seen = injectedBySession.get(sessionID);
|
|
223
|
+
if (seen && seen.size) rawHits = rawHits.filter((r) => !seen.has(r && r.memory && r.memory.id));
|
|
224
|
+
}
|
|
225
|
+
const filtered =
|
|
226
|
+
floor > 0
|
|
227
|
+
? rawHits.filter((r) => (typeof (r && r.score) === "number" ? r.score : 0) >= floor)
|
|
228
|
+
: rawHits;
|
|
229
|
+
const hits = formatResults(filtered, live.recall_limit, labelsEnv());
|
|
230
|
+
if (hits.length === 0) return;
|
|
231
|
+
const fit = fitByTokens(hits, live.recall_max_tokens);
|
|
232
|
+
if (fit.items.length === 0) return;
|
|
233
|
+
|
|
234
|
+
const lines = [INJECT_PREAMBLE, ...fit.items];
|
|
235
|
+
if (result && result.degraded) {
|
|
236
|
+
lines.push(
|
|
237
|
+
`[memini: ${result.note || "semantic search unavailable — results are keyword-only and may be incomplete"}]`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
|
|
241
|
+
|
|
242
|
+
if (injectContext(event, lines.join("\n")) && sessionID) {
|
|
243
|
+
rememberInjected(
|
|
244
|
+
sessionID,
|
|
245
|
+
filtered.map((r) => r && r.memory && r.memory.id).filter(Boolean),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
log.warn(`request hook failed: ${String(error)}`);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
disposeReg(reg);
|
|
253
|
+
} else if (cfg.recall) {
|
|
254
|
+
log.warn("recall unavailable: ctx.session.hook is not present on this opencode build");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- STATUS TOOL: ctx.tool.transform(t => t.add(...)) ------------------
|
|
258
|
+
if (ctx && ctx.tool && typeof ctx.tool.transform === "function") {
|
|
259
|
+
const reg = await ctx.tool.transform((tools) => {
|
|
260
|
+
tools.add({
|
|
261
|
+
name: "memini_status",
|
|
262
|
+
description:
|
|
263
|
+
"Show the memini memory settings in force for this project: which namespace memories " +
|
|
264
|
+
"are written to and recalled from, where that namespace came from (the namespace option, " +
|
|
265
|
+
"MEMINI_NAMESPACE, a server-resolved handshake, or the git worktree fallback), what it " +
|
|
266
|
+
"would be without the env/option pin, and any misconfiguration worth flagging. Read-only; " +
|
|
267
|
+
"secrets are redacted. Call it when the user asks what memini is doing, why a memory " +
|
|
268
|
+
"cannot be recalled, or which namespace is in use.",
|
|
269
|
+
jsonSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
270
|
+
options: { codemode: false },
|
|
271
|
+
execute: async () => {
|
|
272
|
+
try {
|
|
273
|
+
const report = describeSettings(process.env, options, dir);
|
|
274
|
+
// Overlay the live, handshake-aware values so the tool reports what
|
|
275
|
+
// the hooks actually did on their last handshake.
|
|
276
|
+
const live = await currentConfig();
|
|
277
|
+
report.namespace.effective = live.namespace;
|
|
278
|
+
report.namespace.source = live.namespace_source;
|
|
279
|
+
report.memory.recall = live.recall;
|
|
280
|
+
report.memory.capture = live.capture;
|
|
281
|
+
report.memory.recall_limit = live.recall_limit;
|
|
282
|
+
report.memory.recall_max_tokens = live.recall_max_tokens;
|
|
283
|
+
report.memory.recall_min_score = live.recall_min_score;
|
|
284
|
+
const text = renderStatus(report);
|
|
285
|
+
return {
|
|
286
|
+
structured: {
|
|
287
|
+
namespace: report.namespace.effective,
|
|
288
|
+
source: report.namespace.source,
|
|
289
|
+
},
|
|
290
|
+
content: [{ type: "text", text }],
|
|
291
|
+
};
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return { content: [{ type: "text", text: `memini status failed: ${String(error)}` }] };
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
disposeReg(reg);
|
|
299
|
+
} else {
|
|
300
|
+
log.warn("memini_status unavailable: ctx.tool.transform is not present on this opencode build");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- CAPTURE: ctx.event.subscribe("session.idle") ----------------------
|
|
304
|
+
//
|
|
305
|
+
// Detached: the docs say not to await an infinite stream inside setup. We spawn
|
|
306
|
+
// the consumer and hand back an AbortController-based cleanup.
|
|
307
|
+
const handleIdle = async (event) => {
|
|
308
|
+
const live = await currentConfig();
|
|
309
|
+
if (!live.capture) return;
|
|
310
|
+
// The stream may be narrower or broader than session.idle depending on how
|
|
311
|
+
// subscribe filters; guard on the type when present.
|
|
312
|
+
if (event && event.type && event.type !== "session.idle") return;
|
|
313
|
+
const sessionID =
|
|
314
|
+
(event && event.properties && event.properties.sessionID) || (event && event.sessionID);
|
|
315
|
+
if (!sessionID) return;
|
|
316
|
+
const messages = await fetchSessionMessages(ctx, sessionID);
|
|
317
|
+
const { userText, assistantText, assistantID } = extractLastTurn(messages);
|
|
318
|
+
if (!userText || !assistantText) return;
|
|
319
|
+
if (assistantID && captured.has(assistantID)) return;
|
|
320
|
+
const metadata = { source: "opencode", session_id: sessionID, format: "turn" };
|
|
321
|
+
if (lastAssistantFailed(messages)) metadata.failed = true;
|
|
322
|
+
const stored = await rest.postJson(
|
|
323
|
+
"/v1/memories",
|
|
324
|
+
{
|
|
325
|
+
content: `${userText.slice(0, 1000)}\n\n${assistantText.slice(0, 3000)}`,
|
|
326
|
+
tags: ["opencode"],
|
|
327
|
+
metadata,
|
|
328
|
+
},
|
|
329
|
+
live.namespace,
|
|
330
|
+
);
|
|
331
|
+
if (stored !== null && assistantID) captured.add(assistantID);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
if (cfg.capture && ctx && ctx.event && typeof ctx.event.subscribe === "function") {
|
|
335
|
+
const controller = new AbortController();
|
|
336
|
+
const task = (async () => {
|
|
337
|
+
let stream;
|
|
338
|
+
try {
|
|
339
|
+
stream = ctx.event.subscribe("session.idle");
|
|
340
|
+
} catch (error) {
|
|
341
|
+
log.warn(`capture unavailable: ctx.event.subscribe threw: ${String(error)}`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== "function") {
|
|
345
|
+
log.warn("capture unavailable: ctx.event.subscribe did not return an async iterable");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
for await (const event of stream) {
|
|
350
|
+
if (controller.signal.aborted) break;
|
|
351
|
+
try {
|
|
352
|
+
await handleIdle(event);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
log.warn(`event hook failed: ${String(error)}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (!controller.signal.aborted) log.warn(`event stream failed: ${String(error)}`);
|
|
359
|
+
}
|
|
360
|
+
})();
|
|
361
|
+
cleanups.push(async () => {
|
|
362
|
+
controller.abort();
|
|
363
|
+
await task.catch(() => {});
|
|
364
|
+
});
|
|
365
|
+
} else if (cfg.capture) {
|
|
366
|
+
log.warn("capture unavailable: ctx.event.subscribe is not present on this opencode build");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Cleanup: dispose hook/tool registrations and stop the capture consumer when
|
|
370
|
+
// the plugin is disabled, reloaded, or shut down.
|
|
371
|
+
return async () => {
|
|
372
|
+
for (const cleanup of cleanups) {
|
|
373
|
+
try {
|
|
374
|
+
await cleanup();
|
|
375
|
+
} catch {
|
|
376
|
+
/* ignore cleanup failures */
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Plugin.define is identity upstream, so a plain { id, setup } is the module
|
|
383
|
+
// default — no @opencode-ai/plugin dependency needed. See the file header.
|
|
384
|
+
export default { id: "memini", setup };
|