@mem9/opencode 0.1.0

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 ADDED
@@ -0,0 +1,268 @@
1
+ # OpenCode Plugin for mem9
2
+
3
+ Persistent memory for [OpenCode](https://opencode.ai).
4
+
5
+ The package ships two plugin entrypoints:
6
+
7
+ - a server plugin for recall, auto-ingest, and memory tools
8
+ - a TUI plugin for interactive setup inside OpenCode
9
+
10
+ ## Quick Start
11
+
12
+ ### 1. Install the server plugin
13
+
14
+ Install the server plugin in one scope only.
15
+
16
+ User scope:
17
+
18
+ File: `~/.config/opencode/opencode.json`
19
+
20
+ ```json
21
+ {
22
+ "plugin": ["@mem9/opencode"]
23
+ }
24
+ ```
25
+
26
+ Project scope:
27
+
28
+ File: `<project>/.opencode/opencode.json`
29
+
30
+ ```json
31
+ {
32
+ "plugin": ["@mem9/opencode"]
33
+ }
34
+ ```
35
+
36
+ Recommended pattern:
37
+
38
+ - install the server plugin once at user scope
39
+ - keep project-specific behavior in `<project>/.opencode/mem9.json`
40
+
41
+ `@mem9/opencode` resolves npm `latest`.
42
+ If you publish a prerelease channel, install it with an explicit npm specifier instead:
43
+
44
+ ```json
45
+ {
46
+ "plugin": ["@mem9/opencode@rc"]
47
+ }
48
+ ```
49
+
50
+ You can also pin an exact prerelease version:
51
+
52
+ ```json
53
+ {
54
+ "plugin": ["@mem9/opencode@0.1.0-rc.0"]
55
+ }
56
+ ```
57
+
58
+ Avoid duplicate plugin loading, such as:
59
+
60
+ - user scope plugin plus project scope plugin
61
+ - npm plugin plus local file plugin
62
+
63
+ ### 2. Install the TUI plugin for `/mem9-setup`
64
+
65
+ File: `~/.config/opencode/tui.json`
66
+
67
+ ```json
68
+ {
69
+ "plugin": ["@mem9/opencode"]
70
+ }
71
+ ```
72
+
73
+ That enables the `/mem9-setup` command inside OpenCode.
74
+
75
+ ### 3. Restart OpenCode and run `/mem9-setup`
76
+
77
+ `/mem9-setup` is the single entrypoint for both:
78
+
79
+ - shared mem9 credentials
80
+ - OpenCode user/project mem9 settings
81
+
82
+ When no usable profile exists, it shows two actions:
83
+
84
+ - `Get a mem9 API key automatically`
85
+ - `Add an existing mem9 API key`
86
+
87
+ When usable profiles already exist, it shows four actions:
88
+
89
+ - `Get a mem9 API key automatically`
90
+ - `Add an existing mem9 API key`
91
+ - `Use an existing mem9 profile in a scope`
92
+ - `Configure user/project settings`
93
+
94
+ Profile creation ends after the profile is saved. Scope configuration is a separate action.
95
+
96
+ ## File Layout
97
+
98
+ OpenCode config directory:
99
+
100
+ - macOS/Linux: usually `~/.config/opencode`
101
+ - Windows: usually `%APPDATA%\\opencode`
102
+
103
+ OpenCode state directory:
104
+
105
+ - macOS/Linux: usually `~/.local/share/opencode`
106
+ - Windows: usually `%LOCALAPPDATA%\\opencode`
107
+
108
+ mem9 uses these files:
109
+
110
+ - server plugin list: `~/.config/opencode/opencode.json` or `<project>/.opencode/opencode.json`
111
+ - TUI plugin list: `~/.config/opencode/tui.json`
112
+ - shared credentials: `$MEM9_HOME/.credentials.json`
113
+ - user mem9 config: `<OpenCode config dir>/mem9.json`
114
+ - project mem9 config: `<project>/.opencode/mem9.json`
115
+ - debug logs: `<OpenCode state dir>/plugins/mem9/log/YYYY-MM-DD.jsonl`
116
+
117
+ `MEM9_HOME` defaults to `$HOME/.mem9`.
118
+
119
+ ## Credentials File
120
+
121
+ Shared credentials live in:
122
+
123
+ ```text
124
+ $MEM9_HOME/.credentials.json
125
+ ```
126
+
127
+ Example:
128
+
129
+ ```json
130
+ {
131
+ "schemaVersion": 1,
132
+ "profiles": {
133
+ "default": {
134
+ "label": "Personal",
135
+ "baseUrl": "https://api.mem9.ai",
136
+ "apiKey": "..."
137
+ }
138
+ }
139
+ }
140
+ ```
141
+
142
+ `profiles` stores credentials only.
143
+
144
+ ## OpenCode mem9 Config
145
+
146
+ User and project mem9 config use the same schema:
147
+
148
+ ```json
149
+ {
150
+ "schemaVersion": 1,
151
+ "profileId": "default",
152
+ "debug": false,
153
+ "defaultTimeoutMs": 8000,
154
+ "searchTimeoutMs": 15000
155
+ }
156
+ ```
157
+
158
+ Field meanings:
159
+
160
+ - `profileId`: which shared profile this scope should use
161
+ - `debug`: enable redacted JSONL debug logs
162
+ - `defaultTimeoutMs`: request timeout for normal mem9 calls
163
+ - `searchTimeoutMs`: request timeout for recall search
164
+
165
+ Project config overrides user config for the current repository.
166
+
167
+ ## Runtime Overrides
168
+
169
+ These environment variables still override disk config at runtime:
170
+
171
+ - `MEM9_API_KEY`
172
+ - `MEM9_API_URL`
173
+ - `MEM9_DEBUG`
174
+ - `MEM9_HOME`
175
+
176
+ Legacy compatibility remains:
177
+
178
+ - `MEM9_TENANT_ID`
179
+
180
+ `MEM9_TENANT_ID` is treated as the API key source for older setups.
181
+
182
+ ## Local Development Install
183
+
184
+ You can also point OpenCode at a local checkout.
185
+
186
+ Server plugin example:
187
+
188
+ ```json
189
+ {
190
+ "plugin": ["./.opencode/plugins/mem9/src/index.ts"]
191
+ }
192
+ ```
193
+
194
+ TUI plugin example:
195
+
196
+ ```json
197
+ {
198
+ "plugin": ["./.opencode/plugins/mem9/src/tui/index.ts"]
199
+ }
200
+ ```
201
+
202
+ Keep the same one-scope rule for the server plugin even when using local paths.
203
+
204
+ ## What the Plugin Does
205
+
206
+ The server plugin does three things:
207
+
208
+ - recalls relevant mem9 memories before each chat turn
209
+ - exposes mem9 memory tools inside OpenCode
210
+ - starts best-effort background smart ingest when the session becomes idle and when compaction begins
211
+
212
+ ### Hook Flow
213
+
214
+ OpenCode integration currently uses four runtime hooks:
215
+
216
+ | Hook | What mem9 does |
217
+ | --- | --- |
218
+ | `chat.message` | Captures the latest real user prompt and updates in-memory session state. |
219
+ | `experimental.chat.system.transform` | Searches mem9 with the captured prompt and injects a `<relevant-memories>` block. |
220
+ | `event` with `session.idle` | Starts a best-effort background smart-ingest pass for the recent transcript window. |
221
+ | `experimental.session.compacting` | Pushes a compaction hint and starts another best-effort background smart-ingest pass. |
222
+
223
+ ### Recall
224
+
225
+ The plugin captures the latest real user prompt from `chat.message`, cleans it, bounds it, and injects a formatted recall block during `experimental.chat.system.transform`.
226
+
227
+ ### Auto-ingest
228
+
229
+ The plugin ingests from two points:
230
+
231
+ - `session.idle`
232
+ - `experimental.session.compacting`
233
+
234
+ Both paths fetch up to 24 recent session messages, keep real text-only `user` and `assistant` turns, strip injected memory blocks, and upload the last 12 cleaned messages in the background.
235
+
236
+ Identical transcripts are deduped per in-memory session state, so a matching idle ingest and compaction ingest share one upload while that session state stays warm.
237
+
238
+ Two timing details matter:
239
+
240
+ - hook completion happens before the background upload finishes
241
+ - the dedupe window is in-memory and TTL-bound to about 15 minutes, so restart or cache expiry can upload the same transcript again
242
+
243
+ ### Tools
244
+
245
+ The plugin registers these tools:
246
+
247
+ - `memory_store`
248
+ - `memory_search`
249
+ - `memory_get`
250
+ - `memory_update`
251
+ - `memory_delete`
252
+
253
+ ## Troubleshooting
254
+
255
+ - `Setup pending` means the plugin could not find a usable runtime identity. Run `/mem9-setup`, add `MEM9_API_KEY`, or point the active `mem9.json` scope at a profile with a non-empty `apiKey`.
256
+ - If `/mem9-setup` is missing, confirm `@mem9/opencode` is listed in `~/.config/opencode/tui.json`.
257
+ - If recall or tools work in one project and not another, check whether the project has its own `.opencode/mem9.json` override.
258
+ - If recall, auto-ingest, or debug logs appear to run twice, check for duplicate plugin registration across user scope, project scope, npm, or local file paths.
259
+ - If the selected profile exists but has no `apiKey`, update that profile in `$MEM9_HOME/.credentials.json`.
260
+ - If debug logging is enabled and no file appears, confirm OpenCode can write to its state directory.
261
+
262
+ ## Local Verification
263
+
264
+ ```bash
265
+ pnpm test
266
+ pnpm run typecheck
267
+ pnpm run pack:check
268
+ ```
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@mem9/opencode",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode memory plugin — cloud-persistent AI agent memory with hybrid vector + keyword search via mem9",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "oc-plugin": [
8
+ "server",
9
+ "tui"
10
+ ],
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./tui": "./src/tui/index.ts"
14
+ },
15
+ "license": "Apache-2.0",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "author": "mem9-ai",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/mem9-ai/mem9.git",
23
+ "directory": "opencode-plugin"
24
+ },
25
+ "homepage": "https://github.com/mem9-ai/mem9/tree/main/opencode-plugin#readme",
26
+ "keywords": [
27
+ "opencode",
28
+ "opencode-plugin",
29
+ "memory",
30
+ "agent-memory",
31
+ "mem9",
32
+ "tidb",
33
+ "vector-search",
34
+ "persistent-memory",
35
+ "ai-agent"
36
+ ],
37
+ "files": [
38
+ "src",
39
+ "README.md"
40
+ ],
41
+ "dependencies": {
42
+ "@opencode-ai/plugin": "1.14.19"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.3.3",
46
+ "typescript": "^5.8.0"
47
+ },
48
+ "scripts": {
49
+ "test": "node ./scripts/test.mjs",
50
+ "pack:check": "pnpm pack --dry-run",
51
+ "publish:release": "node ./scripts/publish.mjs",
52
+ "typecheck": "tsc --noEmit"
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./server/index.js";
@@ -0,0 +1,39 @@
1
+ import type {
2
+ CreateMemoryInput,
3
+ Memory,
4
+ SearchInput,
5
+ SearchResult,
6
+ StoreResult,
7
+ UpdateMemoryInput,
8
+ } from "../shared/types.js";
9
+
10
+ export interface IngestMessage {
11
+ role: string;
12
+ content: string;
13
+ }
14
+
15
+ export interface IngestInput {
16
+ messages: IngestMessage[];
17
+ session_id: string;
18
+ agent_id: string;
19
+ mode?: "smart";
20
+ }
21
+
22
+ export interface IngestResult {
23
+ status: string;
24
+ memories_changed?: number;
25
+ }
26
+
27
+ /**
28
+ * MemoryBackend — abstraction for server mode.
29
+ * All tools and hooks call through this interface.
30
+ */
31
+ export interface MemoryBackend {
32
+ store(input: CreateMemoryInput): Promise<StoreResult>;
33
+ search(input: SearchInput): Promise<SearchResult>;
34
+ get(id: string): Promise<Memory | null>;
35
+ update(id: string, input: UpdateMemoryInput): Promise<Memory | null>;
36
+ remove(id: string): Promise<boolean>;
37
+ listRecent(limit: number): Promise<Memory[]>;
38
+ ingest(input: IngestInput): Promise<IngestResult>;
39
+ }
@@ -0,0 +1,251 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { PluginInput } from "@opencode-ai/plugin";
3
+ import { parseCredentialsFile } from "../shared/credentials-store.js";
4
+ import { DEFAULT_SCOPE_CONFIG } from "../shared/defaults.js";
5
+ import {
6
+ resolveOpenCodeBasePaths,
7
+ resolveMem9Home,
8
+ resolveMem9Paths,
9
+ type Mem9ResolvedPaths,
10
+ } from "../shared/platform-paths.js";
11
+ import {
12
+ DEFAULT_API_URL,
13
+ type Mem9ConfigFile,
14
+ type Mem9CredentialsFile,
15
+ } from "../shared/types.js";
16
+
17
+ const EMPTY_CREDENTIALS: Mem9CredentialsFile = {
18
+ schemaVersion: 1,
19
+ profiles: {},
20
+ };
21
+
22
+ export interface EffectiveConfig extends Mem9ConfigFile {
23
+ paths?: Mem9ResolvedPaths;
24
+ }
25
+
26
+ export interface RuntimeIdentity {
27
+ apiKey: string;
28
+ baseUrl: string;
29
+ source: "env" | "legacy_env" | "profile";
30
+ }
31
+
32
+ function isRecord(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ function normalizeOptionalString(value: string | undefined): string | undefined {
37
+ if (typeof value !== "string") {
38
+ return undefined;
39
+ }
40
+
41
+ const trimmed = value.trim();
42
+ return trimmed.length > 0 ? trimmed : undefined;
43
+ }
44
+
45
+ function normalizeOptionalBoolean(value: string | undefined): boolean | undefined {
46
+ const normalized = normalizeOptionalString(value)?.toLowerCase();
47
+ if (!normalized) {
48
+ return undefined;
49
+ }
50
+
51
+ if (["1", "true", "yes", "on"].includes(normalized)) {
52
+ return true;
53
+ }
54
+
55
+ if (["0", "false", "no", "off"].includes(normalized)) {
56
+ return false;
57
+ }
58
+
59
+ return undefined;
60
+ }
61
+
62
+ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
63
+ return error instanceof Error;
64
+ }
65
+
66
+ function normalizeConfigFile(value: unknown): Mem9ConfigFile {
67
+ if (!isRecord(value) || value.schemaVersion !== 1) {
68
+ throw new Error("invalid mem9 config file");
69
+ }
70
+
71
+ if ("profileId" in value && value.profileId !== undefined && typeof value.profileId !== "string") {
72
+ throw new Error("invalid mem9 config file");
73
+ }
74
+ if ("debug" in value && value.debug !== undefined && typeof value.debug !== "boolean") {
75
+ throw new Error("invalid mem9 config file");
76
+ }
77
+ if (
78
+ "defaultTimeoutMs" in value &&
79
+ value.defaultTimeoutMs !== undefined &&
80
+ typeof value.defaultTimeoutMs !== "number"
81
+ ) {
82
+ throw new Error("invalid mem9 config file");
83
+ }
84
+ if (
85
+ "searchTimeoutMs" in value &&
86
+ value.searchTimeoutMs !== undefined &&
87
+ typeof value.searchTimeoutMs !== "number"
88
+ ) {
89
+ throw new Error("invalid mem9 config file");
90
+ }
91
+
92
+ return {
93
+ schemaVersion: 1,
94
+ profileId: typeof value.profileId === "string" ? value.profileId : undefined,
95
+ debug: typeof value.debug === "boolean" ? value.debug : undefined,
96
+ defaultTimeoutMs:
97
+ typeof value.defaultTimeoutMs === "number" ? value.defaultTimeoutMs : undefined,
98
+ searchTimeoutMs:
99
+ typeof value.searchTimeoutMs === "number" ? value.searchTimeoutMs : undefined,
100
+ };
101
+ }
102
+
103
+ async function readConfigFile(filePath: string): Promise<Mem9ConfigFile | undefined> {
104
+ try {
105
+ const raw = await readFile(filePath, "utf8");
106
+ return normalizeConfigFile(JSON.parse(raw) as unknown);
107
+ } catch (error) {
108
+ if (isErrnoException(error) && error.code === "ENOENT") {
109
+ return undefined;
110
+ }
111
+ console.warn("[mem9] Skipping unreadable mem9 config file.");
112
+ return undefined;
113
+ }
114
+ }
115
+
116
+ async function readCredentialsFile(filePath: string): Promise<Mem9CredentialsFile> {
117
+ try {
118
+ const raw = await readFile(filePath, "utf8");
119
+ return parseCredentialsFile(raw);
120
+ } catch (error) {
121
+ if (isErrnoException(error) && error.code === "ENOENT") {
122
+ return EMPTY_CREDENTIALS;
123
+ }
124
+ console.warn("[mem9] Skipping unreadable mem9 credentials file.");
125
+ return EMPTY_CREDENTIALS;
126
+ }
127
+ }
128
+
129
+ function resolvePluginPaths(input: PluginInput): Mem9ResolvedPaths {
130
+ const basePaths = resolveOpenCodeBasePaths(process.env);
131
+ return resolveMem9Paths({
132
+ configDir: basePaths.configDir,
133
+ dataDir: basePaths.dataDir,
134
+ projectDir: input.worktree || input.directory,
135
+ mem9Home: resolveMem9Home(process.env),
136
+ });
137
+ }
138
+
139
+ export function mergeConfigLayers(
140
+ globalConfig?: Mem9ConfigFile,
141
+ projectConfig?: Mem9ConfigFile,
142
+ ): Mem9ConfigFile {
143
+ const merged: Mem9ConfigFile = {
144
+ schemaVersion: DEFAULT_SCOPE_CONFIG.schemaVersion,
145
+ debug: DEFAULT_SCOPE_CONFIG.debug,
146
+ defaultTimeoutMs: DEFAULT_SCOPE_CONFIG.defaultTimeoutMs,
147
+ searchTimeoutMs: DEFAULT_SCOPE_CONFIG.searchTimeoutMs,
148
+ };
149
+
150
+ for (const layer of [globalConfig, projectConfig]) {
151
+ if (!layer) {
152
+ continue;
153
+ }
154
+
155
+ if (layer.profileId !== undefined) {
156
+ merged.profileId = layer.profileId;
157
+ }
158
+ if (layer.debug !== undefined) {
159
+ merged.debug = layer.debug;
160
+ }
161
+ if (layer.defaultTimeoutMs !== undefined) {
162
+ merged.defaultTimeoutMs = layer.defaultTimeoutMs;
163
+ }
164
+ if (layer.searchTimeoutMs !== undefined) {
165
+ merged.searchTimeoutMs = layer.searchTimeoutMs;
166
+ }
167
+ }
168
+
169
+ return merged;
170
+ }
171
+
172
+ export function resolveRuntimeIdentity(
173
+ env: Record<string, string | undefined>,
174
+ credentials: Mem9CredentialsFile,
175
+ config: Mem9ConfigFile,
176
+ ): RuntimeIdentity | null {
177
+ const envApiKey = normalizeOptionalString(env.MEM9_API_KEY);
178
+ const envBaseUrl = normalizeOptionalString(env.MEM9_API_URL) ?? DEFAULT_API_URL;
179
+ const legacyTenantID = normalizeOptionalString(env.MEM9_TENANT_ID);
180
+ const profileID = normalizeOptionalString(config.profileId);
181
+
182
+ if (envApiKey) {
183
+ return {
184
+ apiKey: envApiKey,
185
+ baseUrl: envBaseUrl,
186
+ source: "env",
187
+ };
188
+ }
189
+
190
+ if (legacyTenantID) {
191
+ return {
192
+ apiKey: legacyTenantID,
193
+ baseUrl: envBaseUrl,
194
+ source: "legacy_env",
195
+ };
196
+ }
197
+
198
+ if (!profileID) {
199
+ return null;
200
+ }
201
+
202
+ const profile = credentials.profiles[profileID];
203
+ if (!profile) {
204
+ return null;
205
+ }
206
+
207
+ const profileApiKey = normalizeOptionalString(profile.apiKey);
208
+ if (!profileApiKey) {
209
+ return null;
210
+ }
211
+
212
+ return {
213
+ apiKey: profileApiKey,
214
+ baseUrl: normalizeOptionalString(profile.baseUrl) ?? DEFAULT_API_URL,
215
+ source: "profile",
216
+ };
217
+ }
218
+
219
+ export async function resolveEffectiveConfig(input: PluginInput): Promise<EffectiveConfig> {
220
+ const paths = resolvePluginPaths(input);
221
+
222
+ const [globalConfig, projectConfig] = await Promise.all([
223
+ readConfigFile(paths.globalConfigFile),
224
+ readConfigFile(paths.projectConfigFile),
225
+ ]);
226
+
227
+ const merged = mergeConfigLayers(globalConfig, projectConfig);
228
+ const envDebug = normalizeOptionalBoolean(process.env.MEM9_DEBUG);
229
+
230
+ return {
231
+ ...merged,
232
+ debug: envDebug ?? merged.debug,
233
+ paths,
234
+ };
235
+ }
236
+
237
+ export async function resolvePluginIdentity(
238
+ config: EffectiveConfig,
239
+ ): Promise<RuntimeIdentity | null> {
240
+ const envIdentity = resolveRuntimeIdentity(process.env, EMPTY_CREDENTIALS, config);
241
+ if (envIdentity && envIdentity.source !== "profile") {
242
+ return envIdentity;
243
+ }
244
+
245
+ if (!config.paths) {
246
+ return null;
247
+ }
248
+
249
+ const credentials = await readCredentialsFile(config.paths.credentialsFile);
250
+ return resolveRuntimeIdentity(process.env, credentials, config);
251
+ }