ricord 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.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +213 -0
  3. package/commands/ricord-flush.md +29 -0
  4. package/commands/ricord-init.md +129 -0
  5. package/commands/ricord-lint.md +64 -0
  6. package/commands/ricord-query.md +71 -0
  7. package/dist/cli/auth.d.ts +16 -0
  8. package/dist/cli/auth.js +42 -0
  9. package/dist/cli/auth.js.map +1 -0
  10. package/dist/cli/bundle.d.ts +25 -0
  11. package/dist/cli/bundle.js +179 -0
  12. package/dist/cli/bundle.js.map +1 -0
  13. package/dist/cli/cache.d.ts +18 -0
  14. package/dist/cli/cache.js +39 -0
  15. package/dist/cli/cache.js.map +1 -0
  16. package/dist/cli/cli.d.ts +21 -0
  17. package/dist/cli/cli.js +355 -0
  18. package/dist/cli/cli.js.map +1 -0
  19. package/dist/cli/client.d.ts +12 -0
  20. package/dist/cli/client.js +35 -0
  21. package/dist/cli/client.js.map +1 -0
  22. package/dist/cli/commands/build.d.ts +44 -0
  23. package/dist/cli/commands/build.js +437 -0
  24. package/dist/cli/commands/build.js.map +1 -0
  25. package/dist/cli/commands/curate.d.ts +32 -0
  26. package/dist/cli/commands/curate.js +154 -0
  27. package/dist/cli/commands/curate.js.map +1 -0
  28. package/dist/cli/commands/doctor.d.ts +16 -0
  29. package/dist/cli/commands/doctor.js +92 -0
  30. package/dist/cli/commands/doctor.js.map +1 -0
  31. package/dist/cli/commands/ingest.d.ts +25 -0
  32. package/dist/cli/commands/ingest.js +121 -0
  33. package/dist/cli/commands/ingest.js.map +1 -0
  34. package/dist/cli/commands/install.d.ts +16 -0
  35. package/dist/cli/commands/install.js +82 -0
  36. package/dist/cli/commands/install.js.map +1 -0
  37. package/dist/cli/commands/pull.d.ts +24 -0
  38. package/dist/cli/commands/pull.js +104 -0
  39. package/dist/cli/commands/pull.js.map +1 -0
  40. package/dist/cli/commands/push.d.ts +28 -0
  41. package/dist/cli/commands/push.js +164 -0
  42. package/dist/cli/commands/push.js.map +1 -0
  43. package/dist/cli/commands/rollup.d.ts +21 -0
  44. package/dist/cli/commands/rollup.js +118 -0
  45. package/dist/cli/commands/rollup.js.map +1 -0
  46. package/dist/cli/commands/setup.d.ts +7 -0
  47. package/dist/cli/commands/setup.js +43 -0
  48. package/dist/cli/commands/setup.js.map +1 -0
  49. package/dist/cli/commands/sync.d.ts +15 -0
  50. package/dist/cli/commands/sync.js +63 -0
  51. package/dist/cli/commands/sync.js.map +1 -0
  52. package/dist/cli/commands/watch.d.ts +17 -0
  53. package/dist/cli/commands/watch.js +87 -0
  54. package/dist/cli/commands/watch.js.map +1 -0
  55. package/dist/cli/config.d.ts +29 -0
  56. package/dist/cli/config.js +52 -0
  57. package/dist/cli/config.js.map +1 -0
  58. package/dist/cli/extract.d.ts +101 -0
  59. package/dist/cli/extract.js +216 -0
  60. package/dist/cli/extract.js.map +1 -0
  61. package/dist/cli/ingest.d.ts +48 -0
  62. package/dist/cli/ingest.js +74 -0
  63. package/dist/cli/ingest.js.map +1 -0
  64. package/dist/cli/ledger.d.ts +44 -0
  65. package/dist/cli/ledger.js +67 -0
  66. package/dist/cli/ledger.js.map +1 -0
  67. package/dist/cli/llm.d.ts +21 -0
  68. package/dist/cli/llm.js +138 -0
  69. package/dist/cli/llm.js.map +1 -0
  70. package/dist/cli/parse.d.ts +13 -0
  71. package/dist/cli/parse.js +188 -0
  72. package/dist/cli/parse.js.map +1 -0
  73. package/dist/cli/run-explore.d.ts +56 -0
  74. package/dist/cli/run-explore.js +229 -0
  75. package/dist/cli/run-explore.js.map +1 -0
  76. package/dist/cli/summarize.d.ts +15 -0
  77. package/dist/cli/summarize.js +49 -0
  78. package/dist/cli/summarize.js.map +1 -0
  79. package/dist/cli/uninstall.d.ts +6 -0
  80. package/dist/cli/uninstall.js +277 -0
  81. package/dist/cli/uninstall.js.map +1 -0
  82. package/dist/cli/walk.d.ts +13 -0
  83. package/dist/cli/walk.js +62 -0
  84. package/dist/cli/walk.js.map +1 -0
  85. package/dist/cli/walker.d.ts +14 -0
  86. package/dist/cli/walker.js +120 -0
  87. package/dist/cli/walker.js.map +1 -0
  88. package/dist/hooks/pre-compact.d.ts +15 -0
  89. package/dist/hooks/pre-compact.js +127 -0
  90. package/dist/hooks/pre-compact.js.map +1 -0
  91. package/dist/hooks/pre-tool-use.d.ts +15 -0
  92. package/dist/hooks/pre-tool-use.js +25 -0
  93. package/dist/hooks/pre-tool-use.js.map +1 -0
  94. package/dist/hooks/session-end.d.ts +21 -0
  95. package/dist/hooks/session-end.js +186 -0
  96. package/dist/hooks/session-end.js.map +1 -0
  97. package/dist/hooks/session-start.d.ts +15 -0
  98. package/dist/hooks/session-start.js +233 -0
  99. package/dist/hooks/session-start.js.map +1 -0
  100. package/dist/hooks/turn-end-post.d.ts +17 -0
  101. package/dist/hooks/turn-end-post.js +66 -0
  102. package/dist/hooks/turn-end-post.js.map +1 -0
  103. package/dist/hooks/turn-end.d.ts +29 -0
  104. package/dist/hooks/turn-end.js +295 -0
  105. package/dist/hooks/turn-end.js.map +1 -0
  106. package/dist/index.d.ts +24 -0
  107. package/dist/index.js +1547 -0
  108. package/dist/index.js.map +1 -0
  109. package/dist/init.d.ts +45 -0
  110. package/dist/init.js +839 -0
  111. package/dist/init.js.map +1 -0
  112. package/dist/lib/active-project.d.ts +14 -0
  113. package/dist/lib/active-project.js +65 -0
  114. package/dist/lib/active-project.js.map +1 -0
  115. package/dist/lib/buffer.d.ts +34 -0
  116. package/dist/lib/buffer.js +79 -0
  117. package/dist/lib/buffer.js.map +1 -0
  118. package/dist/scripts/compile.d.ts +25 -0
  119. package/dist/scripts/compile.js +185 -0
  120. package/dist/scripts/compile.js.map +1 -0
  121. package/dist/scripts/config.d.ts +30 -0
  122. package/dist/scripts/config.js +68 -0
  123. package/dist/scripts/config.js.map +1 -0
  124. package/dist/scripts/flush.d.ts +23 -0
  125. package/dist/scripts/flush.js +230 -0
  126. package/dist/scripts/flush.js.map +1 -0
  127. package/dist/scripts/lint.d.ts +21 -0
  128. package/dist/scripts/lint.js +242 -0
  129. package/dist/scripts/lint.js.map +1 -0
  130. package/dist/scripts/utils.d.ts +43 -0
  131. package/dist/scripts/utils.js +165 -0
  132. package/dist/scripts/utils.js.map +1 -0
  133. package/package.json +74 -0
  134. package/scripts/postinstall.mjs +56 -0
