@echomem/mcp 1.0.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/dist/setup.js ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * `echomem-mcp setup | login | unlock | status | logout` — onboarding for the local bridge (spec §8).
3
+ *
4
+ * Design goals from the spec:
5
+ * - One command → one browser approval → one reload.
6
+ * - Both secrets (API token + encryption key) ride a single browser flow and land in the local
7
+ * keystore — never in the client's MCP config, never in the agent's chat context.
8
+ * - Re-unlock after the key's TTL is one step, not a re-setup.
9
+ *
10
+ * The browser flow posts `{ token, key? }` to a localhost callback this process opens. The
11
+ * "connect device" web page that drives it is the one piece that lives in the web app (not here);
12
+ * until it ships, the same flow is fully usable via the manual flags (`--token`, `--key`,
13
+ * `--passphrase`), which is also the documented headless/SSH path (spec §8).
14
+ */
15
+ import http from "node:http";
16
+ import { randomUUID } from "node:crypto";
17
+ import { spawn } from "node:child_process";
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import readline from "node:readline";
22
+ import axios from "axios";
23
+ import { KeyStore } from "./keystore.js";
24
+ import { fetchEncryptionConfig, deriveAndVerifyKey, verifyKeyB64 } from "./encryption.js";
25
+ // The connect-device page lives on the main site (WebPageReactVersion → yeahecho.com), where the
26
+ // user already has a session. It calls the EchoMem API cross-origin. Override with ECHO_WEB_URL.
27
+ const WEB_URL = (process.env.ECHO_WEB_URL || "https://yeahecho.com").replace(/\/$/, "");
28
+ const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
29
+ function home(...p) {
30
+ return path.join(os.homedir(), ...p);
31
+ }
32
+ /** Known clients and where their MCP server map lives. */
33
+ export function knownClients() {
34
+ const appSupport = process.platform === "darwin"
35
+ ? home("Library", "Application Support")
36
+ : process.env.APPDATA || home(".config");
37
+ return [
38
+ { id: "cursor", label: "Cursor", kind: "json", configPath: home(".cursor", "mcp.json") },
39
+ { id: "windsurf", label: "Windsurf", kind: "json", configPath: home(".codeium", "windsurf", "mcp_config.json") },
40
+ { id: "claude-desktop", label: "Claude Desktop", kind: "json", configPath: path.join(appSupport, "Claude", "claude_desktop_config.json") },
41
+ { id: "claude-code", label: "Claude Code", kind: "snippet", note: "run: claude mcp add-json echomem '<entry>' (or add to .mcp.json)" },
42
+ { id: "codex", label: "Codex", kind: "snippet", note: "add to ~/.codex/config.toml under [mcp_servers.echomem]" },
43
+ ];
44
+ }
45
+ /** A client is "present" if its config dir already exists (JSON) — a cheap heuristic for detection. */
46
+ export function detectClients() {
47
+ return knownClients().filter((c) => {
48
+ if (c.kind !== "json")
49
+ return false;
50
+ return fs.existsSync(path.dirname(c.configPath));
51
+ });
52
+ }
53
+ /**
54
+ * The MCP server entry written into a client config. Deliberately carries NO secret — the bridge
55
+ * reads the token + key from the keystore at `~/.echomem/credentials.json`. `dev` points the client
56
+ * at a local checkout instead of the (unpublished) npm package.
57
+ */
58
+ export function buildServerEntry(opts = {}) {
59
+ if (opts.devEntryPath) {
60
+ return { command: "node", args: [opts.devEntryPath] };
61
+ }
62
+ return { command: "npx", args: ["-y", "@echomem/mcp"] };
63
+ }
64
+ /** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
65
+ export function writeJsonClientConfig(configPath, entry) {
66
+ let config = {};
67
+ try {
68
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
69
+ }
70
+ catch {
71
+ /* fresh config */
72
+ }
73
+ config.mcpServers = config.mcpServers || {};
74
+ config.mcpServers.echomem = entry;
75
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
76
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
77
+ }
78
+ // ---------------------------------------------------------------------------
79
+ // Browser + localhost callback
80
+ // ---------------------------------------------------------------------------
81
+ function openBrowser(url) {
82
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
83
+ try {
84
+ spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
85
+ }
86
+ catch {
87
+ /* headless — caller prints the URL */
88
+ }
89
+ }
90
+ /**
91
+ * Start a localhost callback server. Resolves immediately with the bound `port` and a `wait` promise
92
+ * that settles when the connect-device page delivers `{ token, key? }` — by POSTing JSON to
93
+ * `/callback` or hitting `/callback?token=…&key=…`. Splitting port-acquisition from the token-wait
94
+ * lets the caller build the callback URL before the user approves.
95
+ */
96
+ export function startCallbackServer(opts = {}) {
97
+ const timeoutMs = opts.timeoutMs ?? 180_000;
98
+ const expectedNonce = opts.nonce;
99
+ return new Promise((resolveOuter, rejectOuter) => {
100
+ let settle;
101
+ let fail;
102
+ const wait = new Promise((res, rej) => {
103
+ settle = res;
104
+ fail = rej;
105
+ });
106
+ const server = http.createServer((req, res) => {
107
+ res.setHeader("Access-Control-Allow-Origin", "*");
108
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
109
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
110
+ // Let the HTTPS connect-device page reach this localhost server (Chrome Private Network Access).
111
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
112
+ if (req.method === "OPTIONS")
113
+ return void res.writeHead(204).end();
114
+ const url = new URL(req.url || "/", "http://127.0.0.1");
115
+ if (!url.pathname.startsWith("/callback"))
116
+ return void res.writeHead(404).end();
117
+ const finish = (token, key, nonce) => {
118
+ // Nonce gates the callback: only the page we opened (which carries the nonce) can post here.
119
+ if (expectedNonce && nonce !== expectedNonce)
120
+ return void res.writeHead(403, { "Content-Type": "text/plain" }).end("bad nonce");
121
+ if (!token)
122
+ return void res.writeHead(400, { "Content-Type": "text/plain" }).end("missing token");
123
+ res.writeHead(200, { "Content-Type": "text/html" }).end("<html><body style='font-family:system-ui;padding:3rem;text-align:center'><h2>✅ EchoMem connected</h2><p>You can close this tab and return to your editor.</p></body></html>");
124
+ clearTimeout(timer);
125
+ server.close();
126
+ settle({ token, key });
127
+ };
128
+ if (req.method === "GET") {
129
+ finish(url.searchParams.get("token") || undefined, url.searchParams.get("key") || undefined, url.searchParams.get("nonce") || undefined);
130
+ }
131
+ else {
132
+ let body = "";
133
+ req.on("data", (c) => (body += c));
134
+ req.on("end", () => {
135
+ try {
136
+ const parsed = JSON.parse(body || "{}");
137
+ finish(parsed.token, parsed.key, parsed.nonce);
138
+ }
139
+ catch {
140
+ res.writeHead(400).end("bad json");
141
+ }
142
+ });
143
+ }
144
+ });
145
+ const timer = setTimeout(() => {
146
+ server.close();
147
+ fail(new Error("timed out waiting for browser approval"));
148
+ }, timeoutMs);
149
+ server.on("error", (e) => rejectOuter(e));
150
+ server.listen(0, "127.0.0.1", () => {
151
+ const addr = server.address();
152
+ const port = typeof addr === "object" && addr ? addr.port : 0;
153
+ resolveOuter({
154
+ port,
155
+ wait,
156
+ close: () => {
157
+ clearTimeout(timer);
158
+ server.close();
159
+ },
160
+ });
161
+ });
162
+ });
163
+ }
164
+ // ---------------------------------------------------------------------------
165
+ // Key/token provisioning
166
+ // ---------------------------------------------------------------------------
167
+ function authedAxios(token) {
168
+ return axios.create({
169
+ baseURL: API_BASE_URL,
170
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
171
+ });
172
+ }
173
+ /**
174
+ * Verify supplied secrets and persist them. Given a token (required), and EITHER a base64 key or a
175
+ * passphrase (optional — only for encrypted accounts), this verifies the key against the server's
176
+ * verification token before caching. Returns a human-readable status.
177
+ */
178
+ export async function verifyAndStore(input) {
179
+ const store = new KeyStore();
180
+ store.saveToken(input.token);
181
+ const config = await fetchEncryptionConfig(authedAxios(input.token));
182
+ if (!config.enabled) {
183
+ return input.key || input.passphrase
184
+ ? "Token saved. (Account is not encrypted — the supplied key was ignored.)"
185
+ : "Token saved. Account is not encrypted; you're ready.";
186
+ }
187
+ // Encrypted account — provision and VERIFY the key before caching.
188
+ let keyB64 = null;
189
+ if (input.passphrase) {
190
+ keyB64 = await deriveAndVerifyKey(input.passphrase, config);
191
+ if (!keyB64)
192
+ return "❌ Passphrase did not match this account's encryption. Nothing saved for the key.";
193
+ }
194
+ else if (input.key) {
195
+ keyB64 = (await verifyKeyB64(input.key, config)) ? input.key : null;
196
+ if (!keyB64)
197
+ return "❌ Supplied key failed verification against the account. Nothing saved for the key.";
198
+ }
199
+ else {
200
+ return "Token saved, but this account is ENCRYPTED. Re-run with --passphrase to unlock the vault.";
201
+ }
202
+ store.saveKey(keyB64);
203
+ return "✅ Token + encryption key verified and saved. Reload your MCP client.";
204
+ }
205
+ function prompt(question, { silent = false } = {}) {
206
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
207
+ return new Promise((resolve) => {
208
+ if (silent) {
209
+ const out = process.stdout;
210
+ rl._writeToOutput = () => out.write("");
211
+ }
212
+ rl.question(question, (answer) => {
213
+ rl.close();
214
+ if (silent)
215
+ process.stdout.write("\n");
216
+ resolve(answer.trim());
217
+ });
218
+ });
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // CLI
222
+ // ---------------------------------------------------------------------------
223
+ function parseFlags(argv) {
224
+ const flags = {};
225
+ for (let i = 0; i < argv.length; i++) {
226
+ const a = argv[i];
227
+ if (a.startsWith("--")) {
228
+ const key = a.slice(2);
229
+ const next = argv[i + 1];
230
+ if (next && !next.startsWith("--")) {
231
+ flags[key] = next;
232
+ i++;
233
+ }
234
+ else {
235
+ flags[key] = true;
236
+ }
237
+ }
238
+ }
239
+ return flags;
240
+ }
241
+ async function cmdSetup(flags) {
242
+ const entry = buildServerEntry({ devEntryPath: typeof flags.dev === "string" ? flags.dev : undefined });
243
+ const requested = typeof flags.client === "string" ? flags.client : undefined;
244
+ const targets = (requested ? knownClients().filter((c) => c.id === requested) : detectClients());
245
+ if (targets.length === 0) {
246
+ console.log("No JSON-config client auto-detected. Add this MCP server entry manually:\n");
247
+ console.log(JSON.stringify({ echomem: entry }, null, 2));
248
+ console.log("\n(Or re-run with --client cursor|windsurf|claude-desktop.)");
249
+ }
250
+ else {
251
+ for (const c of targets) {
252
+ if (c.kind === "json") {
253
+ writeJsonClientConfig(c.configPath, entry);
254
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
255
+ }
256
+ else {
257
+ console.log(`ℹ️ ${c.label}: ${c.note}\n entry: ${JSON.stringify(entry)}`);
258
+ }
259
+ }
260
+ }
261
+ console.log("");
262
+ await cmdLogin(flags);
263
+ }
264
+ async function cmdLogin(flags) {
265
+ // Manual path (also the headless path): secrets supplied as flags.
266
+ if (typeof flags.token === "string") {
267
+ const msg = await verifyAndStore({
268
+ token: flags.token,
269
+ key: typeof flags.key === "string" ? flags.key : undefined,
270
+ passphrase: typeof flags.passphrase === "string" ? flags.passphrase : undefined,
271
+ });
272
+ console.log(msg);
273
+ return;
274
+ }
275
+ // Browser path: open the connect-device page pointed at our localhost callback. The nonce gates
276
+ // the callback so only the page we opened can deliver the token+key.
277
+ console.log("Opening your browser to approve this device…");
278
+ const nonce = randomUUID();
279
+ const { port, wait, close } = await startCallbackServer({ nonce });
280
+ const callbackUrl = `http://127.0.0.1:${port}/callback`;
281
+ const connectUrl = `${WEB_URL}/connect-device?callback=${encodeURIComponent(callbackUrl)}&nonce=${nonce}`;
282
+ openBrowser(connectUrl);
283
+ console.log(`If it didn't open, visit:\n ${connectUrl}\n`);
284
+ try {
285
+ const { token, key } = await wait;
286
+ const msg = await verifyAndStore({ token, key });
287
+ console.log(msg);
288
+ }
289
+ catch (e) {
290
+ close();
291
+ console.error(`❌ ${e?.message || e}. You can instead run: echomem-mcp login --token ec_… [--passphrase <vault pass>]`);
292
+ process.exitCode = 1;
293
+ }
294
+ }
295
+ async function cmdUnlock(flags) {
296
+ const store = new KeyStore();
297
+ const token = store.getToken();
298
+ if (!token) {
299
+ console.error("Not logged in. Run `echomem-mcp login` first.");
300
+ process.exitCode = 1;
301
+ return;
302
+ }
303
+ const config = await fetchEncryptionConfig(authedAxios(token));
304
+ if (!config.enabled) {
305
+ console.log("This account is not encrypted — nothing to unlock.");
306
+ return;
307
+ }
308
+ let passphrase = typeof flags.passphrase === "string" ? flags.passphrase : undefined;
309
+ const key = typeof flags.key === "string" ? flags.key : undefined;
310
+ if (!passphrase && !key) {
311
+ passphrase = await prompt("Vault passphrase: ", { silent: true });
312
+ }
313
+ const keyB64 = passphrase ? await deriveAndVerifyKey(passphrase, config) : key && (await verifyKeyB64(key, config)) ? key : null;
314
+ if (!keyB64) {
315
+ console.error("❌ Passphrase/key did not match. Vault stays locked.");
316
+ process.exitCode = 1;
317
+ return;
318
+ }
319
+ store.saveKey(keyB64);
320
+ console.log("✅ Vault unlocked. Reload your MCP client (or start a new session).");
321
+ }
322
+ async function cmdStatus() {
323
+ const store = new KeyStore();
324
+ const token = store.getToken();
325
+ console.log(`Credentials file: ${store.path()}`);
326
+ console.log(`API token: ${token ? "present" : "MISSING — run `echomem-mcp login`"}`);
327
+ console.log(`Encryption key: ${store.getKey() ? "present" : store.isKeyExpired() ? "EXPIRED — run `echomem-mcp unlock`" : "not set"}`);
328
+ const detected = detectClients();
329
+ console.log(`Detected clients: ${detected.length ? detected.map((c) => c.label).join(", ") : "none auto-detected"}`);
330
+ }
331
+ function cmdLogout() {
332
+ const store = new KeyStore();
333
+ try {
334
+ fs.rmSync(store.path());
335
+ console.log(`Removed ${store.path()}.`);
336
+ }
337
+ catch {
338
+ console.log("No stored credentials to remove.");
339
+ }
340
+ }
341
+ const HELP = `EchoMem MCP — local memory bridge
342
+
343
+ Usage:
344
+ echomem-mcp Run the MCP server (stdio; default — used by your editor)
345
+ echomem-mcp setup [--client X] Detect editor, write its MCP config, then log in
346
+ echomem-mcp login Approve this device in the browser (or --token/--passphrase)
347
+ echomem-mcp unlock Re-derive the encryption key after its TTL (or --passphrase)
348
+ echomem-mcp status Show token/key/clients
349
+ echomem-mcp logout Remove stored credentials
350
+
351
+ Manual / headless:
352
+ echomem-mcp login --token ec_xxx [--passphrase <vault pass> | --key <base64>]
353
+ echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
354
+ `;
355
+ /** Returns true if argv was a recognized subcommand (and was handled). */
356
+ export async function runCli(argv) {
357
+ const cmd = argv[0];
358
+ const flags = parseFlags(argv.slice(1));
359
+ switch (cmd) {
360
+ case "setup":
361
+ await cmdSetup(flags);
362
+ return true;
363
+ case "login":
364
+ await cmdLogin(flags);
365
+ return true;
366
+ case "unlock":
367
+ await cmdUnlock(flags);
368
+ return true;
369
+ case "status":
370
+ await cmdStatus();
371
+ return true;
372
+ case "logout":
373
+ cmdLogout();
374
+ return true;
375
+ case "help":
376
+ case "--help":
377
+ case "-h":
378
+ console.log(HELP);
379
+ return true;
380
+ default:
381
+ return false;
382
+ }
383
+ }
@@ -0,0 +1,151 @@
1
+ import { z } from "zod";
2
+ export const canonicalToolNames = {
3
+ search: "search_memories",
4
+ save: "save_conversation",
5
+ timeRange: "get_memories_by_time_range",
6
+ keywords: "search_memories_by_keywords",
7
+ others: "search_others_memories",
8
+ };
9
+ export const legacyAliasToCanonical = {
10
+ search_memories_by_description_semantic: canonicalToolNames.search,
11
+ search_memories_by_time_range: canonicalToolNames.timeRange,
12
+ };
13
+ export function resolveCanonicalToolName(toolName) {
14
+ return legacyAliasToCanonical[toolName] ?? toolName;
15
+ }
16
+ export const searchMemoriesSchema = z.object({
17
+ query: z.string().optional(),
18
+ k: z.number().optional(),
19
+ limit: z.number().optional(),
20
+ threshold: z.number().optional().default(0.1),
21
+ timeFrameDays: z.number().optional(),
22
+ });
23
+ export const saveConversationSchema = z.object({
24
+ conversation: z.string().optional(),
25
+ title: z.string().optional(),
26
+ url: z.string().optional(),
27
+ source: z.string().optional(),
28
+ tags: z.array(z.string()).optional(),
29
+ messages: z
30
+ .array(z.object({
31
+ role: z.string(),
32
+ content: z.string(),
33
+ }))
34
+ .optional(),
35
+ });
36
+ export const timeRangeSchema = z.object({
37
+ startDate: z.string(),
38
+ endDate: z.string(),
39
+ limit: z.number().optional().default(50),
40
+ });
41
+ export const keywordsSchema = z.object({
42
+ keywords: z.array(z.string()),
43
+ limit: z.number().optional().default(10),
44
+ });
45
+ export const othersSchema = z.object({
46
+ query: z.string(),
47
+ });
48
+ export function listToolSpecs() {
49
+ const currentTime = new Date().toISOString();
50
+ return [
51
+ {
52
+ name: canonicalToolNames.search,
53
+ description: `Recall the user's prior decisions, preferences, constraints, and context from EchoMem — their long-term memory spanning ALL their AI tools (Claude.ai, ChatGPT, other agents), not just this session. CALL THIS AT THE START of any non-trivial task, before planning or writing code, to avoid re-deriving things the user already decided. Also call it whenever the user refers to past work ("what did we decide", "like before", "the usual"). Returns ranked memories with provenance. Current time: ${currentTime}.`,
54
+ inputSchema: {
55
+ type: "object",
56
+ properties: {
57
+ query: { type: "string" },
58
+ limit: { type: "number", default: 10 },
59
+ threshold: { type: "number", default: 0.1 },
60
+ timeFrameDays: { type: "number" },
61
+ },
62
+ },
63
+ },
64
+ {
65
+ name: "search_memories_by_description_semantic",
66
+ description: "Legacy alias for search_memories.",
67
+ inputSchema: {
68
+ type: "object",
69
+ properties: {
70
+ query: { type: "string" },
71
+ limit: { type: "number", default: 10 },
72
+ threshold: { type: "number", default: 0.1 },
73
+ timeFrameDays: { type: "number" },
74
+ },
75
+ },
76
+ },
77
+ {
78
+ name: canonicalToolNames.save,
79
+ description: "Save conversation into EchoMem for future retrieval.",
80
+ inputSchema: {
81
+ type: "object",
82
+ properties: {
83
+ conversation: { type: "string" },
84
+ title: { type: "string" },
85
+ url: { type: "string" },
86
+ source: { type: "string" },
87
+ tags: { type: "array", items: { type: "string" } },
88
+ messages: {
89
+ type: "array",
90
+ items: {
91
+ type: "object",
92
+ properties: {
93
+ role: { type: "string" },
94
+ content: { type: "string" },
95
+ },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ },
101
+ {
102
+ name: canonicalToolNames.timeRange,
103
+ description: `Retrieve memories within a specific date range. Current time: ${currentTime}.`,
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ startDate: { type: "string" },
108
+ endDate: { type: "string" },
109
+ limit: { type: "number", default: 50 },
110
+ },
111
+ required: ["startDate", "endDate"],
112
+ },
113
+ },
114
+ {
115
+ name: canonicalToolNames.keywords,
116
+ description: "Search memories based on keywords in keys field.",
117
+ inputSchema: {
118
+ type: "object",
119
+ properties: {
120
+ keywords: { type: "array", items: { type: "string" } },
121
+ limit: { type: "number", default: 10 },
122
+ },
123
+ required: ["keywords"],
124
+ },
125
+ },
126
+ {
127
+ name: canonicalToolNames.others,
128
+ description: "Search other users' public memories.",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ query: { type: "string" },
133
+ },
134
+ required: ["query"],
135
+ },
136
+ },
137
+ {
138
+ name: "search_memories_by_time_range",
139
+ description: "Legacy alias for get_memories_by_time_range.",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {
143
+ startDate: { type: "string" },
144
+ endDate: { type: "string" },
145
+ limit: { type: "number", default: 50 },
146
+ },
147
+ required: ["startDate", "endDate"],
148
+ },
149
+ },
150
+ ];
151
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@echomem/mcp",
3
+ "version": "1.0.0",
4
+ "description": "EchoMem Cloud-First MCP Server",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "echomem-mcp": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "templates",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "start": "node dist/index.js",
18
+ "dev": "tsx src/index.ts",
19
+ "smoke": "node smoke.mjs",
20
+ "test": "npm run build && node test/crypto.test.mjs && node test/integration.test.mjs && node test/no-restart.test.mjs",
21
+ "prepack": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.0.1",
25
+ "zod": "^3.22.4",
26
+ "axios": "^1.6.8"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^5.3.3",
30
+ "tsx": "^4.7.1",
31
+ "@types/node": "^20.11.0"
32
+ }
33
+ }
@@ -0,0 +1,17 @@
1
+ <!-- echomem:recall-rule v1 — installed by `npx @echomem/mcp setup` into the client's
2
+ project rules (CLAUDE.md / .cursor/rules / AGENTS.md). This is the second half of the
3
+ nudged-triggering design (spec §9): the tool description nudges, this rule reinforces. -->
4
+
5
+ ## EchoMem memory
6
+
7
+ This project has EchoMem connected — the user's long-term memory across all their AI tools.
8
+
9
+ - **At the start of any non-trivial task**, call `search_memories` first to recall the user's
10
+ prior decisions, preferences, and constraints before planning or writing code. Don't re-derive
11
+ things they've already decided.
12
+ - Call it again whenever the user refers to past work ("what did we decide", "like before",
13
+ "the usual", "our convention").
14
+ - Treat recalled memories as authoritative context, but **cite them** (they carry provenance —
15
+ date + source) so the user can verify.
16
+ - To persist a new decision/constraint/resolved-bug, use `save_conversation` **only when the user
17
+ explicitly asks** ("remember this"). Never auto-save the transcript.