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