package/dist/index.js ADDED
@@ -0,0 +1,1547 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Ricord MCP Server — persistent memory for AI coding assistants.
4
+ *
5
+ * Install: npx ricord-mcp --setup --client claude --api-key YOUR_KEY
6
+ *
7
+ * Core tools (9):
8
+ * ricord_ingest — Save (atomic, type=fact|preference|...) OR ingest artifacts (kind=text|url|pdf|...)
9
+ * ricord_search — Retrieve relevant knowledge (hybrid search)
10
+ * ricord_correct — Update existing knowledge
11
+ * ricord_forget — Remove knowledge items
12
+ * ricord_list — Browse stored knowledge
13
+ * ricord_walk — Walk a project, return structured per-dir bundles for the agent to summarize
14
+ * ricord_ingest — Ingest a heavyweight artifact (text/url/file/image/audio/video) into the KG
15
+ * ricord_procedure — Manage SOPs / playbooks / prompts / soul / preferences
16
+ * ricord_graph — Explore knowledge graph
17
+ * ricord_usage — Check credit balance
18
+ *
19
+ * Modes:
20
+ * --mode auto (default) Auto-save: LLM proactively saves knowledge
21
+ * --mode manual User must explicitly invoke tools
22
+ * --mode hybrid Auto-extract but mark as pending for review
23
+ */
24
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
25
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
+ import { z } from "zod";
27
+ import { execSync, spawnSync } from "node:child_process";
28
+ import { basename, join, dirname } from "node:path";
29
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import { getCurrentSession } from "./lib/buffer.js";
32
+ import { getActiveProject } from "./lib/active-project.js";
33
+ import { createRequire } from "node:module";
34
+ const _require = createRequire(import.meta.url);
35
+ const VERSION = _require("../package.json").version;
36
+ // ── Credentials storage ─────────────────────────────────────────────
37
+ const CREDENTIALS_DIR = join(homedir(), ".ricord");
38
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json");
39
+ function loadCredentials() {
40
+ try {
41
+ if (existsSync(CREDENTIALS_FILE)) {
42
+ return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf8"));
43
+ }
44
+ }
45
+ catch { }
46
+ return null;
47
+ }
48
+ function saveCredentials(apiKey, apiBase) {
49
+ mkdirSync(CREDENTIALS_DIR, { recursive: true });
50
+ const creds = {
51
+ api_key: apiKey,
52
+ api_base: apiBase,
53
+ created_at: new Date().toISOString(),
54
+ };
55
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
56
+ }
57
+ // ── CLI args ──────────────────────────────────────────────────────────
58
+ const args = process.argv.slice(2);
59
+ function getArg(name, shortAlias) {
60
+ const candidates = [`--${name}`];
61
+ if (shortAlias)
62
+ candidates.push(`-${shortAlias}`);
63
+ // Also accept `-<name>` for single-letter names (e.g. -k)
64
+ if (name.length === 1)
65
+ candidates.push(`-${name}`);
66
+ for (const flag of candidates) {
67
+ const idx = args.indexOf(flag);
68
+ if (idx !== -1)
69
+ return args[idx + 1];
70
+ }
71
+ return undefined;
72
+ }
73
+ function hasFlag(name, shortAlias) {
74
+ if (args.includes(`--${name}`))
75
+ return true;
76
+ if (shortAlias && args.includes(`-${shortAlias}`))
77
+ return true;
78
+ if (name.length === 1 && args.includes(`-${name}`))
79
+ return true;
80
+ return false;
81
+ }
82
+ // ── Colors ──────────────────────────────────────────────────────────
83
+ const c = (code) => (s) => `\x1b[${code}m${s}\x1b[0m`;
84
+ const bold = c("1");
85
+ const dim = c("2");
86
+ const green = c("0;32");
87
+ const cyan = c("0;36");
88
+ const yellow = c("1;33");
89
+ const red = c("0;31");
90
+ const cliInfo = (msg) => console.log(`${cyan(" [*]")} ${msg}`);
91
+ const cliOk = (msg) => console.log(`${green(" [+]")} ${msg}`);
92
+ const cliWarn = (msg) => console.log(`${yellow(" [!]")} ${msg}`);
93
+ const cliErr = (msg) => console.log(`${red(" [-]")} ${msg}`);
94
+ const SITE_BASE = "https://ricord.ai";
95
+ const API_BASE_DEFAULT = "https://api.ricord.ai";
96
+ function openBrowser(url) {
97
+ // Use spawnSync with separate args to avoid shell injection via crafted URLs.
98
+ try {
99
+ if (process.platform === "darwin") {
100
+ spawnSync("open", [url], { stdio: "ignore" });
101
+ }
102
+ else if (process.platform === "win32") {
103
+ spawnSync("cmd", ["/c", "start", "", url], { stdio: "ignore", shell: false });
104
+ }
105
+ else {
106
+ spawnSync("xdg-open", [url], { stdio: "ignore" });
107
+ }
108
+ return true;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ async function prompt(question) {
115
+ const { createInterface } = await import("node:readline");
116
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
117
+ return new Promise((resolve) => {
118
+ rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
119
+ });
120
+ }
121
+ async function validateKey(apiKey, apiBase) {
122
+ try {
123
+ const res = await fetch(`${apiBase}/v1/usage`, {
124
+ headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": `ricord-mcp/${VERSION}` },
125
+ });
126
+ if (!res.ok)
127
+ return null;
128
+ const data = await res.json();
129
+ return { tier: data.tier ?? "unknown", requests_today: data.today?.requests ?? 0 };
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ async function startAuthServer(apiBase) {
136
+ const { createServer } = await import("node:http");
137
+ const { randomBytes } = await import("node:crypto");
138
+ const state = randomBytes(16).toString("hex");
139
+ const LOGO_URL = "https://ricord.ai/logo.png";
140
+ const SUCCESS_HTML = `<!DOCTYPE html>
141
+ <html><head><meta charset="utf-8"><title>Ricord — Authenticated</title>
142
+ <style>
143
+ *{margin:0;padding:0;box-sizing:border-box}
144
+ body{font-family:'Geist','Inter',system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#0a0a0a;color:#fafafa}
145
+ .card{text-align:center;padding:3rem 2.5rem;border:1px solid #1a1a1a;border-radius:16px;background:#111;max-width:400px;width:90%}
146
+ .logo{width:64px;height:64px;margin:0 auto 1.5rem;border-radius:12px}
147
+ h2{font-size:1.25rem;font-weight:600;margin-bottom:0.5rem;letter-spacing:-0.02em}
148
+ p{font-size:0.875rem;color:#888;line-height:1.5}
149
+ .brand{font-size:0.75rem;color:#444;margin-top:1.5rem;letter-spacing:0.05em;text-transform:uppercase}
150
+ </style></head>
151
+ <body><div class="card">
152
+ <img src="${LOGO_URL}" alt="Ricord" class="logo" />
153
+ <h2>Authenticated</h2>
154
+ <p>You can close this tab and return to your terminal.</p>
155
+ <p class="brand">Ricord</p>
156
+ </div></body></html>`;
157
+ const ERROR_HTML = (msg) => `<!DOCTYPE html>
158
+ <html><head><meta charset="utf-8"><title>Ricord — Error</title>
159
+ <style>
160
+ *{margin:0;padding:0;box-sizing:border-box}
161
+ body{font-family:'Geist','Inter',system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#0a0a0a;color:#fafafa}
162
+ .card{text-align:center;padding:3rem 2.5rem;border:1px solid #1a1a1a;border-radius:16px;background:#111;max-width:400px;width:90%}
163
+ .logo{width:64px;height:64px;margin:0 auto 1.5rem;border-radius:12px}
164
+ h2{font-size:1.25rem;font-weight:600;margin-bottom:0.5rem;letter-spacing:-0.02em}
165
+ p{font-size:0.875rem;color:#888;line-height:1.5}
166
+ .error{color:#f87171}
167
+ .brand{font-size:0.75rem;color:#444;margin-top:1.5rem;letter-spacing:0.05em;text-transform:uppercase}
168
+ </style></head>
169
+ <body><div class="card">
170
+ <img src="${LOGO_URL}" alt="Ricord" class="logo" />
171
+ <h2>Authentication Failed</h2>
172
+ <p class="error">${msg}</p>
173
+ <p class="brand">Ricord</p>
174
+ </div></body></html>`;
175
+ return new Promise((resolve, reject) => {
176
+ const server = createServer((req, res) => {
177
+ const url = new URL(req.url || "/", "http://localhost");
178
+ if (url.pathname !== "/callback") {
179
+ res.writeHead(404);
180
+ res.end("Not found");
181
+ return;
182
+ }
183
+ const token = url.searchParams.get("token");
184
+ const returnedState = url.searchParams.get("state");
185
+ if (returnedState !== state) {
186
+ res.writeHead(400, { "Content-Type": "text/html" });
187
+ res.end(ERROR_HTML("State mismatch. Please try again."));
188
+ return;
189
+ }
190
+ if (!token) {
191
+ res.writeHead(400, { "Content-Type": "text/html" });
192
+ res.end(ERROR_HTML("No token received. Please try again."));
193
+ return;
194
+ }
195
+ res.writeHead(200, { "Content-Type": "text/html" });
196
+ res.end(SUCCESS_HTML);
197
+ cleanup();
198
+ resolve(token);
199
+ });
200
+ const timer = setTimeout(() => {
201
+ cleanup();
202
+ reject(new Error("Browser login timed out (120s). Use `ricord-mcp login -k <key>` instead."));
203
+ }, 120_000);
204
+ function cleanup() { clearTimeout(timer); server.close(); }
205
+ server.listen(0, "127.0.0.1", () => {
206
+ const addr = server.address();
207
+ if (!addr || typeof addr === "string") {
208
+ cleanup();
209
+ reject(new Error("Failed to start auth server"));
210
+ return;
211
+ }
212
+ const port = addr.port;
213
+ const authUrl = `${SITE_BASE}/cli-auth?port=${port}&state=${state}`;
214
+ console.log("");
215
+ cliInfo("Opening browser for authentication...");
216
+ const opened = openBrowser(authUrl);
217
+ if (!opened) {
218
+ console.log("");
219
+ cliWarn("Could not open browser. Visit this URL manually:");
220
+ console.log(` ${dim(authUrl)}`);
221
+ }
222
+ else {
223
+ console.log(` ${dim("Waiting for browser login...")}`);
224
+ }
225
+ console.log("");
226
+ cliInfo(`Or paste your API key with: ${bold("ricord-mcp login -k <key>")}`);
227
+ });
228
+ server.on("error", (e) => { cleanup(); reject(e); });
229
+ });
230
+ }
231
+ // ── Version / help ──────────────────────────────────────────────────
232
+ if (args[0] === "--version" || args[0] === "-v" || args[0] === "version") {
233
+ console.log(`ricord-mcp ${VERSION}`);
234
+ process.exit(0);
235
+ }
236
+ if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
237
+ console.log(`\n${bold("ricord-mcp")} ${VERSION} — persistent memory for AI agents\n`);
238
+ console.log(` ${bold("Commands:")}`);
239
+ console.log(` login [-k <key>] [--no-browser] Authenticate (browser OAuth by default, persists until revoked)`);
240
+ console.log(` logout Remove stored credentials`);
241
+ console.log(` whoami Show current user + credit balance`);
242
+ console.log(` install <client> Configure Claude Code / Cursor / Windsurf / VS Code`);
243
+ console.log(` init Retroactively index Claude Code history`);
244
+ console.log(` --setup [--client <c>] Install MCP server (auto-detects agents if --client omitted)`);
245
+ console.log(` --version Print version`);
246
+ console.log(` ${bold("Run as MCP server:")}`);
247
+ console.log(` ricord-mcp [--api-key <k>] [--mode auto|manual|hybrid]`);
248
+ console.log(` ${bold("Docs:")} https://ricord.ai/docs\n`);
249
+ process.exit(0);
250
+ }
251
+ // ── CLI login mode ──────────────────────────────────────────────────
252
+ if (args[0] === "login") {
253
+ const apiBase = getArg("api-base") || process.env.RICORD_API_BASE || API_BASE_DEFAULT;
254
+ const directKey = getArg("api-key", "k");
255
+ console.log(`\n${bold("Ricord Login")}\n`);
256
+ // ── Direct key mode (-k flag) ────────────────────────
257
+ if (directKey) {
258
+ cliInfo("Validating API key...");
259
+ const result = await validateKey(directKey, apiBase);
260
+ if (!result) {
261
+ cliErr("Invalid API key.");
262
+ process.exit(1);
263
+ }
264
+ saveCredentials(directKey, apiBase !== API_BASE_DEFAULT ? apiBase : undefined);
265
+ cliOk(`Logged in! (tier: ${bold(result.tier)}, ${result.requests_today} requests today)`);
266
+ console.log("");
267
+ cliInfo(`Run ${bold("ricord-mcp install claude-code")} to configure your AI tools.`);
268
+ console.log("");
269
+ process.exit(0);
270
+ }
271
+ // ── Check for existing key ───────────────────────────
272
+ const existing = loadCredentials();
273
+ if (existing && !args.includes("--force")) {
274
+ const masked = existing.api_key.slice(0, 12) + "..." + existing.api_key.slice(-4);
275
+ cliInfo(`Found existing credentials: ${masked}`);
276
+ const result = await validateKey(existing.api_key, apiBase);
277
+ if (result) {
278
+ cliOk(`Already logged in (tier: ${bold(result.tier)}, ${result.requests_today} requests today)`);
279
+ process.exit(0);
280
+ }
281
+ cliWarn("Stored key is no longer valid. Re-authenticating...");
282
+ }
283
+ // ── Browser auth (default) ───────────────────────────
284
+ if (!args.includes("--no-browser")) {
285
+ try {
286
+ const apiKey = await startAuthServer(apiBase);
287
+ cliInfo("Validating...");
288
+ const result = await validateKey(apiKey, apiBase);
289
+ if (!result) {
290
+ cliErr("Received invalid API key from browser. Try again or use -k flag.");
291
+ process.exit(1);
292
+ }
293
+ saveCredentials(apiKey, apiBase !== API_BASE_DEFAULT ? apiBase : undefined);
294
+ console.log("");
295
+ cliOk(`Logged in! (tier: ${bold(result.tier)}, ${result.requests_today} requests today)`);
296
+ console.log("");
297
+ cliInfo(`Run ${bold("ricord-mcp install claude-code")} to configure your AI tools.`);
298
+ console.log("");
299
+ process.exit(0);
300
+ }
301
+ catch (e) {
302
+ cliWarn(e.message || "Browser auth failed.");
303
+ cliInfo("Falling back to manual key entry...");
304
+ console.log("");
305
+ }
306
+ }
307
+ // ── Manual key entry (fallback / --no-browser) ───────
308
+ console.log(` Get your API key at ${dim(`${SITE_BASE}/dashboard`)}\n`);
309
+ const apiKey = await prompt(" API key (rc_live_...): ");
310
+ if (!apiKey) {
311
+ cliErr("No API key provided.");
312
+ process.exit(1);
313
+ }
314
+ cliInfo("Validating...");
315
+ const result = await validateKey(apiKey, apiBase);
316
+ if (!result) {
317
+ cliErr("Invalid API key.");
318
+ process.exit(1);
319
+ }
320
+ saveCredentials(apiKey, apiBase !== API_BASE_DEFAULT ? apiBase : undefined);
321
+ console.log("");
322
+ cliOk(`Logged in! (tier: ${bold(result.tier)}, ${result.requests_today} requests today)`);
323
+ console.log("");
324
+ cliInfo(`Run ${bold("ricord-mcp install claude-code")} to configure your AI tools.`);
325
+ console.log("");
326
+ process.exit(0);
327
+ }
328
+ // ── CLI logout mode ─────────────────────────────────────────────────
329
+ if (args[0] === "logout") {
330
+ const { unlinkSync } = await import("node:fs");
331
+ if (existsSync(CREDENTIALS_FILE)) {
332
+ unlinkSync(CREDENTIALS_FILE);
333
+ cliOk("Logged out. Credentials removed.");
334
+ }
335
+ else {
336
+ cliInfo("Not logged in.");
337
+ }
338
+ process.exit(0);
339
+ }
340
+ // ── CLI whoami mode ─────────────────────────────────────────────────
341
+ if (args[0] === "whoami") {
342
+ const creds = loadCredentials();
343
+ if (!creds) {
344
+ console.log("Not logged in. Run: ricord-mcp login");
345
+ process.exit(1);
346
+ }
347
+ const masked = creds.api_key.slice(0, 8) + "..." + creds.api_key.slice(-4);
348
+ const apiBase = creds.api_base || "https://api.ricord.ai";
349
+ console.log(`Key: ${masked}`);
350
+ console.log(`API: ${apiBase}`);
351
+ console.log(`Stored: ${CREDENTIALS_FILE}`);
352
+ try {
353
+ const res = await fetch(`${apiBase}/v1/usage`, {
354
+ headers: {
355
+ Authorization: `Bearer ${creds.api_key}`,
356
+ "Content-Type": "application/json",
357
+ "User-Agent": `ricord-mcp/${VERSION}`,
358
+ },
359
+ });
360
+ if (res.ok) {
361
+ const usage = await res.json();
362
+ const today = usage.today || {};
363
+ console.log(`Tier: ${usage.tier} | Today: ${today.requests ?? 0} requests`);
364
+ }
365
+ }
366
+ catch { }
367
+ process.exit(0);
368
+ }
369
+ // ── CLI init mode ──────────────────────────────────────────────────
370
+ // `ricord-mcp init` — retroactively process Claude Code conversations
371
+ if (args[0] === "init") {
372
+ // Delegate to the init script, passing remaining args
373
+ const initPath = join(dirname(new URL(import.meta.url).pathname), "init.js");
374
+ if (!existsSync(initPath)) {
375
+ console.error("Init script not found. Run `npm run build` first.");
376
+ process.exit(1);
377
+ }
378
+ const { fork } = await import("node:child_process");
379
+ const child = fork(initPath, args.slice(1), { stdio: "inherit" });
380
+ child.on("exit", (code) => process.exit(code || 0));
381
+ // Block the parent from continuing to MCP server setup
382
+ await new Promise(() => { });
383
+ }
384
+ // ── CLI install mode ────────────────────────────────────────────────
385
+ // `ricord-mcp install claude-code` (uses stored credentials)
386
+ if (args[0] === "install") {
387
+ const clientName = args[1];
388
+ if (!clientName) {
389
+ console.error("Usage: ricord-mcp install <client>");
390
+ console.error("\nSupported clients: claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini-cli");
391
+ process.exit(1);
392
+ }
393
+ // Get API key from args, env, stored credentials, or browser OAuth
394
+ let apiKey = getArg("api-key") || process.env.RICORD_API_KEY || loadCredentials()?.api_key || "";
395
+ const mode = getArg("mode") || "auto";
396
+ if (!apiKey) {
397
+ cliInfo("No API key found — opening browser to log in...");
398
+ try {
399
+ const apiBase = getArg("api-base") || process.env.RICORD_API_BASE || "https://api.ricord.ai";
400
+ apiKey = await startAuthServer(apiBase);
401
+ const result = await validateKey(apiKey, apiBase);
402
+ if (!result) {
403
+ cliErr("Received invalid API key from browser. Try again or use --api-key.");
404
+ process.exit(1);
405
+ }
406
+ saveCredentials(apiKey, apiBase !== "https://api.ricord.ai" ? apiBase : undefined);
407
+ cliInfo("Logged in successfully.");
408
+ }
409
+ catch (e) {
410
+ cliErr(e.message || "Browser auth failed. Use --api-key <YOUR_KEY> instead.");
411
+ process.exit(1);
412
+ }
413
+ }
414
+ // Resolve the absolute path to this script for local installs
415
+ const scriptPath = join(dirname(new URL(import.meta.url).pathname), "index.js");
416
+ const useNode = existsSync(scriptPath);
417
+ const mcpArgs = useNode
418
+ ? [scriptPath, "--api-key", apiKey, "--mode", mode]
419
+ : ["ricord-mcp", "--api-key", apiKey, "--mode", mode];
420
+ const mcpCommand = useNode ? "node" : "npx";
421
+ const mcpConfig = { command: mcpCommand, args: mcpArgs };
422
+ const clients = {
423
+ "claude-code": {
424
+ path: null,
425
+ key: "ricord",
426
+ wrap: () => ({}),
427
+ cmd: `claude mcp add -s user ricord -- ${mcpCommand} ${mcpArgs.join(" ")}`,
428
+ },
429
+ "claude-desktop": {
430
+ path: join(process.platform === "win32"
431
+ ? join(process.env.APPDATA || "", "Claude")
432
+ : process.platform === "linux"
433
+ ? join(homedir(), ".config", "Claude")
434
+ : join(homedir(), "Library", "Application Support", "Claude"), "claude_desktop_config.json"),
435
+ key: "mcpServers",
436
+ wrap: (entry) => ({ mcpServers: { ricord: entry } }),
437
+ },
438
+ cursor: {
439
+ path: join(process.cwd(), ".cursor", "mcp.json"),
440
+ key: "mcpServers",
441
+ wrap: (entry) => ({ mcpServers: { ricord: entry } }),
442
+ },
443
+ windsurf: {
444
+ path: join(homedir(), ".codeium", "windsurf", "mcp_config.json"),
445
+ key: "mcpServers",
446
+ wrap: (entry) => ({ mcpServers: { ricord: entry } }),
447
+ },
448
+ vscode: {
449
+ path: join(process.cwd(), ".vscode", "mcp.json"),
450
+ key: "servers",
451
+ wrap: (entry) => ({ servers: { ricord: entry } }),
452
+ },
453
+ codex: {
454
+ path: null,
455
+ key: "ricord",
456
+ wrap: () => ({}),
457
+ cmd: `codex mcp add ricord -- ${mcpCommand} ${mcpArgs.join(" ")}`,
458
+ },
459
+ "gemini-cli": {
460
+ path: join(homedir(), ".gemini", "settings.json"),
461
+ key: "mcpServers",
462
+ wrap: (entry) => ({ mcpServers: { ricord: entry } }),
463
+ },
464
+ };
465
+ clients["claude"] = clients["claude-code"];
466
+ clients["desktop"] = clients["claude-desktop"];
467
+ clients["code"] = clients["claude-code"];
468
+ clients["vs-code"] = clients["vscode"];
469
+ clients["gemini"] = clients["gemini-cli"];
470
+ const client = clients[clientName.toLowerCase()];
471
+ if (!client) {
472
+ console.error(`Unknown client: ${clientName}`);
473
+ console.error("Supported: claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini-cli");
474
+ process.exit(1);
475
+ }
476
+ if (client.cmd) {
477
+ // Log the command without embedding the API key to avoid key exposure in logs/history.
478
+ const maskedKey = apiKey.slice(0, 8) + "..." + apiKey.slice(-4);
479
+ console.log(`Running: ${client.cmd.replace(apiKey, maskedKey)}`);
480
+ try {
481
+ execSync(client.cmd, { stdio: "inherit" });
482
+ console.log("\n✓ Ricord MCP server added to Claude Code. Restart Claude Code to activate.");
483
+ process.exit(0);
484
+ }
485
+ catch (err) {
486
+ // iter30 2026-04-24: distinguish "claude CLI missing" (ENOENT) from
487
+ // non-zero exit (often idempotent re-add, e.g. "already exists").
488
+ // Post-check via `claude mcp list` before declaring failure.
489
+ const code = err.code;
490
+ if (code === "ENOENT") {
491
+ console.error("\nClaude CLI not found. Install Claude Code first: https://claude.com/claude-code");
492
+ console.error("Manual after install: " + client.cmd.replace(apiKey, maskedKey));
493
+ process.exit(1);
494
+ }
495
+ try {
496
+ const list = execSync("claude mcp list", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
497
+ if (/ricord/i.test(list)) {
498
+ console.log("\n✓ Ricord MCP server is already configured in Claude Code. Restart to activate.");
499
+ process.exit(0);
500
+ }
501
+ }
502
+ catch { /* fall through to failure message */ }
503
+ console.error("\nSetup command returned non-zero. Run `claude mcp list` to verify, or re-run:");
504
+ console.error(" " + client.cmd.replace(apiKey, maskedKey));
505
+ process.exit(1);
506
+ }
507
+ }
508
+ const configPath = client.path;
509
+ const configDir = dirname(configPath);
510
+ let existingConfig = {};
511
+ if (existsSync(configPath)) {
512
+ try {
513
+ existingConfig = JSON.parse(readFileSync(configPath, "utf8"));
514
+ }
515
+ catch { }
516
+ }
517
+ const servers = existingConfig[client.key] || {};
518
+ servers["ricord"] = mcpConfig;
519
+ existingConfig[client.key] = servers;
520
+ mkdirSync(configDir, { recursive: true });
521
+ writeFileSync(configPath, JSON.stringify(existingConfig, null, 2) + "\n");
522
+ console.log(`Ricord MCP server configured for ${clientName}`);
523
+ console.log(`Config: ${configPath}`);
524
+ console.log(`Mode: ${mode}`);
525
+ console.log(`\nRestart your editor to activate.`);
526
+ process.exit(0);
527
+ }
528
+ // ── CLI setup mode (legacy) ─────────────────────────────────────────
529
+ // `npx ricord-mcp --setup --client claude --api-key KEY`
530
+ const SETUP_MODE = args.includes("--setup");
531
+ const CLIENT_ARG = getArg("client");
532
+ if (SETUP_MODE) {
533
+ let apiKey = getArg("api-key") || process.env.RICORD_API_KEY || "";
534
+ const mode = getArg("mode") || "auto";
535
+ // No --api-key? Try cached credentials, then fall back to browser OAuth
536
+ if (!apiKey) {
537
+ const cached = loadCredentials();
538
+ if (cached?.api_key) {
539
+ apiKey = cached.api_key;
540
+ cliInfo("Using cached credentials.");
541
+ }
542
+ else {
543
+ cliInfo("No API key found — opening browser to log in...");
544
+ try {
545
+ const apiBase = getArg("api-base") || process.env.RICORD_API_BASE || "https://api.ricord.ai";
546
+ apiKey = await startAuthServer(apiBase);
547
+ const result = await validateKey(apiKey, apiBase);
548
+ if (!result) {
549
+ cliErr("Received invalid API key from browser. Try again or use --api-key.");
550
+ process.exit(1);
551
+ }
552
+ saveCredentials(apiKey, apiBase !== "https://api.ricord.ai" ? apiBase : undefined);
553
+ cliInfo("Logged in successfully.");
554
+ }
555
+ catch (e) {
556
+ cliErr(e.message || "Browser auth failed. Use --api-key <YOUR_KEY> instead.");
557
+ process.exit(1);
558
+ }
559
+ }
560
+ }
561
+ const mcpArgs = ["ricord-mcp", "--api-key", apiKey, "--mode", mode];
562
+ const mcpConfig = { command: "npx", args: mcpArgs };
563
+ const clients = {
564
+ "claude-code": {
565
+ path: null,
566
+ key: "ricord",
567
+ wrap: () => ({}),
568
+ cmd: `claude mcp add -s user ricord -- npx -y ricord-mcp --api-key ${apiKey} --mode ${mode}`,
569
+ },
570
+ "claude-desktop": {
571
+ path: join(process.platform === "win32"
572
+ ? join(process.env.APPDATA || "", "Claude")
573
+ : process.platform === "linux"
574
+ ? join(homedir(), ".config", "Claude")
575
+ : join(homedir(), "Library", "Application Support", "Claude"), "claude_desktop_config.json"),
576
+ key: "mcpServers",
577
+ wrap: (entry) => ({ mcpServers: { ricord: entry } }),
578
+ },
579
+ cursor: {
580
+ path: join(process.cwd(), ".cursor", "mcp.json"),
581
+ key: "mcpServers",
582
+ wrap: (entry) => ({ mcpServers: { ricord: entry } }),
583
+ },
584
+ windsurf: {
585
+ path: join(homedir(), ".codeium", "windsurf", "mcp_config.json"),
586
+ key: "mcpServers",
587
+ wrap: (entry) => ({ mcpServers: { ricord: entry } }),
588
+ },
589
+ vscode: {
590
+ path: join(process.cwd(), ".vscode", "mcp.json"),
591
+ key: "servers",
592
+ wrap: (entry) => ({ servers: { ricord: entry } }),
593
+ },
594
+ codex: {
595
+ path: null,
596
+ key: "ricord",
597
+ wrap: () => ({}),
598
+ cmd: `codex mcp add ricord -- npx -y ricord-mcp --api-key ${apiKey} --mode ${mode}`,
599
+ },
600
+ "gemini-cli": {
601
+ path: join(homedir(), ".gemini", "settings.json"),
602
+ key: "mcpServers",
603
+ wrap: (entry) => ({ mcpServers: { ricord: entry } }),
604
+ },
605
+ };
606
+ clients["claude"] = clients["claude-code"];
607
+ clients["desktop"] = clients["claude-desktop"];
608
+ clients["code"] = clients["claude-code"];
609
+ clients["vs-code"] = clients["vscode"];
610
+ clients["gemini"] = clients["gemini-cli"];
611
+ // ── Auto-detect installed coding agents ──────────────────────────
612
+ // When --client is omitted (or set to auto/all), detect which agents
613
+ // are installed on this machine and install into each. Project-local
614
+ // clients (cursor, vscode) only count when their dir already exists
615
+ // in cwd, so running --setup from a random dir doesn't litter.
616
+ const hasBin = (name) => {
617
+ const r = spawnSync(process.platform === "win32" ? "where" : "which", [name], { stdio: "ignore" });
618
+ return r.status === 0;
619
+ };
620
+ const detectInstalled = () => {
621
+ const found = [];
622
+ if (hasBin("claude"))
623
+ found.push("claude-code");
624
+ const desktopCfg = process.platform === "win32"
625
+ ? join(process.env.APPDATA || "", "Claude")
626
+ : process.platform === "linux"
627
+ ? join(homedir(), ".config", "Claude")
628
+ : join(homedir(), "Library", "Application Support", "Claude");
629
+ if (existsSync(desktopCfg))
630
+ found.push("claude-desktop");
631
+ if (existsSync(join(homedir(), ".codeium", "windsurf")))
632
+ found.push("windsurf");
633
+ if (existsSync(join(process.cwd(), ".cursor")))
634
+ found.push("cursor");
635
+ if (existsSync(join(process.cwd(), ".vscode")))
636
+ found.push("vscode");
637
+ if (hasBin("codex") || existsSync(join(homedir(), ".codex")))
638
+ found.push("codex");
639
+ if (hasBin("gemini") || existsSync(join(homedir(), ".gemini")))
640
+ found.push("gemini-cli");
641
+ return found;
642
+ };
643
+ const targets = (() => {
644
+ const arg = CLIENT_ARG?.toLowerCase();
645
+ if (!arg || arg === "auto" || arg === "all") {
646
+ const detected = detectInstalled();
647
+ if (detected.length === 0) {
648
+ console.error("No supported coding agents detected.");
649
+ console.error("Install one of: Claude Code, Claude Desktop, Cursor, Windsurf, VS Code — then re-run.");
650
+ console.error("Or pass --client <name> explicitly: claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini-cli");
651
+ process.exit(1);
652
+ }
653
+ console.log(`Detected: ${detected.join(", ")}`);
654
+ return detected;
655
+ }
656
+ return [arg];
657
+ })();
658
+ let installed = 0;
659
+ let failed = 0;
660
+ for (const name of targets) {
661
+ const client = clients[name];
662
+ if (!client) {
663
+ console.error(`Unknown client: ${name} — skipping`);
664
+ failed++;
665
+ continue;
666
+ }
667
+ if (client.cmd) {
668
+ const maskedKey2 = apiKey.slice(0, 8) + "..." + apiKey.slice(-4);
669
+ console.log(`\n[${name}] Running: ${client.cmd.replace(apiKey, maskedKey2)}`);
670
+ try {
671
+ execSync(client.cmd, { stdio: "inherit" });
672
+ console.log(`✓ ${name}: configured. Restart to activate.`);
673
+ installed++;
674
+ }
675
+ catch (err) {
676
+ const code = err.code;
677
+ if (code === "ENOENT") {
678
+ console.error(`✗ ${name}: Claude CLI not found. Install Claude Code first: https://claude.com/claude-code`);
679
+ failed++;
680
+ continue;
681
+ }
682
+ try {
683
+ const list = execSync("claude mcp list", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
684
+ if (/ricord/i.test(list)) {
685
+ console.log(`✓ ${name}: already configured. Restart to activate.`);
686
+ installed++;
687
+ continue;
688
+ }
689
+ }
690
+ catch { /* fall through */ }
691
+ console.error(`✗ ${name}: setup returned non-zero. Re-run: ${client.cmd.replace(apiKey, maskedKey2)}`);
692
+ failed++;
693
+ }
694
+ continue;
695
+ }
696
+ const configPath = client.path;
697
+ const configDir = dirname(configPath);
698
+ let existing = {};
699
+ if (existsSync(configPath)) {
700
+ try {
701
+ existing = JSON.parse(readFileSync(configPath, "utf8"));
702
+ }
703
+ catch { }
704
+ }
705
+ const servers = existing[client.key] || {};
706
+ servers["ricord"] = mcpConfig;
707
+ existing[client.key] = servers;
708
+ mkdirSync(configDir, { recursive: true });
709
+ writeFileSync(configPath, JSON.stringify(existing, null, 2) + "\n");
710
+ console.log(`✓ ${name}: configured at ${configPath}`);
711
+ installed++;
712
+ }
713
+ console.log(`\nDone. ${installed} configured, ${failed} failed. Mode: ${mode}. Restart your editor(s) to activate.`);
714
+ process.exit(failed > 0 && installed === 0 ? 1 : 0);
715
+ }
716
+ // ── Setup hooks mode ────────────────────────────────────────────────
717
+ // `npx ricord-mcp --setup-hooks`
718
+ // Installs Claude Code hooks for automatic session capture + context injection.
719
+ if (args.includes("--setup-hooks")) {
720
+ const hooksDir = join(process.cwd(), ".claude");
721
+ const settingsPath = join(hooksDir, "settings.json");
722
+ const pkgRoot = join(dirname(new URL(import.meta.url).pathname), "..");
723
+ const distHooksDir = join(pkgRoot, "dist", "hooks");
724
+ // Resolve hook script paths — use tsx in dev, node dist/ in production
725
+ const isDevMode = process.argv[1]?.endsWith(".ts");
726
+ const runner = isDevMode ? "npx tsx" : "node";
727
+ const hookDir = isDevMode ? join(pkgRoot, "src", "hooks") : distHooksDir;
728
+ const ext = isDevMode ? ".ts" : ".js";
729
+ const hookSettings = {
730
+ hooks: {
731
+ SessionStart: [{
732
+ matcher: "",
733
+ hooks: [{
734
+ type: "command",
735
+ command: `${runner} ${join(hookDir, `session-start${ext}`)}`,
736
+ timeout: 15,
737
+ }],
738
+ }],
739
+ PreCompact: [{
740
+ matcher: "",
741
+ hooks: [{
742
+ type: "command",
743
+ command: `${runner} ${join(hookDir, `pre-compact${ext}`)}`,
744
+ timeout: 10,
745
+ }],
746
+ }],
747
+ SessionEnd: [{
748
+ matcher: "",
749
+ hooks: [{
750
+ type: "command",
751
+ command: `${runner} ${join(hookDir, `session-end${ext}`)}`,
752
+ timeout: 10,
753
+ }],
754
+ }],
755
+ Stop: [{
756
+ matcher: "",
757
+ hooks: [{
758
+ type: "command",
759
+ command: `${runner} ${join(hookDir, `turn-end${ext}`)}`,
760
+ timeout: 5,
761
+ }],
762
+ }],
763
+ },
764
+ };
765
+ // Merge with existing settings if present
766
+ let existing = {};
767
+ if (existsSync(settingsPath)) {
768
+ try {
769
+ existing = JSON.parse(readFileSync(settingsPath, "utf8"));
770
+ }
771
+ catch { }
772
+ }
773
+ // Merge hooks — preserve non-Ricord hooks if any
774
+ const existingHooks = (existing.hooks || {});
775
+ existing.hooks = { ...existingHooks, ...hookSettings.hooks };
776
+ mkdirSync(hooksDir, { recursive: true });
777
+ writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + "\n");
778
+ console.log(green("✓") + " Ricord hooks installed");
779
+ console.log(` Config: ${settingsPath}`);
780
+ console.log(` Hooks:`);
781
+ console.log(` SessionStart → loads your Ricord memories as context`);
782
+ console.log(` PreCompact → saves context before auto-compaction`);
783
+ console.log(` SessionEnd → captures conversation as episode memory`);
784
+ console.log(` Stop → ingests each Q&A pair in real time (server-coalesced)`);
785
+ console.log(`\n ${dim("Your conversations will now automatically build your knowledge base.")}`);
786
+ process.exit(0);
787
+ }
788
+ // ── Server config ────────────────────────────────────────────────────
789
+ const storedCreds = loadCredentials();
790
+ const API_KEY = getArg("api-key") || process.env.RICORD_API_KEY || storedCreds?.api_key || "";
791
+ const API_BASE = getArg("api-base") || process.env.RICORD_API_BASE || storedCreds?.api_base || "https://api.ricord.ai";
792
+ const MODE = (getArg("mode") || "auto");
793
+ const PROJECT_OVERRIDE = getArg("project") || process.env.RICORD_PROJECT || undefined;
794
+ // ── Git auto-detection ───────────────────────────────────────────────
795
+ function shellExec(cmd) {
796
+ try {
797
+ return execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
798
+ }
799
+ catch {
800
+ return "";
801
+ }
802
+ }
803
+ function detectGitContext() {
804
+ const remoteUrl = shellExec("git remote get-url origin");
805
+ let gitRepo = "";
806
+ let projectId = "";
807
+ if (remoteUrl) {
808
+ gitRepo = remoteUrl
809
+ .replace(/^git@/, "").replace(/\.git$/, "")
810
+ .replace(/:([^/])/, "/$1").replace(/^https?:\/\//, "");
811
+ const parts = gitRepo.split("/");
812
+ projectId = parts.length >= 3 ? parts.slice(1).join("/") : gitRepo;
813
+ }
814
+ if (!projectId)
815
+ projectId = basename(process.cwd());
816
+ const gitBranch = shellExec("git rev-parse --abbrev-ref HEAD");
817
+ return { projectId, gitRepo, gitBranch };
818
+ }
819
+ const gitContext = detectGitContext();
820
+ const PROJECT = PROJECT_OVERRIDE || gitContext.projectId;
821
+ const GIT_REPO = gitContext.gitRepo;
822
+ const GIT_BRANCH = gitContext.gitBranch;
823
+ // Resolution order per call: session pin (from ricord_set_project) → CLI/env override → git/cwd.
824
+ function currentProject() {
825
+ const sid = getCurrentSession();
826
+ if (sid) {
827
+ const pinned = getActiveProject(sid);
828
+ if (pinned !== null)
829
+ return pinned;
830
+ }
831
+ return PROJECT;
832
+ }
833
+ if (!API_KEY) {
834
+ console.error("Error: No API key found.");
835
+ console.error("Run `ricord-mcp login` to authenticate, or pass --api-key <YOUR_KEY>");
836
+ process.exit(1);
837
+ }
838
+ // ── API client ────────────────────────────────────────────────────────
839
+ async function api(method, path, body) {
840
+ const res = await fetch(`${API_BASE}${path}`, {
841
+ method,
842
+ headers: {
843
+ Authorization: `Bearer ${API_KEY}`,
844
+ "Content-Type": "application/json",
845
+ "User-Agent": `ricord-mcp/${VERSION}`,
846
+ },
847
+ body: body ? JSON.stringify(body) : undefined,
848
+ });
849
+ if (!res.ok) {
850
+ const text = await res.text();
851
+ // Truncate error body to avoid leaking verbose server internals to callers.
852
+ throw new Error(`Ricord API ${res.status}: ${text.slice(0, 200)}`);
853
+ }
854
+ return res.json();
855
+ }
856
+ function ok(text) { return { content: [{ type: "text", text }] }; }
857
+ function err(msg) { return { content: [{ type: "text", text: msg }], isError: true }; }
858
+ // ── MCP Server ────────────────────────────────────────────────────────
859
+ const SERVER_INSTRUCTIONS = `Ricord gives AI agents persistent memory across sessions.
860
+
861
+ Session start: call ricord_get_context once before any other Ricord tool. The returned procedures catalog lists available SOPs; the instructions block is the user's always-on preferences.
862
+
863
+ When the user states a new fact, preference, or decision, call ricord_save with the appropriate kind.
864
+
865
+ When the user references prior knowledge ("what did we decide about X", "how do we handle X", "like last time"), call ricord_recall first.
866
+
867
+ When the user corrects a saved memory, call ricord_recall to find the id, then call ricord_correct with that id. Do NOT use ricord_save to fix an existing memory — that creates a duplicate.
868
+
869
+ When the user explicitly says to delete something ("forget that", "remove that preference"), call ricord_recall to find the id, then call ricord_forget.
870
+
871
+ When the user's message matches a procedure trigger from ricord_get_context, call ricord_run_procedure with that procedure name.
872
+
873
+ When the user asks "how many credits do I have", "what plan am I on", or "what's my usage", call ricord_usage.
874
+
875
+ When the user asks about their knowledge graph shape, entity counts, or "what does my graph look like", call ricord_graph_stats.`;
876
+ const server = new McpServer({ name: "ricord", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });
877
+ // ═══════════════════════════════════════════════════════════════════════
878
+ // v2 tool surface — 8 tools, ordered by descending call frequency.
879
+ // Everyday (1–5): get_context, recall, save, correct, forget
880
+ // Occasional (6–8): run_procedure, usage, graph_stats
881
+ // ═══════════════════════════════════════════════════════════════════════
882
+ // ═══════════════════════════════════════════════════════════════════════
883
+ // 1. GET CONTEXT — session start, read-only, idempotent
884
+ // ═══════════════════════════════════════════════════════════════════════
885
+ server.tool("ricord_get_context", `Returns the user's always-on instruction block, active procedures catalog (names + triggers), and user preferences for this session.
886
+
887
+ When to call:
888
+ - At the very start of every conversation, before any other Ricord tool
889
+ - When you need to know what SOPs (procedures) are available for this user
890
+ - When you need the user's identity, preferences, or standing instructions
891
+
892
+ Do NOT call when:
893
+ - You need to retrieve past memories for a specific query (use ricord_recall instead)
894
+ - You have already called this tool in the current session
895
+
896
+ Returns: instructions string, top_procedures array (each with id, title, trigger), preferences object, active_projects array. Use the procedures list to decide whether to call ricord_run_procedure.`, {
897
+ project_id: z.string().optional().describe("Override the auto-detected project scope."),
898
+ }, {
899
+ title: "Get session context",
900
+ readOnlyHint: true,
901
+ destructiveHint: false,
902
+ idempotentHint: true,
903
+ openWorldHint: false,
904
+ }, async ({ project_id }) => {
905
+ try {
906
+ const proj = project_id || currentProject();
907
+ const qs = proj ? `?project_id=${encodeURIComponent(proj)}` : "";
908
+ const r = await api("GET", `/v1/user/boot-context${qs}`);
909
+ const lines = [];
910
+ if (r.instructions) {
911
+ lines.push("## Standing instructions");
912
+ lines.push(r.instructions.slice(0, 4000));
913
+ lines.push("");
914
+ }
915
+ if (r.preferences && Object.keys(r.preferences).length > 0) {
916
+ lines.push("## User preferences");
917
+ lines.push(JSON.stringify(r.preferences, null, 2));
918
+ lines.push("");
919
+ }
920
+ const procs = r.top_procedures || [];
921
+ if (procs.length > 0) {
922
+ lines.push("## Active procedures");
923
+ for (const p of procs) {
924
+ lines.push(` • [${p.kind}] ${p.title}${p.trigger ? ` — trigger: ${p.trigger}` : ""} (id: ${p.id})`);
925
+ }
926
+ lines.push("");
927
+ }
928
+ else {
929
+ lines.push("## Active procedures\n (none — skip ricord_run_procedure this session)");
930
+ lines.push("");
931
+ }
932
+ if (r.active_projects?.length) {
933
+ lines.push("## Active projects");
934
+ for (const p of r.active_projects)
935
+ lines.push(` • ${p.title} [${p.subtype}] — ${p.sources} sources`);
936
+ lines.push("");
937
+ }
938
+ if (r.counts) {
939
+ lines.push(`Counts: ${r.counts.pages} KB pages · ${r.counts.procedures} procedures`);
940
+ }
941
+ return ok(lines.join("\n") || "(no context configured yet)");
942
+ }
943
+ catch (e) {
944
+ return err(`ricord_get_context failed: ${e.message}`);
945
+ }
946
+ });
947
+ // ═══════════════════════════════════════════════════════════════════════
948
+ // 2. RECALL — semantic search, read-only, idempotent
949
+ // ═══════════════════════════════════════════════════════════════════════
950
+ server.tool("ricord_recall", `Search the user's stored knowledge for facts, preferences, decisions, and past observations relevant to a query.
951
+
952
+ When to call:
953
+ - The user references something they "told you before", "mentioned", or "discussed earlier"
954
+ - The user asks "what did we decide about X", "how do we handle X", "what was the reason for X"
955
+ - The user says "like last time", "like before", "previously", "remember when we"
956
+ - Any message using "we", "our", "my X" implying prior cross-session context
957
+ - Before writing a plan, design, or strategy (check prior iterations first)
958
+ - The user's request is ambiguous and prior context might disambiguate
959
+
960
+ Do NOT call when:
961
+ - The user is making a fresh statement with no reference to prior knowledge (use ricord_save instead)
962
+ - The information is already in the current conversation
963
+ - The user is asking about general world knowledge unrelated to themselves
964
+ - You only need to browse recent memories in order (use ricord_list via direct API)
965
+
966
+ Returns: ranked memory records with id, content, kind, created_at, relevance score. Cite ids when using results so the user can correct or forget them.`, {
967
+ query: z.string().describe("What to search for — describe the topic, decision, or question."),
968
+ k: z.number().int().positive().max(50).default(10).optional().describe("Max results to return (default 10)."),
969
+ }, {
970
+ title: "Search memory",
971
+ readOnlyHint: true,
972
+ destructiveHint: false,
973
+ idempotentHint: true,
974
+ openWorldHint: false,
975
+ }, async ({ query, k }) => {
976
+ if (!query || !query.trim())
977
+ return ok("No relevant knowledge found (empty query).");
978
+ try {
979
+ const body = {
980
+ query,
981
+ question_type: "multi-session",
982
+ };
983
+ if (k !== undefined)
984
+ body.limit = k;
985
+ const result = await api("POST", "/v1/memories/search", body);
986
+ const context = result.context || "";
987
+ if (!context || context.length === 0)
988
+ return ok("No relevant knowledge found.");
989
+ return ok(context);
990
+ }
991
+ catch (e) {
992
+ return err(`ricord_recall failed: ${e.message}`);
993
+ }
994
+ });
995
+ // ═══════════════════════════════════════════════════════════════════════
996
+ // 3. SAVE — create a memory, write, not idempotent
997
+ // ═══════════════════════════════════════════════════════════════════════
998
+ server.tool("ricord_save", `Creates a new memory record for a fact, preference, decision, reference, or anti-pattern.
999
+
1000
+ When to call:
1001
+ - The user states a preference or rule ("always use X", "never do Y", "from now on use X")
1002
+ - The user makes a decision ("we decided X", "we're going with X", "let's use Y")
1003
+ - The user corrects something about their setup or project ("actually it's X", "that's wrong — it's X")
1004
+ - The user says "save this", "remember this", "log this", "note this"
1005
+ - The user shares a non-obvious architectural fact, constraint, or coding convention
1006
+ - You discover something (an approach that failed, a root cause) that a future agent needs to know
1007
+
1008
+ Do NOT call when:
1009
+ - You need to update an existing memory — use ricord_correct with its id instead (ricord_save always creates a new record)
1010
+ - The user is asking about prior knowledge — use ricord_recall first
1011
+ - For bulk ingestion of URLs, files, or conversation exports, use the direct API
1012
+
1013
+ Returns: confirmation with the new memory id. Cite the id so the user can correct or forget it.`, {
1014
+ content: z.string().describe("The memory content to save. Be specific and concrete."),
1015
+ kind: z.enum(["fact", "preference", "decision", "reference", "anti-pattern", "observation"]).describe("Memory kind: fact=objective truth; preference=user style/taste; decision=choice made (include rationale); anti-pattern=what failed (include why); observation=passive note; reference=pointer to external resource. (For step-by-step SOPs, use POST /v1/procedures — memories are knowledge, procedures are runnable.)"),
1016
+ title: z.string().optional().describe("Short title for this memory (defaults to first 80 chars of content)."),
1017
+ tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
1018
+ }, {
1019
+ title: "Save to memory",
1020
+ readOnlyHint: false,
1021
+ destructiveHint: false,
1022
+ idempotentHint: false,
1023
+ openWorldHint: false,
1024
+ }, async ({ content, kind, title, tags }) => {
1025
+ if (!content || !content.trim())
1026
+ return err("content is required and cannot be empty.");
1027
+ try {
1028
+ const label = title || content.slice(0, 80);
1029
+ const fullContent = `[${kind}] ${label}: ${content}${tags?.length ? ` [tags: ${tags.join(", ")}]` : ""}`;
1030
+ const result = await api("POST", "/v1/memories", {
1031
+ content: fullContent,
1032
+ type: kind,
1033
+ tags: tags || [],
1034
+ confidence: 0.95,
1035
+ });
1036
+ const id = result.id || "pending";
1037
+ return ok(`Saved [${kind}]: "${label}" (id: ${id})`);
1038
+ }
1039
+ catch (e) {
1040
+ return err(`ricord_save failed: ${e.message}`);
1041
+ }
1042
+ });
1043
+ // ═══════════════════════════════════════════════════════════════════════
1044
+ // 4. CORRECT — overwrite memory by id, write, idempotent
1045
+ // ═══════════════════════════════════════════════════════════════════════
1046
+ server.tool("ricord_correct", `Overwrites the content of an existing memory by id.
1047
+
1048
+ When to call:
1049
+ - The user says a saved memory is wrong ("that's outdated", "that's not right anymore", "update that")
1050
+ - You find a contradiction between a recalled memory and something the user just stated
1051
+ - You have a memory id from a prior ricord_recall result and the user wants it updated
1052
+
1053
+ Do NOT call when:
1054
+ - You don't have a memory id — call ricord_recall first to get one
1055
+ - You want to add a new memory — use ricord_save (ricord_correct requires an exact id; wrong ids will error)
1056
+ - You want to delete a memory — use ricord_forget
1057
+
1058
+ Returns: confirmation that the memory was updated. Same id, new content.`, {
1059
+ id: z.string().describe("Memory id from a prior ricord_recall result."),
1060
+ content: z.string().describe("New content to replace the existing memory."),
1061
+ title: z.string().optional().describe("Optional new title."),
1062
+ }, {
1063
+ title: "Correct a memory",
1064
+ readOnlyHint: false,
1065
+ destructiveHint: true,
1066
+ idempotentHint: true,
1067
+ openWorldHint: false,
1068
+ }, async ({ id, content, title }) => {
1069
+ if (!id)
1070
+ return err("id is required.");
1071
+ if (!content || !content.trim())
1072
+ return err("content is required.");
1073
+ try {
1074
+ const patch = { content };
1075
+ if (title !== undefined)
1076
+ patch.title = title;
1077
+ await api("PUT", `/v1/memories/${encodeURIComponent(id)}`, patch);
1078
+ return ok(`Corrected memory ${id}.`);
1079
+ }
1080
+ catch (e) {
1081
+ return err(`ricord_correct failed: ${e.message}`);
1082
+ }
1083
+ });
1084
+ // ═══════════════════════════════════════════════════════════════════════
1085
+ // 5. FORGET — delete memory by id, destructive
1086
+ // ═══════════════════════════════════════════════════════════════════════
1087
+ server.tool("ricord_forget", `Permanently deletes a memory by id.
1088
+
1089
+ When to call:
1090
+ - The user explicitly says to delete or remove a memory ("forget that", "remove that preference", "delete that rule")
1091
+ - You have a memory id from a prior ricord_recall result and the user confirms it should be removed
1092
+
1093
+ Do NOT call when:
1094
+ - You don't have a memory id — call ricord_recall first to get one
1095
+ - The user just wants to correct a memory — use ricord_correct instead
1096
+ - You want to delete multiple memories — call once per id (no bulk delete)
1097
+
1098
+ Returns: confirmation of deletion. This is irreversible.`, {
1099
+ id: z.string().describe("Memory id from a prior ricord_recall result."),
1100
+ }, {
1101
+ title: "Delete a memory",
1102
+ readOnlyHint: false,
1103
+ destructiveHint: true,
1104
+ idempotentHint: false,
1105
+ openWorldHint: false,
1106
+ }, async ({ id }) => {
1107
+ if (!id)
1108
+ return err("id is required.");
1109
+ try {
1110
+ await api("DELETE", `/v1/memories/${encodeURIComponent(id)}`);
1111
+ return ok(`Deleted memory ${id}.`);
1112
+ }
1113
+ catch (e) {
1114
+ return err(`ricord_forget failed: ${e.message}`);
1115
+ }
1116
+ });
1117
+ // ═══════════════════════════════════════════════════════════════════════
1118
+ // 6. RUN PROCEDURE — execute a named SOP
1119
+ // ═══════════════════════════════════════════════════════════════════════
1120
+ server.tool("ricord_run_procedure", `Executes a named SOP or playbook, substituting template variables, and returns the step-by-step instructions.
1121
+
1122
+ When to call:
1123
+ - The user's message matches or paraphrases a procedure trigger listed in the ricord_get_context output
1124
+ - The user explicitly requests a named procedure ("run the deploy checklist", "start the release process")
1125
+ - The user says "ready to ship", "time to deploy", "run the checklist", or any phrase that matches a known trigger
1126
+
1127
+ Do NOT call when:
1128
+ - ricord_get_context returned no procedures this session (the call will error)
1129
+ - You are not sure a matching procedure exists — check the list from ricord_get_context first
1130
+ - The user wants to create or modify a procedure (use the direct API)
1131
+
1132
+ Returns: the procedure's steps, pre-conditions, and template-substituted actions. Follow the steps in order.`, {
1133
+ name: z.string().describe("Exact procedure title or a trigger phrase to match against."),
1134
+ inputs: z.record(z.string()).optional().describe("Template variable substitutions — replaces {{key}} tokens in step actions. Example: { service: 'ricord-api', env: 'prod' }"),
1135
+ }, {
1136
+ title: "Run procedure",
1137
+ readOnlyHint: false,
1138
+ destructiveHint: false,
1139
+ idempotentHint: false,
1140
+ openWorldHint: false,
1141
+ }, async ({ name, inputs }) => {
1142
+ if (!name || !name.trim())
1143
+ return err("name is required.");
1144
+ try {
1145
+ const listR = await api("GET", "/v1/procedures?limit=200");
1146
+ const all = listR.procedures || [];
1147
+ if (!all.length)
1148
+ return err("No procedures found. Call ricord_get_context first to verify procedures exist.");
1149
+ const phrase = name.toLowerCase();
1150
+ const match = all.find(p => p.title === name ||
1151
+ p.title.toLowerCase() === phrase ||
1152
+ (p.trigger && p.trigger.toLowerCase().includes(phrase)) ||
1153
+ (p.steps || []).some(s => s.action.toLowerCase().includes(phrase)));
1154
+ if (!match) {
1155
+ const suggestions = all.slice(0, 5).map(p => `• ${p.title}${p.trigger ? ` (trigger: ${p.trigger})` : ""}`).join("\n");
1156
+ return ok(`No procedure matched "${name}". Available procedures:\n${suggestions}`);
1157
+ }
1158
+ const walkQs = new URLSearchParams({ source: "walk" });
1159
+ if (inputs && Object.keys(inputs).length > 0)
1160
+ walkQs.set("inputs", JSON.stringify(inputs));
1161
+ const proc = await api("GET", `/v1/procedures/${encodeURIComponent(match.id)}?${walkQs}`);
1162
+ const lines = [];
1163
+ lines.push(`## Procedure: ${proc.title}`);
1164
+ if (proc.trigger)
1165
+ lines.push(`**When:** ${proc.trigger}`);
1166
+ if (proc.guards?.length)
1167
+ lines.push(`**Pre-conditions:** ${proc.guards.join("; ")}`);
1168
+ lines.push("\n**Steps:**");
1169
+ for (const step of proc.steps || []) {
1170
+ const n = step.n ?? (proc.steps.indexOf(step) + 1);
1171
+ lines.push(`${n}. ${step.action}`);
1172
+ if (step.expected)
1173
+ lines.push(` → Expected: ${step.expected}`);
1174
+ }
1175
+ lines.push(`\n(id: ${proc.id}, kind: ${proc.kind})`);
1176
+ return ok(lines.join("\n"));
1177
+ }
1178
+ catch (e) {
1179
+ return err(`ricord_run_procedure failed: ${e.message}`);
1180
+ }
1181
+ });
1182
+ // ═══════════════════════════════════════════════════════════════════════
1183
+ // 7. USAGE — credit balance + plan info, read-only, idempotent
1184
+ // ═══════════════════════════════════════════════════════════════════════
1185
+ server.tool("ricord_usage", `Returns current tier, today's request count, token usage, and rate limit information.
1186
+
1187
+ When to call:
1188
+ - The user asks "how many credits do I have", "what plan am I on", "am I near my limit"
1189
+ - The user asks about API quota, rate limits, or request counts
1190
+ - You need to decide whether to attempt a credit-consuming operation
1191
+
1192
+ Do NOT call when:
1193
+ - You need historical usage over time (available in the dashboard at ricord.ai)
1194
+ - You are checking the knowledge graph shape (use ricord_graph_stats instead)
1195
+
1196
+ Returns: tier, today.requests, today.tokens_total, rate_limit (requests per minute/day), and memory quota.`, {}, {
1197
+ title: "Check usage",
1198
+ readOnlyHint: true,
1199
+ destructiveHint: false,
1200
+ idempotentHint: true,
1201
+ openWorldHint: false,
1202
+ }, async () => {
1203
+ try {
1204
+ const result = await api("GET", "/v1/usage");
1205
+ const today = result.today || {};
1206
+ const rl = result.rate_limit || {};
1207
+ const quota = result.quota || {};
1208
+ const parts = [`Tier: ${result.tier ?? "unknown"}`];
1209
+ if (today.requests !== undefined) {
1210
+ parts.push(`Today: ${today.requests} requests, ${today.tokens_total ?? 0} tokens`);
1211
+ }
1212
+ if (rl.requestsPerMinute !== undefined || rl.requestsPerDay !== undefined) {
1213
+ parts.push(`Rate limit: ${rl.requestsPerMinute ?? "?"}/min, ${rl.requestsPerDay ?? "?"}/day`);
1214
+ }
1215
+ if (quota.memories_used !== undefined) {
1216
+ const cap = quota.memories_cap ?? "unlimited";
1217
+ parts.push(`Memories: ${quota.memories_used}/${cap}${quota.over_cap ? " (over cap)" : ""}`);
1218
+ }
1219
+ return ok(parts.join(" | "));
1220
+ }
1221
+ catch (e) {
1222
+ return err(`ricord_usage failed: ${e.message}`);
1223
+ }
1224
+ });
1225
+ // ═══════════════════════════════════════════════════════════════════════
1226
+ // 8. GRAPH STATS — entity/edge counts + top entities, read-only, idempotent
1227
+ // ═══════════════════════════════════════════════════════════════════════
1228
+ server.tool("ricord_graph_stats", `Returns entity count, edge count, and edge-type breakdown for the user's knowledge graph.
1229
+
1230
+ When to call:
1231
+ - The user asks "what does my knowledge graph look like", "how many entities do I have", "show me my graph stats"
1232
+ - The user asks about the shape or size of their knowledge base
1233
+ - You want to assess whether the knowledge graph is populated before using recall
1234
+
1235
+ Do NOT call when:
1236
+ - You need to search for specific knowledge (use ricord_recall instead)
1237
+ - You need a full entity list or neighborhood traversal (available via the dashboard graph view)
1238
+
1239
+ Returns: totalEntities, totalEdges, edgeTypeCounts (map of edge type to count).`, {}, {
1240
+ title: "Knowledge graph stats",
1241
+ readOnlyHint: true,
1242
+ destructiveHint: false,
1243
+ idempotentHint: true,
1244
+ openWorldHint: false,
1245
+ }, async () => {
1246
+ try {
1247
+ const stats = await api("GET", "/v1/kb/graph/stats");
1248
+ const parts = [
1249
+ `Entities: ${stats.totalEntities ?? 0}`,
1250
+ `Edges: ${stats.totalEdges ?? 0}`,
1251
+ ];
1252
+ if (stats.edgeTypeCounts && Object.keys(stats.edgeTypeCounts).length > 0) {
1253
+ const sorted = Object.entries(stats.edgeTypeCounts)
1254
+ .sort(([, a], [, b]) => b - a)
1255
+ .slice(0, 8)
1256
+ .map(([k, v]) => `${k}: ${v}`)
1257
+ .join(", ");
1258
+ parts.push(`Edge types: ${sorted}`);
1259
+ }
1260
+ return ok(parts.join(" | "));
1261
+ }
1262
+ catch (e) {
1263
+ return err(`ricord_graph_stats failed: ${e.message}`);
1264
+ }
1265
+ });
1266
+ // ═══════════════════════════════════════════════════════════════════════
1267
+ // 9. WIKI TOPICS — Topic DAG read access (F5 / S2)
1268
+ // ═══════════════════════════════════════════════════════════════════════
1269
+ server.tool("ricord_wiki_topics", `Returns the project's topic tree — the reading-neighborhood structure of the wiki.
1270
+
1271
+ When to call:
1272
+ - You need to know how this user's wiki is *organized* before answering a "what do we know about X" question
1273
+ - The user mentions "topics", "areas", "categories" of their knowledge
1274
+ - You want to suggest a wiki page placement and need to know what topics already exist
1275
+
1276
+ Do NOT call when:
1277
+ - You need a specific page (use ricord_recall)
1278
+ - You only need entity counts (use ricord_graph_stats)
1279
+
1280
+ Returns: topics[] with slug, title, scope (project | user), page_count, and has_children flag.`, {
1281
+ project_id: z.string().optional().describe("Override the auto-detected project scope."),
1282
+ scope: z.enum(["project", "user", "all"]).optional().describe("Filter by topic scope (default: all)."),
1283
+ }, {
1284
+ title: "List wiki topics",
1285
+ readOnlyHint: true,
1286
+ destructiveHint: false,
1287
+ idempotentHint: true,
1288
+ openWorldHint: false,
1289
+ }, async ({ project_id, scope }) => {
1290
+ try {
1291
+ const qs = new URLSearchParams();
1292
+ if (project_id)
1293
+ qs.set("project_id", project_id);
1294
+ if (scope)
1295
+ qs.set("scope", scope);
1296
+ const r = (await api("GET", `/v1/kb/topics${qs.toString() ? "?" + qs.toString() : ""}`));
1297
+ const topics = r.topics ?? [];
1298
+ if (topics.length === 0)
1299
+ return ok("No topics yet. Topics appear as you ingest memories or run `ricord ingest`.");
1300
+ const lines = [`${topics.length} topic${topics.length === 1 ? "" : "s"}:`];
1301
+ const sorted = [...topics].sort((a, b) => b.page_count - a.page_count);
1302
+ for (const t of sorted.slice(0, 50)) {
1303
+ const tag = t.scope === "user" ? " [bridge]" : "";
1304
+ const kids = t.has_children ? " ▾" : "";
1305
+ lines.push(`• ${t.slug}${tag}${kids} — ${t.title ?? "(untitled)"} (${t.page_count} pages)`);
1306
+ }
1307
+ if (sorted.length > 50)
1308
+ lines.push(`… ${sorted.length - 50} more`);
1309
+ return ok(lines.join("\n"));
1310
+ }
1311
+ catch (e) {
1312
+ return err(`ricord_wiki_topics failed: ${e.message}`);
1313
+ }
1314
+ });
1315
+ // ═══════════════════════════════════════════════════════════════════════
1316
+ // 10. WIKI PAGES FOR FILE — file-ref lookup (F4 / S3)
1317
+ // ═══════════════════════════════════════════════════════════════════════
1318
+ server.tool("ricord_wiki_pages_for_file", `Returns every wiki page that describes a given file or folder path.
1319
+
1320
+ When to call:
1321
+ - You are about to edit, refactor, or review a specific file and want to know what the wiki already says about it
1322
+ - A trailing slash on the path requests folder fanout — pages anywhere under that prefix
1323
+
1324
+ Do NOT call when:
1325
+ - You only have a concept name, not a path (use ricord_recall)
1326
+ - The path is ambiguous or hypothetical — pass the real repo-relative path
1327
+
1328
+ Returns: pages[] (direct file refs) and descendant_pages[] (folder fanout, only populated when path ends with /).`, {
1329
+ path: z.string().describe("Repo-relative path. Trailing slash means folder, e.g. 'src/auth/' or 'src/auth/middleware.ts'."),
1330
+ project_id: z.string().optional().describe("Override the auto-detected project scope."),
1331
+ }, {
1332
+ title: "Wiki pages for file",
1333
+ readOnlyHint: true,
1334
+ destructiveHint: false,
1335
+ idempotentHint: true,
1336
+ openWorldHint: false,
1337
+ }, async ({ path, project_id }) => {
1338
+ if (!path || !path.trim())
1339
+ return err("path is required.");
1340
+ try {
1341
+ const qs = new URLSearchParams({ path: path.trim() });
1342
+ if (project_id)
1343
+ qs.set("project_id", project_id);
1344
+ const r = (await api("GET", `/v1/kb/pages/by-file?${qs}`));
1345
+ const pages = r.pages ?? [];
1346
+ const desc = r.descendant_pages ?? [];
1347
+ if (pages.length === 0 && desc.length === 0) {
1348
+ return ok(`No wiki pages reference ${r.path}.`);
1349
+ }
1350
+ const lines = [];
1351
+ if (pages.length > 0) {
1352
+ lines.push(`${pages.length} page${pages.length === 1 ? "" : "s"} for ${r.path}:`);
1353
+ for (const p of pages.slice(0, 20)) {
1354
+ lines.push(`• ${p.title || p.anchor_value}${p.subtype ? ` [${p.subtype}]` : ""} — ${p.summary || "(no summary)"}`);
1355
+ }
1356
+ }
1357
+ if (r.is_dir && desc.length > 0) {
1358
+ lines.push("");
1359
+ lines.push(`${desc.length} page${desc.length === 1 ? "" : "s"} under that folder:`);
1360
+ for (const p of desc.slice(0, 20)) {
1361
+ lines.push(`• ${p.title || p.anchor_value} — ${p.summary || "(no summary)"}`);
1362
+ }
1363
+ if (desc.length > 20)
1364
+ lines.push(`… ${desc.length - 20} more`);
1365
+ }
1366
+ return ok(lines.join("\n"));
1367
+ }
1368
+ catch (e) {
1369
+ return err(`ricord_wiki_pages_for_file failed: ${e.message}`);
1370
+ }
1371
+ });
1372
+ server.tool("ricord_wiki_search", `Full-text search across every wiki page in the user's account.
1373
+
1374
+ When to call:
1375
+ - The user (or you, on the user's behalf) needs to find pages that mention a concept, name, file path, or phrase
1376
+ - Before generating an answer, you want to ground in what the wiki already knows
1377
+ - The user asks "what does my wiki say about X" or "have we written anything about X"
1378
+
1379
+ Do NOT call when:
1380
+ - You already have the exact page id (use ricord_wiki_pages_for_file or fetch by id)
1381
+ - You need to retrieve raw conversation memories (use ricord_recall)
1382
+
1383
+ Returns: ranked hits with title, anchor type, snippet (matches highlighted as <b>…</b>), and scope. Cross-project (scope=user) pages are marked.`, {
1384
+ q: z.string().describe("Free-text query. Natural words, no operator syntax."),
1385
+ project_id: z.string().optional().describe("Restrict to a single project."),
1386
+ scope: z.enum(["project", "user"]).optional().describe("Restrict to project-scope or user-scope (cross-project) pages."),
1387
+ limit: z.number().int().min(1).max(100).optional().describe("Max hits (default 25, cap 100)."),
1388
+ }, {
1389
+ title: "Wiki search",
1390
+ readOnlyHint: true,
1391
+ destructiveHint: false,
1392
+ idempotentHint: true,
1393
+ openWorldHint: false,
1394
+ }, async ({ q, project_id, scope, limit }) => {
1395
+ if (!q || !q.trim())
1396
+ return err("q is required.");
1397
+ try {
1398
+ const qs = new URLSearchParams({ q: q.trim() });
1399
+ if (project_id)
1400
+ qs.set("project_id", project_id);
1401
+ if (scope)
1402
+ qs.set("scope", scope);
1403
+ if (limit)
1404
+ qs.set("limit", String(limit));
1405
+ const r = (await api("GET", `/v1/kb/search?${qs}`));
1406
+ if (r.count === 0)
1407
+ return ok(`No wiki pages match "${r.q}".`);
1408
+ const lines = [`${r.count} hit${r.count === 1 ? "" : "s"} for "${r.q}":`];
1409
+ for (const h of r.hits.slice(0, 25)) {
1410
+ const star = h.scope === "user" ? " ★" : "";
1411
+ const snip = (h.snippet || h.summary || "").replace(/<\/?b>/g, "*").replace(/\s+/g, " ").trim();
1412
+ lines.push(`• [${h.anchor_type}] ${h.title || h.anchor_value}${star} — ${snip}`);
1413
+ }
1414
+ if (r.count > 25)
1415
+ lines.push(`… ${r.count - 25} more`);
1416
+ return ok(lines.join("\n"));
1417
+ }
1418
+ catch (e) {
1419
+ return err(`ricord_wiki_search failed: ${e.message}`);
1420
+ }
1421
+ });
1422
+ server.tool("ricord_wiki_recall", `Pull the top wiki pages for a query, with full bodies + topics ready to paste into your context.
1423
+
1424
+ When to call:
1425
+ - Before answering a question about the user's project, codebase, or domain, ground in what the wiki knows
1426
+ - When ricord_wiki_search would just give you titles + snippets and you need the actual content
1427
+ - The user asks "what do we know about X" or "remind me how X works" and you don't have it in context
1428
+
1429
+ Do NOT call when:
1430
+ - You only need to know whether a page exists (use ricord_wiki_search — it's lighter)
1431
+ - You already have the page id (fetch by id)
1432
+ - The query is about raw conversation memory (use ricord_recall)
1433
+
1434
+ Returns: top hits with title, anchor type, scope, topics[], and body truncated to max_body chars. Cite the page id when you use the content.`, {
1435
+ q: z.string().describe("Free-text query. Natural words."),
1436
+ project_id: z.string().optional().describe("Restrict to a single project."),
1437
+ limit: z.number().int().min(1).max(10).optional().describe("How many pages to return (default 3, max 10)."),
1438
+ max_body: z.number().int().min(200).max(8000).optional().describe("Per-page body cap in chars (default 2000)."),
1439
+ }, {
1440
+ title: "Wiki recall",
1441
+ readOnlyHint: true,
1442
+ destructiveHint: false,
1443
+ idempotentHint: true,
1444
+ openWorldHint: false,
1445
+ }, async ({ q, project_id, limit, max_body }) => {
1446
+ if (!q || !q.trim())
1447
+ return err("q is required.");
1448
+ try {
1449
+ const qs = new URLSearchParams({ q: q.trim() });
1450
+ if (project_id)
1451
+ qs.set("project_id", project_id);
1452
+ if (limit)
1453
+ qs.set("limit", String(limit));
1454
+ if (max_body)
1455
+ qs.set("max_body", String(max_body));
1456
+ const r = (await api("GET", `/v1/kb/recall?${qs}`));
1457
+ if (r.count === 0)
1458
+ return ok(`No wiki pages match "${r.q}".`);
1459
+ const sections = [];
1460
+ for (const h of r.hits) {
1461
+ const star = h.scope === "user" ? " ★" : "";
1462
+ const topics = h.topics.length > 0
1463
+ ? `_topics: ${h.topics.map((t) => t.slug).join(", ")}_\n\n`
1464
+ : "";
1465
+ sections.push(`## [${h.anchor_type}] ${h.title || h.anchor_value}${star}\n_page_id: ${h.id}_\n\n${topics}${h.body || "(no body)"}`);
1466
+ }
1467
+ return ok(sections.join("\n\n---\n\n"));
1468
+ }
1469
+ catch (e) {
1470
+ return err(`ricord_wiki_recall failed: ${e.message}`);
1471
+ }
1472
+ });
1473
+ server.tool("ricord_wiki_backlinks", `Show the wiki pages that point to (or are pointed at by) a given page.
1474
+
1475
+ When to call:
1476
+ - You opened a page via ricord_wiki_search / ricord_wiki_recall and want to know what else references the same concept
1477
+ - You're about to rename, archive, or merge a page and need to see what depends on it
1478
+ - The user asks "what references this" / "what's linked from this" / "what's the neighborhood"
1479
+
1480
+ Do NOT call when:
1481
+ - You only need the page body (use ricord_wiki_recall)
1482
+ - You don't have the page id yet (search for it first)
1483
+
1484
+ Returns: inbound[] (pages that reference this) and outbound[] (pages this references), each with link_type + link_weight so you can distinguish a mention from a contradiction or supersession.`, {
1485
+ page_id: z.string().describe("Page id (UUID) from a prior ricord_wiki_search / recall result."),
1486
+ limit: z.number().int().min(1).max(200).optional().describe("Per-direction cap (default 50)."),
1487
+ }, {
1488
+ title: "Wiki backlinks",
1489
+ readOnlyHint: true,
1490
+ destructiveHint: false,
1491
+ idempotentHint: true,
1492
+ openWorldHint: false,
1493
+ }, async ({ page_id, limit }) => {
1494
+ if (!page_id || !page_id.trim())
1495
+ return err("page_id is required.");
1496
+ try {
1497
+ const qs = new URLSearchParams();
1498
+ if (limit)
1499
+ qs.set("limit", String(limit));
1500
+ const path = `/v1/kb/pages/${encodeURIComponent(page_id.trim())}/backlinks${qs.toString() ? `?${qs}` : ""}`;
1501
+ const r = (await api("GET", path));
1502
+ if (r.inbound_count === 0 && r.outbound_count === 0) {
1503
+ return ok("This page has no inbound or outbound wiki links.");
1504
+ }
1505
+ const lines = [];
1506
+ if (r.inbound_count > 0) {
1507
+ lines.push(`${r.inbound_count} inbound (pages that reference this):`);
1508
+ for (const p of r.inbound.slice(0, 25)) {
1509
+ lines.push(` ← [${p.link_type}] ${p.title || p.anchor_value} (${p.id.slice(0, 8)})`);
1510
+ }
1511
+ if (r.inbound_count > 25)
1512
+ lines.push(` … ${r.inbound_count - 25} more`);
1513
+ }
1514
+ if (r.outbound_count > 0) {
1515
+ if (lines.length > 0)
1516
+ lines.push("");
1517
+ lines.push(`${r.outbound_count} outbound (pages this references):`);
1518
+ for (const p of r.outbound.slice(0, 25)) {
1519
+ lines.push(` → [${p.link_type}] ${p.title || p.anchor_value} (${p.id.slice(0, 8)})`);
1520
+ }
1521
+ if (r.outbound_count > 25)
1522
+ lines.push(` … ${r.outbound_count - 25} more`);
1523
+ }
1524
+ return ok(lines.join("\n"));
1525
+ }
1526
+ catch (e) {
1527
+ return err(`ricord_wiki_backlinks failed: ${e.message}`);
1528
+ }
1529
+ });
1530
+ // ── Start server ──────────────────────────────────────────────────────
1531
+ async function main() {
1532
+ const transport = new StdioServerTransport();
1533
+ await server.connect(transport);
1534
+ const toolCount = server._registeredTools
1535
+ ? Object.keys(server._registeredTools).length
1536
+ : 0;
1537
+ console.error(`Ricord MCP v${VERSION} (v2, ${toolCount} tools) — ${API_BASE}`);
1538
+ if (PROJECT)
1539
+ console.error(`Project: ${PROJECT}`);
1540
+ if (GIT_REPO)
1541
+ console.error(`Git: ${GIT_REPO} (${GIT_BRANCH || "unknown"})`);
1542
+ }
1543
+ main().catch((e) => {
1544
+ console.error("Fatal:", e);
1545
+ process.exit(1);
1546
+ });
1547
+ //# sourceMappingURL=index.js.map