open-memex 0.1.0 → 0.3.0-alpha

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/src/doctor.ts ADDED
@@ -0,0 +1,161 @@
1
+ // `open-memex doctor` — environment health checks.
2
+ //
3
+ // Read-only except that paths() and the MCP handshake may create the (empty)
4
+ // data directories, exactly like a normal `open-memex mcp` start would.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.ts";
10
+ import { paths } from "./paths.ts";
11
+ import { resolveCwdScope } from "./scope.ts";
12
+
13
+ interface Check {
14
+ name: string;
15
+ ok: boolean;
16
+ detail: string;
17
+ }
18
+
19
+ const EXPECTED_TOOLS = [
20
+ "memory_add",
21
+ "memory_search",
22
+ "memory_list",
23
+ "memory_supersede",
24
+ "memory_forget",
25
+ ];
26
+
27
+ function nodeCheck(): Check {
28
+ const [major, minor] = process.versions.node.split(".").map(Number);
29
+ const ok = major > 22 || (major === 22 && minor >= 6);
30
+ return {
31
+ name: "node",
32
+ ok,
33
+ detail: `v${process.versions.node} (need >= 22.6 for --experimental-strip-types)`,
34
+ };
35
+ }
36
+
37
+ function configCheck(): Check {
38
+ try {
39
+ const cfg = loadConfig();
40
+ const src = configSource();
41
+ const customized = (Object.keys(DEFAULT_CONFIG) as (keyof typeof DEFAULT_CONFIG)[]).filter(
42
+ (k) => JSON.stringify(cfg[k]) !== JSON.stringify(DEFAULT_CONFIG[k]),
43
+ );
44
+ const detail =
45
+ (src ?? "built-in defaults") +
46
+ (customized.length > 0 ? ` — customized: ${customized.join(", ")}` : "");
47
+ return { name: "config", ok: true, detail };
48
+ } catch (err) {
49
+ return { name: "config", ok: false, detail: String(err) };
50
+ }
51
+ }
52
+
53
+ function scopeCheck(): Check {
54
+ try {
55
+ const s = resolveCwdScope(process.cwd());
56
+ return { name: "scope", ok: true, detail: `cwd → ${s.kind} scope "${s.key}"` };
57
+ } catch (err) {
58
+ return { name: "scope", ok: false, detail: String(err) };
59
+ }
60
+ }
61
+
62
+ function storageCheck(): Check {
63
+ try {
64
+ const p = paths();
65
+ fs.accessSync(p.memories, fs.constants.W_OK);
66
+ return { name: "storage", ok: true, detail: `${p.root} (writable)` };
67
+ } catch (err) {
68
+ return { name: "storage", ok: false, detail: String(err) };
69
+ }
70
+ }
71
+
72
+ /** Spawn the real MCP server, handshake, and verify the five tools list. */
73
+ function mcpCheck(): Promise<Check> {
74
+ const name = "mcp";
75
+ return new Promise((resolve) => {
76
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
77
+ const server = path.join(root, "src", "mcp.ts");
78
+ if (!fs.existsSync(server)) {
79
+ resolve({ name, ok: false, detail: `server entry not found: ${server}` });
80
+ return;
81
+ }
82
+ const child = spawn(process.execPath, ["--experimental-strip-types", server], {
83
+ stdio: ["pipe", "pipe", "pipe"],
84
+ });
85
+ const done = (c: Check) => {
86
+ clearTimeout(timer);
87
+ try {
88
+ child.kill();
89
+ } catch {
90
+ /* already exited */
91
+ }
92
+ resolve(c);
93
+ };
94
+ const timer = setTimeout(
95
+ () => done({ name, ok: false, detail: "handshake timed out after 20s" }),
96
+ 20000,
97
+ );
98
+
99
+ let buf = "";
100
+ const send = (msg: object) => child.stdin.write(JSON.stringify(msg) + "\n");
101
+
102
+ child.stdout.on("data", (d: Buffer) => {
103
+ buf += d.toString();
104
+ let idx: number;
105
+ while ((idx = buf.indexOf("\n")) >= 0) {
106
+ const line = buf.slice(0, idx).trim();
107
+ buf = buf.slice(idx + 1);
108
+ if (!line) continue;
109
+ let msg: { id?: number; result?: { tools?: { name: string }[] } };
110
+ try {
111
+ msg = JSON.parse(line);
112
+ } catch {
113
+ continue; // ignore non-JSON lines on stdout
114
+ }
115
+ if (msg.id === 1 && msg.result) {
116
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
117
+ send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
118
+ } else if (msg.id === 2) {
119
+ const names = (msg.result?.tools ?? []).map((t) => t.name);
120
+ const missing = EXPECTED_TOOLS.filter((t) => !names.includes(t));
121
+ done({
122
+ name,
123
+ ok: missing.length === 0,
124
+ detail:
125
+ missing.length === 0
126
+ ? `handshake OK, 5 tools listed (${names.join(", ")})`
127
+ : `missing tools: ${missing.join(", ")}`,
128
+ });
129
+ }
130
+ }
131
+ });
132
+ child.on("error", (err) =>
133
+ done({ name, ok: false, detail: `spawn failed: ${(err as Error).message}` }),
134
+ );
135
+ // Server stderr is its own log; not a doctor failure signal.
136
+
137
+ send({
138
+ jsonrpc: "2.0",
139
+ id: 1,
140
+ method: "initialize",
141
+ params: {
142
+ protocolVersion: "2024-11-05",
143
+ capabilities: {},
144
+ clientInfo: { name: "open-memex-doctor", version: "0.0.0" },
145
+ },
146
+ });
147
+ });
148
+ }
149
+
150
+ export async function runDoctor(): Promise<boolean> {
151
+ console.log("open-memex doctor");
152
+ const checks: Check[] = [nodeCheck(), configCheck(), scopeCheck(), storageCheck()];
153
+ checks.push(await mcpCheck());
154
+ let allOk = true;
155
+ for (const c of checks) {
156
+ console.log(` ${c.ok ? "ok " : "FAIL"} ${c.name}: ${c.detail}`);
157
+ if (!c.ok) allOk = false;
158
+ }
159
+ console.log(allOk ? "All checks passed." : "Some checks failed — see above.");
160
+ return allOk;
161
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
2
  import { loadConfig } from "./config.ts";
3
- import { resolveProjectScope, resolveCwdScope, USER_SCOPE, type Scope } from "./scope.ts";
3
+ import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
4
4
  import { db } from "./store/db.ts";
5
5
  import { syncScope, upsertFromFile } from "./store/sync.ts";
6
6
  import { scopeHasFiles } from "./store/migrate.ts";
@@ -8,10 +8,12 @@ import {
8
8
  writeMemoryFile,
9
9
  readMemoryFile,
10
10
  ulid,
11
+ msToRfc3339,
11
12
  type Frontmatter,
12
13
  } from "./store/markdown.ts";
13
14
  import { buildContextBlock } from "./retrieve/inject.ts";
14
15
  import { detectKeywords } from "./capture/keywords.ts";
16
+ import { findDuplicates } from "./store/lifecycle.ts";
15
17
  import { redact } from "./redact.ts";
16
18
  import { makeTools } from "./tools/memory.ts";
17
19
 
@@ -24,7 +26,7 @@ const plugin: Plugin = async ({ worktree, directory }) => {
24
26
  try {
25
27
  db();
26
28
  syncScope(scope.key);
27
- syncScope(USER_SCOPE.key);
29
+ syncScope(PERSONAL_SCOPE.key);
28
30
  if (cfg.logLevel === "debug") {
29
31
  console.log(`[open-memex] loaded. scope=${scope.key}`);
30
32
  }
@@ -61,27 +63,46 @@ const plugin: Plugin = async ({ worktree, directory }) => {
61
63
 
62
64
  const hits = detectKeywords(text, cfg);
63
65
  for (const h of hits) {
64
- const { content, hadSecret } = redact(h.content, cfg.redactPatterns);
65
- if (hadSecret || content.length === 0) continue;
66
+ const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
67
+ // Secrets are masked (first 4 chars kept) and the capture proceeds;
68
+ // skip only when nothing usable remains.
69
+ if (content.length === 0) continue;
70
+ if (hadSecret && cfg.logLevel === "debug") {
71
+ console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
72
+ }
73
+ // Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
74
+ const target = h.personal ? PERSONAL_SCOPE : scope;
75
+ // Dedup (§3.4): skip exact duplicates captured before.
76
+ if (findDuplicates(target.key, content).exact) continue;
66
77
  const now = Date.now();
78
+ const rfc = msToRfc3339(now);
67
79
  const fm: Frontmatter = {
68
80
  id: ulid(),
69
- scope_key: scope.key,
70
- scope_kind: scope.kind,
71
- project_name: scope.projectName,
72
- type: "note",
81
+ schema_version: 2,
82
+ scope_key: target.key,
83
+ scope: target.kind === "project" ? "project" : "personal",
84
+ visibility: target.kind === "project" ? "internal" : "private",
85
+ project_name: target.projectName,
86
+ type: "fact",
87
+ role: "knowledge",
88
+ importance: "normal",
89
+ status: "active",
73
90
  tags: ["keyword"],
74
91
  source: "keyword",
75
- created_at: now,
76
- updated_at: now,
92
+ created_at: rfc,
93
+ updated_at: rfc,
94
+ supersedes: null,
95
+ superseded_by: null,
77
96
  };
78
97
  try {
79
98
  const { filePath } = writeMemoryFile(fm, content);
80
99
  const mf = readMemoryFile(filePath);
81
100
  if (mf) upsertFromFile(mf);
82
- if (cfg.logLevel === "debug") {
83
- console.log(`[open-memex] captured keyword memory ${fm.id}`);
84
- }
101
+ // Capture feedback: always visible (not debug-only) — the user said
102
+ // "记住…", they should see that it landed. The opencode plugin API
103
+ // offers no toast channel, so the plugin log is the feedback surface.
104
+ const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
105
+ console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
85
106
  } catch (err) {
86
107
  console.error("[open-memex] keyword capture failed:", err);
87
108
  }
package/src/init.ts ADDED
@@ -0,0 +1,304 @@
1
+ // `open-memex init` — one-command project setup (§17 adoption path).
2
+ // Pure file operation: no DB, no network. Safe to run in any directory.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { DEFAULT_CONFIG, saveConfig } from "./config.ts";
8
+
9
+ const MARKER = "<!-- open-memex -->";
10
+
11
+ /** npm dist-tag carrying the 0.3.x preview line. */
12
+ const ALPHA_TAG = "open-memex@alpha";
13
+
14
+ export interface McpCommand {
15
+ command: string;
16
+ args: string[];
17
+ /** false when no durable bin exists (e.g. one-shot npx) and we fell back to npx. */
18
+ durable: boolean;
19
+ }
20
+
21
+ /**
22
+ * Resolve the MCP server command to write into client configs.
23
+ *
24
+ * A one-shot `npx open-memex@alpha init` runs from npm's ephemeral `_npx` cache,
25
+ * so `open-memex` resolving on PATH *inside that process* does not mean it will be
26
+ * there tomorrow. Only a bin found on PATH outside `_npx` cache dirs counts as
27
+ * durable; otherwise fall back to an npx-based command (slower startup, zero install).
28
+ */
29
+ export function resolveMcpCommand(): McpCommand {
30
+ const pathEnv = process.env.PATH ?? "";
31
+ const dirs = pathEnv.split(path.delimiter).filter((d) => d && !d.includes("_npx"));
32
+ const names = process.platform === "win32" ? ["open-memex.cmd", "open-memex"] : ["open-memex"];
33
+ const durable = dirs.some((d) =>
34
+ names.some((n) => {
35
+ try {
36
+ return fs.existsSync(path.join(d, n));
37
+ } catch {
38
+ return false;
39
+ }
40
+ }),
41
+ );
42
+ if (durable) return { command: "open-memex", args: ["mcp"], durable: true };
43
+ return { command: "npx", args: ["-y", ALPHA_TAG, "mcp"], durable: false };
44
+ }
45
+
46
+ const INSTRUCTIONS = `${MARKER}
47
+ # OpenMemex memory
48
+
49
+ You have a local memory MCP server (\`open-memex\`) with five tools:
50
+ \`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`.
51
+
52
+ - BE PROACTIVE. When the user shares something worth remembering across sessions
53
+ (a decision, a preference, a project convention, a fix and its cause), call
54
+ \`memory_add\` without being asked. Keep each memory to one self-contained statement.
55
+ - Before asking the user about past decisions, conventions, or preferences they may
56
+ have told you before, call \`memory_search\` first — try a few keyword variants
57
+ (including the user's own language) when the first search comes up empty.
58
+ - Memories default to this project's scope; use the \`personal\` scope for facts about
59
+ the user that hold across all projects. When a saved fact becomes outdated, call
60
+ \`memory_supersede\` instead of adding a duplicate.
61
+ `;
62
+
63
+ /** Project root: git top-level, falling back to cwd. */
64
+ function projectRoot(): string {
65
+ try {
66
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
67
+ encoding: "utf8",
68
+ stdio: ["ignore", "pipe", "ignore"],
69
+ }).trim();
70
+ if (top) return top;
71
+ } catch {
72
+ /* not a git repo — use cwd */
73
+ }
74
+ return process.cwd();
75
+ }
76
+
77
+ function writeMcpJson(root: string, client: string, force: boolean): string | null {
78
+ if (client === "opencode") return writeOpencodeMcpJson(root, force);
79
+ if (client === "visualstudio") return writeVisualStudioMcpJson(root, force);
80
+ const dir = client === "cursor" ? path.join(root, ".cursor") : path.join(root, ".vscode");
81
+ const file = path.join(dir, "mcp.json");
82
+ const sectionKey = client === "cursor" ? "mcpServers" : "servers";
83
+
84
+ let doc: Record<string, unknown> = {};
85
+ if (fs.existsSync(file)) {
86
+ try {
87
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
88
+ } catch {
89
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
90
+ return null;
91
+ }
92
+ }
93
+
94
+ const section = ((doc[sectionKey] ??= {}) as Record<string, unknown>);
95
+ if (section["open-memex"] && !force) {
96
+ console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
97
+ return file;
98
+ }
99
+ // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
100
+ const mc = resolveMcpCommand();
101
+ section["open-memex"] =
102
+ client === "cursor"
103
+ ? { command: mc.command, args: mc.args }
104
+ : {
105
+ type: "stdio",
106
+ command: mc.command,
107
+ args: mc.args,
108
+ cwd: "${workspaceFolder}",
109
+ };
110
+
111
+ fs.mkdirSync(dir, { recursive: true });
112
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
113
+ console.log(` + ${file}`);
114
+ if (!mc.durable) {
115
+ console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
116
+ console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
117
+ }
118
+ return file;
119
+ }
120
+
121
+ /** opencode MCP config: project-level opencode.jsonc, `type: "local"` + command array (v1 format). */
122
+ function writeOpencodeMcpJson(root: string, force: boolean): string | null {
123
+ const file = path.join(root, "opencode.jsonc");
124
+ let doc: Record<string, unknown> = {};
125
+ if (fs.existsSync(file)) {
126
+ try {
127
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
128
+ } catch {
129
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
130
+ return null;
131
+ }
132
+ }
133
+ const section = ((doc["mcp"] ??= {}) as Record<string, unknown>);
134
+ if (section["open-memex"] && !force) {
135
+ console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
136
+ return file;
137
+ }
138
+ // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
139
+ const mc = resolveMcpCommand();
140
+ section["open-memex"] = {
141
+ type: "local",
142
+ command: [mc.command, ...mc.args],
143
+ enabled: true,
144
+ };
145
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
146
+ console.log(` + ${file}`);
147
+ if (!mc.durable) {
148
+ console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
149
+ console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
150
+ }
151
+ return file;
152
+ }
153
+
154
+ /** Visual Studio (Windows-only, 2022 17.14+ / 2026): solution-level `.mcp.json`
155
+ * with the `"servers"` section, per Microsoft Learn. Source-controllable.
156
+ * (VS also auto-discovers `.vscode/mcp.json` and `.cursor/mcp.json`.) */
157
+ function writeVisualStudioMcpJson(root: string, force: boolean): string | null {
158
+ const file = path.join(root, ".mcp.json");
159
+ let doc: Record<string, unknown> = {};
160
+ if (fs.existsSync(file)) {
161
+ try {
162
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
163
+ } catch {
164
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
165
+ return null;
166
+ }
167
+ }
168
+ const section = ((doc["servers"] ??= {}) as Record<string, unknown>);
169
+ if (section["open-memex"] && !force) {
170
+ console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
171
+ return file;
172
+ }
173
+ // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
174
+ const mc = resolveMcpCommand();
175
+ section["open-memex"] = {
176
+ type: "stdio",
177
+ command: mc.command,
178
+ args: mc.args,
179
+ };
180
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
181
+ console.log(` + ${file}`);
182
+ if (!mc.durable) {
183
+ console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
184
+ console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
185
+ }
186
+ return file;
187
+ }
188
+
189
+ function writeInstructions(root: string): string {
190
+ const dir = path.join(root, ".github");
191
+ const file = path.join(dir, "copilot-instructions.md");
192
+ if (fs.existsSync(file)) {
193
+ const cur = fs.readFileSync(file, "utf8");
194
+ if (cur.includes(MARKER)) {
195
+ console.log(` = ${file} already has open-memex instructions — left as is`);
196
+ return file;
197
+ }
198
+ fs.writeFileSync(file, cur.replace(/\s+$/, "") + "\n\n" + INSTRUCTIONS);
199
+ } else {
200
+ fs.mkdirSync(dir, { recursive: true });
201
+ fs.writeFileSync(file, INSTRUCTIONS);
202
+ }
203
+ console.log(` + ${file}`);
204
+ return file;
205
+ }
206
+
207
+ export const INIT_CLIENTS = ["vscode", "cursor", "opencode", "visualstudio"] as const;
208
+
209
+ /** Normalize --client values; accepts "visual-studio" as an alias. */
210
+ export function normalizeClient(c: string): string {
211
+ const lower = c.toLowerCase();
212
+ return lower === "visual-studio" ? "visualstudio" : lower;
213
+ }
214
+
215
+ /** Ask a yes/no question. Only called on a TTY when --yes was not passed. */
216
+ async function askBool(q: string, def: boolean): Promise<boolean> {
217
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
218
+ try {
219
+ const hint = def ? "Y/n" : "y/N";
220
+ const ans = (await rl.question(`${q} [${hint}]: `)).trim().toLowerCase();
221
+ if (!ans) return def;
222
+ return ans === "y" || ans === "yes";
223
+ } finally {
224
+ rl.close();
225
+ }
226
+ }
227
+
228
+ async function promptClient(): Promise<string | null> {
229
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
230
+ try {
231
+ const ans = (
232
+ await rl.question(
233
+ "Which editor? (1) VS Code (2) Cursor (3) opencode (4) Visual Studio (5) skip [1]: ",
234
+ )
235
+ ).trim();
236
+ switch (ans) {
237
+ case "":
238
+ case "1":
239
+ return "vscode";
240
+ case "2":
241
+ return "cursor";
242
+ case "3":
243
+ return "opencode";
244
+ case "4":
245
+ return "visualstudio";
246
+ case "5":
247
+ return null;
248
+ default:
249
+ console.log(` ? unknown choice "${ans}" — editor setup skipped`);
250
+ return null;
251
+ }
252
+ } finally {
253
+ rl.close();
254
+ }
255
+ }
256
+
257
+ export async function initProject(opts: {
258
+ client?: string;
259
+ force: boolean;
260
+ yes: boolean;
261
+ }): Promise<void> {
262
+ const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
263
+ let client = normalizeClient(opts.client ?? "");
264
+ if (client && !(INIT_CLIENTS as readonly string[]).includes(client)) {
265
+ console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
266
+ process.exit(1);
267
+ }
268
+ if (!client && interactive) client = (await promptClient()) ?? "";
269
+ if (!client && !interactive) client = "vscode"; // historical default for scripts / one-shot npx
270
+ if (interactive) {
271
+ // Install-time settings (D19). Non-default answers persist to the JSONC
272
+ // config file; `open-memex config set` changes them later.
273
+ const patch: Record<string, unknown> = {};
274
+ const keywordCaptureEnabled = await askBool(
275
+ "Auto-capture keywords like 记住… / remember… into memory?",
276
+ DEFAULT_CONFIG.keywordCaptureEnabled,
277
+ );
278
+ if (keywordCaptureEnabled !== DEFAULT_CONFIG.keywordCaptureEnabled)
279
+ patch.keywordCaptureEnabled = keywordCaptureEnabled;
280
+ const injectOnFirstTurn = await askBool(
281
+ "Inject relevant memories when a session starts?",
282
+ DEFAULT_CONFIG.injectOnFirstTurn,
283
+ );
284
+ if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
285
+ patch.injectOnFirstTurn = injectOnFirstTurn;
286
+ if (Object.keys(patch).length > 0) {
287
+ const file = saveConfig(patch);
288
+ console.log(
289
+ ` + settings saved to ${file} (change later with \`open-memex config set <key> <value>\`)`,
290
+ );
291
+ }
292
+ }
293
+ const root = projectRoot();
294
+ console.log(`open-memex init — project root: ${root}`);
295
+ if (client) {
296
+ writeMcpJson(root, client, opts.force);
297
+ // copilot-instructions.md is VS Code/Cursor-shaped; opencode as a plain MCP
298
+ // consumer already gets the guidance from the tool descriptions (D16).
299
+ if (client !== "opencode") writeInstructions(root);
300
+ } else {
301
+ console.log(" - editor setup skipped");
302
+ }
303
+ console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
304
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * open-memex generic MCP server (stdio transport).
3
+ *
4
+ * Exposes the same five memory tools as the opencode plugin
5
+ * (memory_add / memory_search / memory_list / memory_supersede /
6
+ * memory_forget) over the Model Context Protocol, so any MCP client —
7
+ * VS Code Copilot Chat, Cursor, Claude Code, etc. — can use open-memex
8
+ * without a host-specific plugin.
9
+ *
10
+ * Run: node --experimental-strip-types src/mcp.ts
11
+ * (or: npm run mcp)
12
+ *
13
+ * The project scope is resolved from the process working directory, so
14
+ * launch the server with cwd set to the project root (VS Code, Cursor and
15
+ * Claude Code all do this for workspace-configured MCP servers).
16
+ *
17
+ * IMPORTANT: stdout is the MCP protocol channel. Never log to stdout here;
18
+ * diagnostics go to stderr.
19
+ */
20
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
+ import { z } from "zod";
23
+ import { loadConfig } from "./config.ts";
24
+ import { resolveProjectScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
25
+ import { db } from "./store/db.ts";
26
+ import { syncScope } from "./store/sync.ts";
27
+ import {
28
+ addMemory,
29
+ searchMemories,
30
+ listMemories,
31
+ supersedeMemory,
32
+ forgetMemory,
33
+ memoryAddArgs,
34
+ memorySearchArgs,
35
+ memoryListArgs,
36
+ memorySupersedeArgs,
37
+ memoryForgetArgs,
38
+ TOOL_DESCRIPTIONS,
39
+ type ToolResult,
40
+ } from "./tools/ops.ts";
41
+
42
+ const SERVER_VERSION = "0.2.0-alpha";
43
+
44
+ /** Adapt a framework-agnostic op result to an MCP tool response. */
45
+ function toMcp(p: Promise<ToolResult>) {
46
+ return p.then(
47
+ (r) => ({ content: [{ type: "text" as const, text: r.output }] }),
48
+ (e: unknown) => ({
49
+ content: [
50
+ {
51
+ type: "text" as const,
52
+ text: `open-memex error: ${(e as Error)?.message ?? String(e)}`,
53
+ },
54
+ ],
55
+ isError: true as const,
56
+ }),
57
+ );
58
+ }
59
+
60
+ export async function runMcpServer() {
61
+ const cfg = loadConfig();
62
+ const scope: Scope = resolveProjectScope(process.cwd());
63
+ const getScope = () => scope;
64
+
65
+ // Init DB and one-shot sync of markdown -> index, mirroring the plugin.
66
+ db();
67
+ syncScope(scope.key);
68
+ syncScope(PERSONAL_SCOPE.key);
69
+ console.error(`[open-memex] MCP server up. scope=${scope.key}`);
70
+
71
+ const server = new McpServer({ name: "open-memex", version: SERVER_VERSION });
72
+
73
+ server.registerTool(
74
+ "memory_add",
75
+ {
76
+ description: TOOL_DESCRIPTIONS.memory_add,
77
+ inputSchema: z.object(memoryAddArgs),
78
+ },
79
+ (args) => toMcp(addMemory(getScope, cfg, args)),
80
+ );
81
+
82
+ server.registerTool(
83
+ "memory_search",
84
+ {
85
+ description: TOOL_DESCRIPTIONS.memory_search,
86
+ inputSchema: z.object(memorySearchArgs),
87
+ annotations: { readOnlyHint: true },
88
+ },
89
+ (args) => toMcp(searchMemories(getScope, args)),
90
+ );
91
+
92
+ server.registerTool(
93
+ "memory_list",
94
+ {
95
+ description: TOOL_DESCRIPTIONS.memory_list,
96
+ inputSchema: z.object(memoryListArgs),
97
+ annotations: { readOnlyHint: true },
98
+ },
99
+ (args) => toMcp(listMemories(getScope, args)),
100
+ );
101
+
102
+ server.registerTool(
103
+ "memory_supersede",
104
+ {
105
+ description: TOOL_DESCRIPTIONS.memory_supersede,
106
+ inputSchema: z.object(memorySupersedeArgs),
107
+ },
108
+ (args) => toMcp(supersedeMemory(cfg, args)),
109
+ );
110
+
111
+ server.registerTool(
112
+ "memory_forget",
113
+ {
114
+ description: TOOL_DESCRIPTIONS.memory_forget,
115
+ inputSchema: z.object(memoryForgetArgs),
116
+ annotations: { destructiveHint: true },
117
+ },
118
+ (args) => toMcp(forgetMemory(args)),
119
+ );
120
+
121
+ const transport = new StdioServerTransport();
122
+ await server.connect(transport);
123
+ }
124
+
125
+ // Standalone entry: `node --experimental-strip-types src/mcp.ts`.
126
+ // The CLI (`open-memex mcp`) imports runMcpServer() instead.
127
+ import { pathToFileURL } from "node:url";
128
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
129
+ runMcpServer().catch((err) => {
130
+ console.error("[open-memex] MCP server failed:", err);
131
+ process.exit(1);
132
+ });
133
+ }