open-mem 0.12.0 → 0.13.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/CHANGELOG.md +9 -0
- package/README.md +9 -3
- package/bin/cli.mjs +650 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.13.0] - 2026-02-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`npx open-mem` CLI installer** — one-command plugin setup for OpenCode. Automatically finds or creates the config file and adds `open-mem` to the plugin array. Supports `--global`, `--uninstall`, `--dry-run`, `--force`, and `--version` flags. JSONC-aware (preserves comments in existing config files). Cleans OpenCode plugin cache on uninstall.
|
|
12
|
+
- AI provider detection in installer — shows which providers are configured after install.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Documentation updated — README Quick Start and Getting Started guide now recommend `npx open-mem` as the primary installation method, with manual `bun add` as alternative.
|
|
16
|
+
|
|
8
17
|
## [0.12.0] - 2026-02-16
|
|
9
18
|
|
|
10
19
|
### Changed
|
package/README.md
CHANGED
|
@@ -31,11 +31,19 @@ You use tools, open-mem captures the outputs, AI compresses them into structured
|
|
|
31
31
|
|
|
32
32
|
## Quick start
|
|
33
33
|
|
|
34
|
+
```bash
|
|
35
|
+
npx open-mem
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
That's it. This adds `open-mem` to your OpenCode plugin config automatically. It starts capturing from your next session.
|
|
39
|
+
|
|
40
|
+
Or install manually:
|
|
41
|
+
|
|
34
42
|
```bash
|
|
35
43
|
bun add open-mem
|
|
36
44
|
```
|
|
37
45
|
|
|
38
|
-
|
|
46
|
+
Then add to your OpenCode config (`~/.config/opencode/opencode.json` or `.opencode/opencode.json`):
|
|
39
47
|
|
|
40
48
|
```json
|
|
41
49
|
{
|
|
@@ -43,8 +51,6 @@ Add it to your OpenCode config (`~/.config/opencode/opencode.json`):
|
|
|
43
51
|
}
|
|
44
52
|
```
|
|
45
53
|
|
|
46
|
-
That's it. open-mem starts capturing from your next session.
|
|
47
|
-
|
|
48
54
|
### AI compression (optional)
|
|
49
55
|
|
|
50
56
|
By default, open-mem uses a basic metadata extractor. For semantic compression, add an AI provider:
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// open-mem — Plugin Installer for OpenCode
|
|
4
|
+
// Usage: npx open-mem [--global] [--force] [--uninstall] [--dry-run] [--help] [--version]
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import readline from "node:readline/promises";
|
|
10
|
+
|
|
11
|
+
// ── colour helpers (disabled when piped) ────────────────────────────
|
|
12
|
+
const isTTY = process.stdout.isTTY;
|
|
13
|
+
const RED = isTTY ? "\x1b[0;31m" : "";
|
|
14
|
+
const GREEN = isTTY ? "\x1b[0;32m" : "";
|
|
15
|
+
const YELLOW = isTTY ? "\x1b[0;33m" : "";
|
|
16
|
+
const BLUE = isTTY ? "\x1b[0;34m" : "";
|
|
17
|
+
const DIM = isTTY ? "\x1b[2m" : "";
|
|
18
|
+
const BOLD = isTTY ? "\x1b[1m" : "";
|
|
19
|
+
const RESET = isTTY ? "\x1b[0m" : "";
|
|
20
|
+
|
|
21
|
+
const info = (msg) => process.stdout.write(`${BLUE}[info]${RESET} ${msg}\n`);
|
|
22
|
+
const ok = (msg) => process.stdout.write(`${GREEN}[ok]${RESET} ${msg}\n`);
|
|
23
|
+
const warn = (msg) => process.stdout.write(`${YELLOW}[warn]${RESET} ${msg}\n`);
|
|
24
|
+
const err = (msg) => process.stderr.write(`${RED}[error]${RESET} ${msg}\n`);
|
|
25
|
+
|
|
26
|
+
// ── constants ───────────────────────────────────────────────────────
|
|
27
|
+
const PLUGIN_ENTRY = "open-mem@latest";
|
|
28
|
+
const PKG_NAME = "open-mem";
|
|
29
|
+
const DOCS_URL = "https://github.com/clopca/open-mem";
|
|
30
|
+
|
|
31
|
+
// ── CLI flags ───────────────────────────────────────────────────────
|
|
32
|
+
const args = process.argv.slice(2);
|
|
33
|
+
const flagGlobal = args.includes("--global");
|
|
34
|
+
const flagUninstall = args.includes("--uninstall");
|
|
35
|
+
const flagDryRun = args.includes("--dry-run");
|
|
36
|
+
const flagForce = args.includes("--force");
|
|
37
|
+
const flagHelp = args.includes("--help") || args.includes("-h");
|
|
38
|
+
const flagVersion = args.includes("--version") || args.includes("-v");
|
|
39
|
+
|
|
40
|
+
// ── unknown flag validation ─────────────────────────────────────────
|
|
41
|
+
const KNOWN_FLAGS = new Set([
|
|
42
|
+
"--global",
|
|
43
|
+
"--uninstall",
|
|
44
|
+
"--dry-run",
|
|
45
|
+
"--force",
|
|
46
|
+
"--help",
|
|
47
|
+
"-h",
|
|
48
|
+
"--version",
|
|
49
|
+
"-v",
|
|
50
|
+
]);
|
|
51
|
+
const unknown = args.filter((a) => a.startsWith("-") && !KNOWN_FLAGS.has(a));
|
|
52
|
+
if (unknown.length > 0) {
|
|
53
|
+
err(`Unknown flag${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}`);
|
|
54
|
+
info(`Run ${BOLD}npx open-mem --help${RESET} for usage.`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── version helper ──────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function getVersion() {
|
|
61
|
+
try {
|
|
62
|
+
const pkgPath = path.join(
|
|
63
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
64
|
+
"..",
|
|
65
|
+
"package.json",
|
|
66
|
+
);
|
|
67
|
+
return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── --version flag ──────────────────────────────────────────────────
|
|
74
|
+
if (flagVersion) {
|
|
75
|
+
const pkgPath = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "package.json");
|
|
76
|
+
try {
|
|
77
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
78
|
+
process.stdout.write(`${pkg.name} v${pkg.version}\n`);
|
|
79
|
+
} catch {
|
|
80
|
+
process.stdout.write(`open-mem (version unknown)\n`);
|
|
81
|
+
}
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── help ────────────────────────────────────────────────────────────
|
|
86
|
+
if (flagHelp) {
|
|
87
|
+
process.stdout.write(`
|
|
88
|
+
${BOLD}open-mem${RESET} — Persistent memory plugin for OpenCode
|
|
89
|
+
|
|
90
|
+
${BOLD}USAGE${RESET}
|
|
91
|
+
npx open-mem [flags]
|
|
92
|
+
|
|
93
|
+
${BOLD}FLAGS${RESET}
|
|
94
|
+
${DIM}(none)${RESET} Add open-mem to local .opencode/opencode.json
|
|
95
|
+
--global Target ~/.config/opencode/opencode.json instead
|
|
96
|
+
--uninstall Remove open-mem from all discovered config files
|
|
97
|
+
--dry-run Preview changes without writing anything
|
|
98
|
+
--force Skip confirmation prompts
|
|
99
|
+
--help, -h Show this help
|
|
100
|
+
--version, -v Show version
|
|
101
|
+
|
|
102
|
+
${BOLD}EXAMPLES${RESET}
|
|
103
|
+
npx open-mem ${DIM}# install locally${RESET}
|
|
104
|
+
npx open-mem --global ${DIM}# install globally${RESET}
|
|
105
|
+
npx open-mem --uninstall ${DIM}# remove from all configs${RESET}
|
|
106
|
+
|
|
107
|
+
${BOLD}DOCS${RESET}
|
|
108
|
+
${DOCS_URL}
|
|
109
|
+
`);
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── JSONC helpers ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/** Strip line and block comments from JSONC so JSON.parse can handle it. */
|
|
116
|
+
function stripJsonComments(text) {
|
|
117
|
+
let result = "";
|
|
118
|
+
let i = 0;
|
|
119
|
+
let inString = false;
|
|
120
|
+
let stringChar = "";
|
|
121
|
+
|
|
122
|
+
while (i < text.length) {
|
|
123
|
+
// inside a JSON string — pass through, handling escapes
|
|
124
|
+
if (inString) {
|
|
125
|
+
if (text[i] === "\\") {
|
|
126
|
+
result += text[i] + (text[i + 1] ?? "");
|
|
127
|
+
i += 2;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (text[i] === stringChar) inString = false;
|
|
131
|
+
result += text[i];
|
|
132
|
+
i++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// string start
|
|
137
|
+
if (text[i] === '"' || text[i] === "'") {
|
|
138
|
+
inString = true;
|
|
139
|
+
stringChar = text[i];
|
|
140
|
+
result += text[i];
|
|
141
|
+
i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// line comment
|
|
146
|
+
if (text[i] === "/" && text[i + 1] === "/") {
|
|
147
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// block comment
|
|
152
|
+
if (text[i] === "/" && text[i + 1] === "*") {
|
|
153
|
+
i += 2;
|
|
154
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
155
|
+
i += 2; // skip closing */
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
result += text[i];
|
|
160
|
+
i++;
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── config discovery ────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/** Walk up from `start` looking for an OpenCode config file. */
|
|
168
|
+
function findUp(start) {
|
|
169
|
+
const candidates = [
|
|
170
|
+
".opencode/opencode.jsonc",
|
|
171
|
+
".opencode/opencode.json",
|
|
172
|
+
"opencode.jsonc",
|
|
173
|
+
"opencode.json",
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
let dir = path.resolve(start);
|
|
177
|
+
const root = path.parse(dir).root;
|
|
178
|
+
|
|
179
|
+
while (true) {
|
|
180
|
+
for (const c of candidates) {
|
|
181
|
+
const full = path.join(dir, c);
|
|
182
|
+
if (fs.existsSync(full)) return full;
|
|
183
|
+
}
|
|
184
|
+
const parent = path.dirname(dir);
|
|
185
|
+
if (parent === dir || dir === root) break;
|
|
186
|
+
dir = parent;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Determine the target config path for install. */
|
|
192
|
+
function getTargetPath() {
|
|
193
|
+
if (flagGlobal) {
|
|
194
|
+
const globalDir = path.join(os.homedir(), ".config", "opencode");
|
|
195
|
+
// prefer existing file
|
|
196
|
+
for (const name of ["opencode.jsonc", "opencode.json"]) {
|
|
197
|
+
const p = path.join(globalDir, name);
|
|
198
|
+
if (fs.existsSync(p)) return p;
|
|
199
|
+
}
|
|
200
|
+
return path.join(globalDir, "opencode.json");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// local: walk up from cwd
|
|
204
|
+
const found = findUp(process.cwd());
|
|
205
|
+
if (found) return found;
|
|
206
|
+
|
|
207
|
+
// fallback: create in .opencode/
|
|
208
|
+
return path.join(process.cwd(), ".opencode", "opencode.json");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Find ALL config files that contain open-mem (for uninstall). */
|
|
212
|
+
function findAllConfigsWithPlugin() {
|
|
213
|
+
const configs = [];
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
|
|
216
|
+
const candidates = [
|
|
217
|
+
".opencode/opencode.jsonc",
|
|
218
|
+
".opencode/opencode.json",
|
|
219
|
+
"opencode.jsonc",
|
|
220
|
+
"opencode.json",
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
const tryAdd = (p) => {
|
|
224
|
+
const resolved = path.resolve(p);
|
|
225
|
+
if (seen.has(resolved)) return;
|
|
226
|
+
seen.add(resolved);
|
|
227
|
+
if (!fs.existsSync(resolved)) return;
|
|
228
|
+
try {
|
|
229
|
+
const config = readConfig(resolved);
|
|
230
|
+
if (config && config.data && findPluginEntry(config.data) !== -1) {
|
|
231
|
+
configs.push(resolved);
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
/* skip */
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Walk up from cwd, checking all candidates at each level
|
|
239
|
+
let dir = path.resolve(process.cwd());
|
|
240
|
+
const root = path.parse(dir).root;
|
|
241
|
+
while (true) {
|
|
242
|
+
for (const c of candidates) tryAdd(path.join(dir, c));
|
|
243
|
+
const parent = path.dirname(dir);
|
|
244
|
+
if (parent === dir || dir === root) break;
|
|
245
|
+
dir = parent;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Global locations
|
|
249
|
+
const globalDir = path.join(os.homedir(), ".config", "opencode");
|
|
250
|
+
tryAdd(path.join(globalDir, "opencode.jsonc"));
|
|
251
|
+
tryAdd(path.join(globalDir, "opencode.json"));
|
|
252
|
+
|
|
253
|
+
return configs;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── read / write helpers ────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function readConfig(filePath) {
|
|
259
|
+
if (!fs.existsSync(filePath)) return null;
|
|
260
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
261
|
+
try {
|
|
262
|
+
return { raw, data: JSON.parse(stripJsonComments(raw)) };
|
|
263
|
+
} catch {
|
|
264
|
+
return { raw, data: null };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Write a brand-new minimal config with the plugin entry.
|
|
270
|
+
* Used when no config file exists at all.
|
|
271
|
+
*/
|
|
272
|
+
function writeNewConfig(filePath) {
|
|
273
|
+
const content = JSON.stringify({ plugin: [PLUGIN_ENTRY] }, null, 2) + "\n";
|
|
274
|
+
if (flagDryRun) {
|
|
275
|
+
info(`Would create ${BOLD}${filePath}${RESET} with:`);
|
|
276
|
+
process.stdout.write(DIM + content + RESET);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
280
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
281
|
+
ok(`Created ${BOLD}${filePath}${RESET}`);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Add the plugin entry to an existing config, preserving JSONC comments.
|
|
287
|
+
* Strategy: regex-based insertion so comments survive.
|
|
288
|
+
*/
|
|
289
|
+
function addPluginEntry(filePath, raw, data) {
|
|
290
|
+
// already present? (handles any version suffix)
|
|
291
|
+
if (data && findPluginEntry(data) !== -1) {
|
|
292
|
+
ok(`${BOLD}${PKG_NAME}${RESET} already in ${filePath}`);
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let updated;
|
|
297
|
+
|
|
298
|
+
// case 1: "plugin": [...] exists — append our entry
|
|
299
|
+
const pluginArrayRe = /("plugin"\s*:\s*\[)([\s\S]*?)(\])/;
|
|
300
|
+
const m = raw.match(pluginArrayRe);
|
|
301
|
+
if (m) {
|
|
302
|
+
const inside = m[2].trim();
|
|
303
|
+
if (inside.length === 0) {
|
|
304
|
+
// empty array
|
|
305
|
+
updated = raw.replace(pluginArrayRe, `$1"${PLUGIN_ENTRY}"$3`);
|
|
306
|
+
} else {
|
|
307
|
+
// non-empty — add after last entry
|
|
308
|
+
updated = raw.replace(pluginArrayRe, (_, open, entries, close) => {
|
|
309
|
+
const trimmed = entries.trimEnd();
|
|
310
|
+
const needsComma = trimmed.length > 0 && !trimmed.endsWith(",");
|
|
311
|
+
return `${open}${entries.trimEnd()}${needsComma ? "," : ""} "${PLUGIN_ENTRY}"${close}`;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
// case 2: no plugin key — inject after the opening {
|
|
316
|
+
const idx = raw.indexOf("{");
|
|
317
|
+
if (idx === -1) {
|
|
318
|
+
err(`Cannot parse ${filePath} — not a JSON object`);
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
const before = raw.slice(0, idx + 1);
|
|
322
|
+
const after = raw.slice(idx + 1);
|
|
323
|
+
// detect indent
|
|
324
|
+
const indentMatch = after.match(/\n(\s+)/);
|
|
325
|
+
const indent = indentMatch ? indentMatch[1] : " ";
|
|
326
|
+
updated = `${before}\n${indent}"plugin": ["${PLUGIN_ENTRY}"],${after}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (flagDryRun) {
|
|
330
|
+
info(`Would update ${BOLD}${filePath}${RESET}`);
|
|
331
|
+
process.stdout.write(DIM + updated + RESET);
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
fs.writeFileSync(filePath, updated, "utf8");
|
|
336
|
+
ok(`Added ${BOLD}${PLUGIN_ENTRY}${RESET} to ${filePath}`);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Remove the plugin entry from a config file, preserving JSONC comments. */
|
|
341
|
+
function removePluginEntry(filePath, raw) {
|
|
342
|
+
if (!raw.includes(PKG_NAME)) return false;
|
|
343
|
+
|
|
344
|
+
// Remove the entry (with or without @latest, quotes, surrounding commas)
|
|
345
|
+
let updated = raw;
|
|
346
|
+
|
|
347
|
+
// Pattern: "open-mem" or "open-mem@latest" or "open-mem@<version>" as array element
|
|
348
|
+
// Handle trailing comma, leading comma, or standalone
|
|
349
|
+
// NOTE: no 'g' flag — avoids lastIndex issues with test() + replace()
|
|
350
|
+
const patterns = [
|
|
351
|
+
// entry with trailing comma and optional whitespace
|
|
352
|
+
new RegExp(`\\s*"${PKG_NAME}(?:@[^"]*)?"\\s*,`),
|
|
353
|
+
// entry with leading comma — also trim trailing whitespace after comma removal
|
|
354
|
+
new RegExp(`,\\s*"${PKG_NAME}(?:@[^"]*)?"\\s*`),
|
|
355
|
+
// standalone entry (only element)
|
|
356
|
+
new RegExp(`"${PKG_NAME}(?:@[^"]*)?"`),
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
for (const pat of patterns) {
|
|
360
|
+
if (pat.test(updated)) {
|
|
361
|
+
updated = updated.replace(pat, "");
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (updated === raw) return false;
|
|
367
|
+
|
|
368
|
+
if (flagDryRun) {
|
|
369
|
+
info(`Would update ${BOLD}${filePath}${RESET}`);
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
fs.writeFileSync(filePath, updated, "utf8");
|
|
374
|
+
ok(`Removed ${BOLD}${PKG_NAME}${RESET} from ${filePath}`);
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Find plugin entry in parsed config data. */
|
|
379
|
+
function findPluginEntry(data) {
|
|
380
|
+
if (!data || !Array.isArray(data.plugin)) return -1;
|
|
381
|
+
return data.plugin.findIndex(
|
|
382
|
+
(e) => typeof e === "string" && (e === PKG_NAME || e.startsWith(PKG_NAME + "@")),
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── node_modules cleanup ────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
function findStaleNodeModules(start) {
|
|
389
|
+
const results = [];
|
|
390
|
+
let dir = path.resolve(start);
|
|
391
|
+
const root = path.parse(dir).root;
|
|
392
|
+
|
|
393
|
+
while (true) {
|
|
394
|
+
const candidate = path.join(dir, "node_modules", PKG_NAME);
|
|
395
|
+
if (fs.existsSync(candidate)) results.push(candidate);
|
|
396
|
+
const parent = path.dirname(dir);
|
|
397
|
+
if (parent === dir || dir === root) break;
|
|
398
|
+
dir = parent;
|
|
399
|
+
}
|
|
400
|
+
return results;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function cleanupNodeModules() {
|
|
404
|
+
const stale = findStaleNodeModules(process.cwd());
|
|
405
|
+
if (stale.length === 0) return;
|
|
406
|
+
|
|
407
|
+
for (const p of stale) {
|
|
408
|
+
if (flagDryRun) {
|
|
409
|
+
info(`Would remove ${BOLD}${p}${RESET}`);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
414
|
+
ok(`Removed ${BOLD}${p}${RESET}`);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
warn(`Could not remove ${p}: ${e.message}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// also remove from package.json dependencies if present
|
|
421
|
+
const pkgPath = path.join(process.cwd(), "package.json");
|
|
422
|
+
if (fs.existsSync(pkgPath)) {
|
|
423
|
+
try {
|
|
424
|
+
const raw = fs.readFileSync(pkgPath, "utf8");
|
|
425
|
+
if (raw.includes(`"${PKG_NAME}"`)) {
|
|
426
|
+
const pkg = JSON.parse(raw);
|
|
427
|
+
let changed = false;
|
|
428
|
+
for (const key of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
429
|
+
if (pkg[key] && pkg[key][PKG_NAME]) {
|
|
430
|
+
delete pkg[key][PKG_NAME];
|
|
431
|
+
changed = true;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (changed) {
|
|
435
|
+
if (flagDryRun) {
|
|
436
|
+
info(`Would remove ${BOLD}${PKG_NAME}${RESET} from ${pkgPath}`);
|
|
437
|
+
} else {
|
|
438
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
439
|
+
ok(`Removed ${BOLD}${PKG_NAME}${RESET} from ${pkgPath}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
} catch {
|
|
444
|
+
/* skip */
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── confirmation prompt ─────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
async function confirm(question) {
|
|
452
|
+
if (flagForce) return true;
|
|
453
|
+
if (!isTTY) return true; // non-interactive — assume yes
|
|
454
|
+
|
|
455
|
+
const rl = readline.createInterface({
|
|
456
|
+
input: process.stdin,
|
|
457
|
+
output: process.stdout,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
const answer = await rl.question(`${question} ${DIM}[Y/n]${RESET} `);
|
|
462
|
+
return !answer || answer.toLowerCase().startsWith("y");
|
|
463
|
+
} finally {
|
|
464
|
+
rl.close();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── install flow ────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
async function install() {
|
|
471
|
+
const target = getTargetPath();
|
|
472
|
+
info(`Target: ${BOLD}${target}${RESET}`);
|
|
473
|
+
|
|
474
|
+
const config = readConfig(target);
|
|
475
|
+
|
|
476
|
+
// no file yet — create one
|
|
477
|
+
if (!config) {
|
|
478
|
+
if (!(await confirm(`Create ${target}?`))) {
|
|
479
|
+
info("Aborted.");
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
writeNewConfig(target);
|
|
483
|
+
printNextSteps();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// file exists but can't be parsed
|
|
488
|
+
if (!config.data) {
|
|
489
|
+
err(`Could not parse ${target} — fix the JSON/JSONC syntax first.`);
|
|
490
|
+
process.exit(1);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// already has the entry?
|
|
494
|
+
if (findPluginEntry(config.data) !== -1) {
|
|
495
|
+
ok(`${BOLD}${PLUGIN_ENTRY}${RESET} is already in ${target}`);
|
|
496
|
+
printNextSteps();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!(await confirm(`Add ${PLUGIN_ENTRY} to ${target}?`))) {
|
|
501
|
+
info("Aborted.");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
addPluginEntry(target, config.raw, config.data);
|
|
506
|
+
printNextSteps();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── uninstall flow ──────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
async function uninstall() {
|
|
512
|
+
const configs = findAllConfigsWithPlugin();
|
|
513
|
+
|
|
514
|
+
if (configs.length === 0) {
|
|
515
|
+
info(`No config files found containing ${BOLD}${PKG_NAME}${RESET}.`);
|
|
516
|
+
} else {
|
|
517
|
+
info(`Found ${configs.length} config(s) with ${BOLD}${PKG_NAME}${RESET}:`);
|
|
518
|
+
for (const c of configs) info(` ${c}`);
|
|
519
|
+
|
|
520
|
+
if (await confirm("Remove open-mem from these configs?")) {
|
|
521
|
+
for (const c of configs) {
|
|
522
|
+
const raw = fs.readFileSync(c, "utf8");
|
|
523
|
+
removePluginEntry(c, raw);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// cleanup node_modules
|
|
529
|
+
const stale = findStaleNodeModules(process.cwd());
|
|
530
|
+
if (stale.length > 0) {
|
|
531
|
+
info(`Found ${stale.length} node_modules installation(s):`);
|
|
532
|
+
for (const s of stale) info(` ${s}`);
|
|
533
|
+
|
|
534
|
+
if (await confirm("Remove these?")) {
|
|
535
|
+
cleanupNodeModules();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// cleanup OpenCode cache
|
|
540
|
+
const cacheDir = path.join(os.homedir(), ".cache", "opencode", "node_modules", PKG_NAME);
|
|
541
|
+
if (fs.existsSync(cacheDir)) {
|
|
542
|
+
info(`Found cached plugin at ${BOLD}${cacheDir}${RESET}`);
|
|
543
|
+
if (await confirm("Remove cached plugin?")) {
|
|
544
|
+
if (flagDryRun) {
|
|
545
|
+
info(`Would remove ${BOLD}${cacheDir}${RESET}`);
|
|
546
|
+
} else {
|
|
547
|
+
try {
|
|
548
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
549
|
+
ok(`Removed ${BOLD}${cacheDir}${RESET}`);
|
|
550
|
+
} catch (e) {
|
|
551
|
+
warn(`Could not remove cache: ${e.message}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// cleanup OpenCode cache package.json dependency
|
|
558
|
+
const cachePkgPath = path.join(os.homedir(), ".cache", "opencode", "package.json");
|
|
559
|
+
if (fs.existsSync(cachePkgPath)) {
|
|
560
|
+
try {
|
|
561
|
+
const raw = fs.readFileSync(cachePkgPath, "utf8");
|
|
562
|
+
if (raw.includes(`"${PKG_NAME}"`)) {
|
|
563
|
+
const pkg = JSON.parse(raw);
|
|
564
|
+
let changed = false;
|
|
565
|
+
for (const key of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
566
|
+
if (pkg[key] && pkg[key][PKG_NAME]) {
|
|
567
|
+
delete pkg[key][PKG_NAME];
|
|
568
|
+
changed = true;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (changed) {
|
|
572
|
+
if (flagDryRun) {
|
|
573
|
+
info(`Would remove ${BOLD}${PKG_NAME}${RESET} from ${cachePkgPath}`);
|
|
574
|
+
} else {
|
|
575
|
+
fs.writeFileSync(cachePkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
576
|
+
ok(`Removed ${BOLD}${PKG_NAME}${RESET} from ${cachePkgPath}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} catch {
|
|
581
|
+
/* skip */
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
ok("Uninstall complete.");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ── AI provider detection ───────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
function detectAIProviders() {
|
|
591
|
+
const providers = [
|
|
592
|
+
{ key: "GOOGLE_GENERATIVE_AI_API_KEY", name: "Google Gemini" },
|
|
593
|
+
{ key: "ANTHROPIC_API_KEY", name: "Anthropic" },
|
|
594
|
+
{ key: "AWS_ACCESS_KEY_ID", name: "AWS Bedrock" },
|
|
595
|
+
{ key: "OPENAI_API_KEY", name: "OpenAI" },
|
|
596
|
+
{ key: "OPENROUTER_API_KEY", name: "OpenRouter" },
|
|
597
|
+
];
|
|
598
|
+
return providers.filter((p) => process.env[p.key]);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── next steps ──────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
function printNextSteps() {
|
|
604
|
+
const detected = detectAIProviders();
|
|
605
|
+
|
|
606
|
+
process.stdout.write(`
|
|
607
|
+
${GREEN}✓${RESET} ${BOLD}open-mem${RESET} is configured!
|
|
608
|
+
|
|
609
|
+
${BOLD}Next steps:${RESET}
|
|
610
|
+
1. Start OpenCode — open-mem loads automatically
|
|
611
|
+
2. Use ${BOLD}mem-find${RESET}, ${BOLD}mem-create${RESET}, ${BOLD}mem-history${RESET} tools in your sessions
|
|
612
|
+
3. Observations are captured and compressed automatically
|
|
613
|
+
|
|
614
|
+
`);
|
|
615
|
+
|
|
616
|
+
if (detected.length > 0) {
|
|
617
|
+
const names = detected.map((p) => p.name).join(", ");
|
|
618
|
+
process.stdout.write(`${BOLD}AI compression:${RESET} ${GREEN}✓${RESET} ${names} detected\n`);
|
|
619
|
+
} else {
|
|
620
|
+
process.stdout.write(
|
|
621
|
+
`${BOLD}Optional — enable AI compression:${RESET}\n` +
|
|
622
|
+
` ${DIM}export GOOGLE_GENERATIVE_AI_API_KEY=...${RESET}\n` +
|
|
623
|
+
` ${DIM}# Also supports: Anthropic, AWS Bedrock, OpenAI, OpenRouter${RESET}\n`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
process.stdout.write(`\n${BOLD}Docs:${RESET} ${DOCS_URL}\n`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── main ────────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
async function main() {
|
|
633
|
+
const version = getVersion();
|
|
634
|
+
process.stdout.write(
|
|
635
|
+
`\n${BOLD}open-mem${RESET}${version ? ` ${DIM}v${version}${RESET}` : ""} ${DIM}— Persistent memory plugin for OpenCode${RESET}\n\n`,
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
if (flagDryRun) info(`${YELLOW}Dry-run mode${RESET} — no files will be modified.\n`);
|
|
639
|
+
|
|
640
|
+
if (flagUninstall) {
|
|
641
|
+
await uninstall();
|
|
642
|
+
} else {
|
|
643
|
+
await install();
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
main().catch((e) => {
|
|
648
|
+
err(e.message);
|
|
649
|
+
process.exit(1);
|
|
650
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Persistent memory plugin for OpenCode — captures, compresses, and recalls context across coding sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"bin": {
|
|
16
|
+
"open-mem": "./bin/cli.mjs",
|
|
16
17
|
"open-mem-mcp": "./dist/mcp.js",
|
|
17
18
|
"open-mem-daemon": "./dist/daemon.js",
|
|
18
19
|
"open-mem-maintenance": "./dist/maintenance.js",
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
24
25
|
"dist",
|
|
26
|
+
"bin",
|
|
25
27
|
"README.md",
|
|
26
28
|
"LICENSE",
|
|
27
29
|
"CHANGELOG.md"
|