@eleboucher/opencode-memini 0.2.8
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 +92 -0
- package/memini.js +249 -0
- package/package.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# memini + opencode
|
|
2
|
+
|
|
3
|
+
[opencode](https://opencode.ai) supports [plugins](https://opencode.ai/docs/plugins/)
|
|
4
|
+
— JS/TS modules that hook into its lifecycle. memini ships a native plugin in
|
|
5
|
+
[`plugin/`](plugin/) that makes memory automatic: it recalls relevant memories
|
|
6
|
+
before each turn and captures completed turns afterward, with no tool calls
|
|
7
|
+
required from the model.
|
|
8
|
+
|
|
9
|
+
## Recommended: the memory plugin
|
|
10
|
+
|
|
11
|
+
What it wires (two hooks):
|
|
12
|
+
|
|
13
|
+
- **`chat.message`** — searches memini for the incoming user message and
|
|
14
|
+
prepends the matches as a synthetic context part before the turn runs.
|
|
15
|
+
- **`event` (`session.idle`)** — once the session goes idle, stores the
|
|
16
|
+
completed user/assistant turn back into memini (episodic) so it can be
|
|
17
|
+
recalled later.
|
|
18
|
+
|
|
19
|
+
### Install
|
|
20
|
+
|
|
21
|
+
Add the package to the `plugin` array in your `opencode.json` — a project
|
|
22
|
+
`opencode.json` to scope it to one repo, or `~/.config/opencode/opencode.json`
|
|
23
|
+
for every project:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"$schema": "https://opencode.ai/config.json",
|
|
28
|
+
"plugin": ["@eleboucher/opencode-memini"]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
opencode installs it from npm with Bun at startup.
|
|
33
|
+
|
|
34
|
+
### Configure
|
|
35
|
+
|
|
36
|
+
Pass options inline via the `[name, options]` form:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"plugin": [["@eleboucher/opencode-memini", { "namespace": "my-project", "recall_limit": 8 }]]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Option | Env var | Default | Purpose |
|
|
45
|
+
| ------------------- | ---------------------- | ----------------------- | ------------------------------------------------------ |
|
|
46
|
+
| `base_url` | `MEMINI_BASE_URL` | `http://localhost:8080` | memini REST base URL |
|
|
47
|
+
| `namespace` | `MEMINI_NAMESPACE` | git repo basename | tenant the memory is scoped to (`X-Memini-Namespace`) |
|
|
48
|
+
| `recall` | `MEMINI_RECALL` | on | `false` disables recall-before-turn |
|
|
49
|
+
| `capture` | `MEMINI_CAPTURE` | on | `false` disables capture-after-turn |
|
|
50
|
+
| `recall_limit` | `MEMINI_RECALL_LIMIT` | `5` | max memories injected per turn |
|
|
51
|
+
| `timeout_ms` | `MEMINI_TIMEOUT_MS` | `5000` | per-request timeout |
|
|
52
|
+
| `fallback_on_error` | `MEMINI_FALLBACK` | on | `false` surfaces errors instead of degrading silently |
|
|
53
|
+
| — | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (env only — secret) |
|
|
54
|
+
| — | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
|
|
55
|
+
|
|
56
|
+
Inline options win over the env vars. Secrets stay in the environment: set
|
|
57
|
+
`MEMINI_API_KEY` (sent as `Authorization: Bearer …`), and optionally
|
|
58
|
+
`MEMINI_REQUIRE_HTTPS=1` to refuse plaintext HTTP, in the shell that launches
|
|
59
|
+
opencode — not in `opencode.json`.
|
|
60
|
+
|
|
61
|
+
Every option is optional, `namespace` included: `["@eleboucher/opencode-memini"]`
|
|
62
|
+
runs with no config. Unset, the plugin derives the namespace from the git
|
|
63
|
+
worktree basename and sends it as the `X-Memini-Namespace` header, so scoping
|
|
64
|
+
stays correct even against a remote memini (the HTTP MCP wire below can't — a
|
|
65
|
+
remote server has no access to your cwd). Set it to share one memory pool with
|
|
66
|
+
your other agents.
|
|
67
|
+
|
|
68
|
+
### Tests
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
cd integrations/opencode/plugin && node --test
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Alternative: manual MCP wire (tools, not automatic)
|
|
75
|
+
|
|
76
|
+
Instead of the plugin you can expose memini's `memory_*` tools to the model and
|
|
77
|
+
let it call them on demand. opencode reads MCP servers from `opencode.json` under
|
|
78
|
+
`mcp`. This wire gives the model the full tool set, including `memory_recall`
|
|
79
|
+
(with `tags` / `metadata` filters), `memory_list` (query-less browse by tier /
|
|
80
|
+
tags / metadata category), and `memory_remember`. Set `metadata.category` on
|
|
81
|
+
writes to browse by subject later — see `docs/categories.md`.
|
|
82
|
+
|
|
83
|
+
**Remote:** merge [`opencode.json`](opencode.json) into your config.
|
|
84
|
+
|
|
85
|
+
**Local (stdio):** see [`opencode.local.json`](opencode.local.json) — opencode
|
|
86
|
+
spawns `memini mcp`.
|
|
87
|
+
|
|
88
|
+
Mind the context budget: memini adds a handful of tools, which is light, but
|
|
89
|
+
disable other large MCP servers you aren't using. Use the same
|
|
90
|
+
`X-Memini-Namespace` as your other agents to share memory. `my-project` is a
|
|
91
|
+
placeholder — replace it with your real project name, or drop the header and let
|
|
92
|
+
memini auto-resolve from its own working directory.
|
package/memini.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memini memory plugin for opencode.
|
|
3
|
+
*
|
|
4
|
+
* Hooks opencode's plugin API so memory is automatic — the model never has to
|
|
5
|
+
* call a tool:
|
|
6
|
+
* - chat.message: recall memories relevant to the incoming user message and
|
|
7
|
+
* inject them as a synthetic context part before the turn runs.
|
|
8
|
+
* - event (session.idle): capture the completed user/assistant turn into
|
|
9
|
+
* memini as episodic memory once the session goes idle.
|
|
10
|
+
*
|
|
11
|
+
* Talks to memini over REST (/v1/search, /v1/memories), scoped by the
|
|
12
|
+
* X-Memini-Namespace header. Default endpoint http://localhost:8080.
|
|
13
|
+
*
|
|
14
|
+
* Config comes from the plugin options (the [name, options] form in
|
|
15
|
+
* opencode.json), with env-var fallbacks; secrets like MEMINI_API_KEY come from
|
|
16
|
+
* the environment. See the options/env table in ../README.md.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const DEFAULT_BASE_URL = "http://localhost:8080";
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
21
|
+
const DEFAULT_RECALL_LIMIT = 5;
|
|
22
|
+
const DEFAULT_NAMESPACE = "opencode";
|
|
23
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
24
|
+
|
|
25
|
+
function envBool(value, fallback) {
|
|
26
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
27
|
+
return !/^(0|false|no|off)$/i.test(String(value).trim());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// sanitizeNamespace keeps the X-Memini-Namespace value header-safe (the server
|
|
31
|
+
// sanitizes too, but the header should be clean): alnum, dot, dash, underscore;
|
|
32
|
+
// collapse the rest to dashes and trim.
|
|
33
|
+
function sanitizeNamespace(s) {
|
|
34
|
+
return String(s).trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// deriveNamespace scopes memory to the project: the basename of the git
|
|
38
|
+
// worktree (the repo dir name), which is the same scheme memini auto-resolves
|
|
39
|
+
// from a git repo. Use the same namespace across your other agents to share
|
|
40
|
+
// memory. Returns "" when no path is given.
|
|
41
|
+
export function deriveNamespace(worktree) {
|
|
42
|
+
if (typeof worktree !== "string" || !worktree.trim()) return "";
|
|
43
|
+
const base = worktree.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
|
|
44
|
+
return sanitizeNamespace(base);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// resolveConfig merges env vars with the options object (options win), filling
|
|
48
|
+
// in defaults. Exported for testing.
|
|
49
|
+
export function resolveConfig(env, options, worktree) {
|
|
50
|
+
const e = env || {};
|
|
51
|
+
const o = options || {};
|
|
52
|
+
const namespace =
|
|
53
|
+
o.namespace || e.MEMINI_NAMESPACE || deriveNamespace(worktree) || DEFAULT_NAMESPACE;
|
|
54
|
+
return {
|
|
55
|
+
base_url: o.base_url || e.MEMINI_BASE_URL || DEFAULT_BASE_URL,
|
|
56
|
+
namespace: sanitizeNamespace(namespace) || DEFAULT_NAMESPACE,
|
|
57
|
+
recall: o.recall !== undefined ? o.recall !== false : envBool(e.MEMINI_RECALL, true),
|
|
58
|
+
capture: o.capture !== undefined ? o.capture !== false : envBool(e.MEMINI_CAPTURE, true),
|
|
59
|
+
recall_limit: Number(o.recall_limit || e.MEMINI_RECALL_LIMIT || DEFAULT_RECALL_LIMIT),
|
|
60
|
+
timeout_ms: Number(o.timeout_ms || e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
|
61
|
+
fallback_on_error:
|
|
62
|
+
o.fallback_on_error !== undefined
|
|
63
|
+
? o.fallback_on_error !== false
|
|
64
|
+
: envBool(e.MEMINI_FALLBACK, true),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// extractPartsText joins the text of a message's parts, skipping our injected
|
|
69
|
+
// recall context (synthetic) and ignored parts so captured turns hold only what
|
|
70
|
+
// the user wrote and what the assistant replied. Exported for testing.
|
|
71
|
+
export function extractPartsText(parts) {
|
|
72
|
+
if (!Array.isArray(parts)) return "";
|
|
73
|
+
return parts
|
|
74
|
+
.filter((p) => p && p.type === "text" && p.synthetic !== true && p.ignored !== true)
|
|
75
|
+
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
|
76
|
+
.join("\n")
|
|
77
|
+
.trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// formatResults renders memini search hits as a compact bullet list. Exported
|
|
81
|
+
// for testing.
|
|
82
|
+
export function formatResults(results, limit) {
|
|
83
|
+
if (!Array.isArray(results) || results.length === 0) return "";
|
|
84
|
+
return results
|
|
85
|
+
.slice(0, limit || DEFAULT_RECALL_LIMIT)
|
|
86
|
+
.map((result, index) => {
|
|
87
|
+
const mem = (result && result.memory) || {};
|
|
88
|
+
const text = String(mem.summary || mem.content || `Memory ${index + 1}`).trim();
|
|
89
|
+
const tier = String(mem.tier || "memory").trim();
|
|
90
|
+
return `- (${tier}) ${text.slice(0, 300)}`;
|
|
91
|
+
})
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizedHostname(hostname) {
|
|
97
|
+
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function usesPlaintextBearerAuth(baseUrl, secret) {
|
|
101
|
+
if (!secret) return false;
|
|
102
|
+
try {
|
|
103
|
+
const parsed = new URL(baseUrl);
|
|
104
|
+
return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function plaintextBearerAuthMessage(baseUrl) {
|
|
111
|
+
return `memini: MEMINI_API_KEY is configured for plaintext HTTP to ${baseUrl}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// createPlaintextBearerAuthGuard refuses (MEMINI_REQUIRE_HTTPS=1) or warns once
|
|
115
|
+
// when a bearer token would be sent over plaintext HTTP to a non-loopback host.
|
|
116
|
+
// Exported for testing.
|
|
117
|
+
export function createPlaintextBearerAuthGuard(warn, env) {
|
|
118
|
+
let warned = false;
|
|
119
|
+
return function guardPlaintextBearerAuth(baseUrl, secret) {
|
|
120
|
+
if (!usesPlaintextBearerAuth(baseUrl, secret)) return;
|
|
121
|
+
const message = plaintextBearerAuthMessage(baseUrl);
|
|
122
|
+
if ((env || process.env).MEMINI_REQUIRE_HTTPS === "1") throw new Error(message);
|
|
123
|
+
if (!warned) {
|
|
124
|
+
warned = true;
|
|
125
|
+
warn(message);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createClient(cfg, log) {
|
|
131
|
+
const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
|
|
132
|
+
const secret = process.env.MEMINI_API_KEY;
|
|
133
|
+
const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard((m) => log.warn(m));
|
|
134
|
+
if (process.env.MEMINI_REQUIRE_HTTPS === "1") guardPlaintextBearerAuth(baseUrl, secret);
|
|
135
|
+
|
|
136
|
+
async function postJson(path, payload) {
|
|
137
|
+
guardPlaintextBearerAuth(baseUrl, secret);
|
|
138
|
+
const headers = { "Content-Type": "application/json", "X-Memini-Namespace": cfg.namespace };
|
|
139
|
+
if (secret) headers.Authorization = `Bearer ${secret}`;
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers,
|
|
144
|
+
body: JSON.stringify(payload),
|
|
145
|
+
signal: AbortSignal.timeout(cfg.timeout_ms),
|
|
146
|
+
});
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
if (cfg.fallback_on_error) return null;
|
|
149
|
+
const body = await res.text().catch(() => "");
|
|
150
|
+
throw new Error(`memini ${path} failed: ${res.status} ${body}`);
|
|
151
|
+
}
|
|
152
|
+
return await res.json();
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (!cfg.fallback_on_error) throw error;
|
|
155
|
+
log.warn(`memini: ${String(error)}`);
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { postJson, baseUrl };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// extractLastTurn returns the latest user and assistant text from the message
|
|
164
|
+
// list returned by client.session.messages ([{info, parts}, ...]), plus the id
|
|
165
|
+
// of the assistant message (for dedup). Exported for testing.
|
|
166
|
+
export function extractLastTurn(messages) {
|
|
167
|
+
let userText = "";
|
|
168
|
+
let assistantText = "";
|
|
169
|
+
let assistantID = "";
|
|
170
|
+
if (!Array.isArray(messages)) return { userText, assistantText, assistantID };
|
|
171
|
+
for (const entry of messages) {
|
|
172
|
+
const info = entry && entry.info;
|
|
173
|
+
if (!info) continue;
|
|
174
|
+
const text = extractPartsText(entry.parts);
|
|
175
|
+
if (!text) continue;
|
|
176
|
+
if (info.role === "user") userText = text;
|
|
177
|
+
else if (info.role === "assistant") {
|
|
178
|
+
assistantText = text;
|
|
179
|
+
assistantID = info.id || "";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { userText, assistantText, assistantID };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const MeminiPlugin = async ({ client, worktree, directory }, options) => {
|
|
186
|
+
const log = {
|
|
187
|
+
warn: (message) => {
|
|
188
|
+
// client.app.log is opencode's structured logger; fall back to stderr.
|
|
189
|
+
try {
|
|
190
|
+
client?.app?.log?.({ body: { service: "memini", level: "warn", message } });
|
|
191
|
+
} catch {
|
|
192
|
+
/* ignore logging failures */
|
|
193
|
+
}
|
|
194
|
+
console.error(`[memini] ${message}`);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const cfg = resolveConfig(process.env, options, worktree || directory);
|
|
199
|
+
const rest = createClient(cfg, log);
|
|
200
|
+
// Assistant message ids already captured, so repeated session.idle events for
|
|
201
|
+
// the same turn don't write duplicates.
|
|
202
|
+
const captured = new Set();
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
"chat.message": async (input, output) => {
|
|
206
|
+
if (!cfg.recall) return;
|
|
207
|
+
const query = extractPartsText(output && output.parts);
|
|
208
|
+
if (!query) return;
|
|
209
|
+
const result = await rest.postJson("/v1/search", { query, limit: cfg.recall_limit });
|
|
210
|
+
const block = formatResults(result && result.results, cfg.recall_limit);
|
|
211
|
+
if (!block) return;
|
|
212
|
+
// Borrow sessionID/messageID from the real parts when the hook input
|
|
213
|
+
// omits them (messageID is optional in the contract), so the injected
|
|
214
|
+
// part is attributed to the same message.
|
|
215
|
+
const sibling = output.parts.find((p) => p && p.type === "text") || {};
|
|
216
|
+
const sessionID = input.sessionID || sibling.sessionID;
|
|
217
|
+
const messageID = input.messageID || sibling.messageID;
|
|
218
|
+
output.parts.unshift({
|
|
219
|
+
id: `mem-recall-${messageID || sessionID}`,
|
|
220
|
+
sessionID,
|
|
221
|
+
messageID,
|
|
222
|
+
type: "text",
|
|
223
|
+
synthetic: true,
|
|
224
|
+
text:
|
|
225
|
+
`Relevant long-term memory from memini (background context — prefer ` +
|
|
226
|
+
`current workspace state and the user's instructions):\n${block}`,
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
event: async ({ event }) => {
|
|
231
|
+
if (!cfg.capture || !event || event.type !== "session.idle") return;
|
|
232
|
+
const sessionID = event.properties && event.properties.sessionID;
|
|
233
|
+
if (!sessionID) return;
|
|
234
|
+
const res = await client.session.messages({ path: { id: sessionID } });
|
|
235
|
+
const { userText, assistantText, assistantID } = extractLastTurn(res && res.data);
|
|
236
|
+
if (!userText || !assistantText) return;
|
|
237
|
+
if (assistantID && captured.has(assistantID)) return;
|
|
238
|
+
const stored = await rest.postJson("/v1/memories", {
|
|
239
|
+
content: `User: ${userText.slice(0, 1000)}\nAssistant: ${assistantText.slice(0, 3000)}`,
|
|
240
|
+
tier: "episodic",
|
|
241
|
+
tags: ["opencode"],
|
|
242
|
+
metadata: { source: "opencode", session_id: sessionID },
|
|
243
|
+
});
|
|
244
|
+
if (stored !== null && assistantID) captured.add(assistantID);
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export default MeminiPlugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eleboucher/opencode-memini",
|
|
3
|
+
"version": "0.2.8",
|
|
4
|
+
"description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"memini",
|
|
7
|
+
"memory",
|
|
8
|
+
"opencode",
|
|
9
|
+
"opencode-plugin"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"files": [
|
|
13
|
+
"memini.js",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "memini.js",
|
|
18
|
+
"exports": "./memini.js"
|
|
19
|
+
}
|