@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 +268 -0
- package/package.json +54 -0
- package/src/index.ts +1 -0
- package/src/server/backend.ts +39 -0
- package/src/server/config.ts +251 -0
- package/src/server/debug.ts +112 -0
- package/src/server/hooks.ts +349 -0
- package/src/server/index.ts +73 -0
- package/src/server/ingest/select.ts +18 -0
- package/src/server/ingest/submit.ts +56 -0
- package/src/server/recall/format.ts +84 -0
- package/src/server/recall/query.ts +127 -0
- package/src/server/server-backend.ts +170 -0
- package/src/server/session-transcript.ts +65 -0
- package/src/server/setup-flow.ts +17 -0
- package/src/server/tools.ts +189 -0
- package/src/shared/credentials-store.ts +55 -0
- package/src/shared/defaults.ts +13 -0
- package/src/shared/platform-paths.ts +73 -0
- package/src/shared/plugin-meta.ts +1 -0
- package/src/shared/setup-files.ts +492 -0
- package/src/shared/types.ts +74 -0
- package/src/tui/index.ts +622 -0
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
|
+
}
|