@cairnkeep/cli 1.0.2

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 (129) hide show
  1. package/AGENTS.md +45 -0
  2. package/CONTRIBUTING.md +49 -0
  3. package/LICENSE +201 -0
  4. package/README.md +149 -0
  5. package/SECURITY.md +37 -0
  6. package/bin/cairn +73 -0
  7. package/claude/agents/code-reviewer.md +174 -0
  8. package/claude/agents/security-investigator.md +112 -0
  9. package/claude/agents/security-target-selector.md +90 -0
  10. package/claude/agents/security-validator.md +97 -0
  11. package/claude/agents/wiki-ingester.md +108 -0
  12. package/claude/agents/wiki-lint-auditor.md +94 -0
  13. package/claude/agents/wiki-query-analyst.md +84 -0
  14. package/claude/commands/context-explore.md +40 -0
  15. package/claude/commands/graphify.md +150 -0
  16. package/claude/commands/memory-review.md +85 -0
  17. package/claude/commands/memory-sync.md +71 -0
  18. package/claude/commands/recall.md +32 -0
  19. package/claude/commands/remember.md +43 -0
  20. package/claude/commands/repo-review.md +191 -0
  21. package/claude/commands/security-audit.md +174 -0
  22. package/claude/commands/wiki-ingest.md +112 -0
  23. package/claude/commands/wiki-lint.md +86 -0
  24. package/claude/commands/wiki-query.md +92 -0
  25. package/claude/hooks/context-explore-pretask.sh +93 -0
  26. package/claude/hooks/memory-capture.sh +59 -0
  27. package/claude/hooks/memory-recall.sh +84 -0
  28. package/claude/hooks/memory-wakeup.sh +45 -0
  29. package/docs/building-an-overlay.md +117 -0
  30. package/docs/domain-knowledge.md +86 -0
  31. package/docs/git-providers.md +36 -0
  32. package/docs/operating.md +366 -0
  33. package/mcp-memory-server/dist/embeddings.js +122 -0
  34. package/mcp-memory-server/dist/explore-cache.js +97 -0
  35. package/mcp-memory-server/dist/index.js +1256 -0
  36. package/mcp-memory-server/package.json +36 -0
  37. package/opencode/agents/code-reviewer.md +173 -0
  38. package/opencode/agents/security-investigator.md +111 -0
  39. package/opencode/agents/security-target-selector.md +89 -0
  40. package/opencode/agents/security-validator.md +96 -0
  41. package/opencode/agents/wiki-ingester.md +107 -0
  42. package/opencode/agents/wiki-lint-auditor.md +93 -0
  43. package/opencode/agents/wiki-query-analyst.md +83 -0
  44. package/opencode/command/code-review.md +199 -0
  45. package/opencode/command/context-explore.md +42 -0
  46. package/opencode/command/graphify.md +153 -0
  47. package/opencode/command/memory-review.md +44 -0
  48. package/opencode/command/memory-sync.md +94 -0
  49. package/opencode/command/recall.md +38 -0
  50. package/opencode/command/remember.md +46 -0
  51. package/opencode/command/security-audit.md +80 -0
  52. package/opencode/command/wiki-ingest.md +55 -0
  53. package/opencode/command/wiki-lint.md +51 -0
  54. package/opencode/command/wiki-query.md +50 -0
  55. package/opencode/plugins/memory-capture.ts +188 -0
  56. package/opencode/plugins/memory-recall.ts +132 -0
  57. package/opencode/plugins/memory-wakeup.ts +102 -0
  58. package/opencode/templates/security-finding-register.yaml.template +51 -0
  59. package/opencode/templates/security-issue.md.template +43 -0
  60. package/opencode/templates/security-policy.md.template +29 -0
  61. package/opencode/templates/security-report.md.template +43 -0
  62. package/opencode/templates/wiki-contradictions.md.template +31 -0
  63. package/opencode/templates/wiki-index.md.template +40 -0
  64. package/opencode/templates/wiki-lint-report.md.template +38 -0
  65. package/opencode/templates/wiki-log.md.template +11 -0
  66. package/opencode/templates/wiki-policy.md.template +47 -0
  67. package/opencode/templates/wiki-query-answer.md.template +26 -0
  68. package/opencode/templates/wiki-source-summary.md.template +33 -0
  69. package/opencode/workflows/memory-review-workflow.md +66 -0
  70. package/opencode/workflows/security-audit-workflow.md +197 -0
  71. package/opencode/workflows/wiki-ingest-workflow.md +149 -0
  72. package/opencode/workflows/wiki-lint-workflow.md +128 -0
  73. package/opencode/workflows/wiki-query-workflow.md +122 -0
  74. package/package.json +56 -0
  75. package/scripts/bootstrap.sh +99 -0
  76. package/scripts/doctor.sh +91 -0
  77. package/scripts/install-audit-timer.sh +68 -0
  78. package/scripts/lib/assert-tool-event.mjs +50 -0
  79. package/scripts/memory-store.sh +71 -0
  80. package/scripts/memory-wiki-audit.sh +156 -0
  81. package/scripts/sync-claude-assets.sh +182 -0
  82. package/scripts/sync-opencode-explore-assets.sh +187 -0
  83. package/scripts/sync-opencode-graphify-assets.sh +152 -0
  84. package/scripts/sync-opencode-memory-assets.sh +160 -0
  85. package/scripts/sync-opencode-plugin-assets.sh +163 -0
  86. package/scripts/sync-opencode-security-assets.sh +195 -0
  87. package/scripts/sync-opencode-wiki-assets.sh +250 -0
  88. package/scripts/test-audit-timer.sh +29 -0
  89. package/scripts/test-bootstrap-untracked.sh +41 -0
  90. package/scripts/test-cli-dispatch.sh +21 -0
  91. package/scripts/test-doctor.sh +32 -0
  92. package/scripts/test-launcher-hooks.sh +66 -0
  93. package/scripts/test-memory-store.sh +44 -0
  94. package/scripts/test-remember-recall-assertions.sh +70 -0
  95. package/scripts/transcript-to-text.mjs +53 -0
  96. package/scripts/verify-docs-parity.sh +112 -0
  97. package/scripts/verify-explore-maturation.sh +288 -0
  98. package/scripts/verify-fastcontext-reliability.sh +624 -0
  99. package/scripts/verify-no-private-references.sh +145 -0
  100. package/scripts/verify-opencode-live-parity.sh +864 -0
  101. package/scripts/verify-routing-seam.sh +169 -0
  102. package/scripts/verify-token-savings-ab.sh +557 -0
  103. package/templates/PROJECT-INSTRUCTIONS.md.template +42 -0
  104. package/templates/alignment-gap-register.yaml.template +56 -0
  105. package/templates/alignment-policy.md.template +28 -0
  106. package/templates/alignment-pr-template.md.template +18 -0
  107. package/templates/alignment-report.md.template +56 -0
  108. package/templates/env.example.template +25 -0
  109. package/templates/graph-policy.md.template +37 -0
  110. package/templates/graphs-gitignore.template +3 -0
  111. package/templates/memory-wiki-audit.service.template +6 -0
  112. package/templates/memory-wiki-audit.timer.template +9 -0
  113. package/templates/memory.json.template +4 -0
  114. package/templates/planning-config.json.template +6 -0
  115. package/templates/project-brief.md.template +40 -0
  116. package/templates/security-finding-register.yaml.template +51 -0
  117. package/templates/security-issue.md.template +43 -0
  118. package/templates/security-policy.md.template +29 -0
  119. package/templates/security-report.md.template +43 -0
  120. package/templates/start-claude.sh.template +46 -0
  121. package/templates/start-opencode.sh.template +41 -0
  122. package/templates/structured-change-canvas.md.template +63 -0
  123. package/templates/wiki-contradictions.md.template +31 -0
  124. package/templates/wiki-index.md.template +40 -0
  125. package/templates/wiki-lint-report.md.template +38 -0
  126. package/templates/wiki-log.md.template +11 -0
  127. package/templates/wiki-policy.md.template +47 -0
  128. package/templates/wiki-query-answer.md.template +26 -0
  129. package/templates/wiki-source-summary.md.template +33 -0
@@ -0,0 +1,1256 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { randomUUID, timingSafeEqual } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
5
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import { stdin as input, stdout as output } from "node:process";
9
+ import { AgentFS } from "agentfs-sdk";
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
13
+ import { z } from "zod";
14
+ import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText, } from "./embeddings.js";
15
+ import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
16
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
17
+ const infraRoot = resolve(moduleDir, "..", "..");
18
+ // Path to the domain-knowledge sync script. Defaults to a script bundled next to
19
+ // this repo, but a wrapper can point CAIRN_ANYTHINGLLM_SYNC_SCRIPT at its own copy
20
+ // (the integration scripts often live in the wrapper, not the core).
21
+ const anythingllmSyncScript = process.env.CAIRN_ANYTHINGLLM_SYNC_SCRIPT
22
+ ? resolve(expandHome(process.env.CAIRN_ANYTHINGLLM_SYNC_SCRIPT))
23
+ : join(infraRoot, "anythingllm", "sync_to_anythingllm.py");
24
+ const HISTORY_NAMESPACE = "__history__";
25
+ function expandHome(value) {
26
+ if (value === "~") {
27
+ return homedir();
28
+ }
29
+ if (value.startsWith("~/")) {
30
+ return join(homedir(), value.slice(2));
31
+ }
32
+ return value;
33
+ }
34
+ function getBaseDir() {
35
+ return resolve(expandHome(process.env.CAIRN_AGENTFS_BASE_DIR ?? "~/.cairnkeep"));
36
+ }
37
+ // Provider-neutral config resolution so the same server works under OpenCode,
38
+ // Claude Code, or any harness. First existing path wins. .opencode/memory.json
39
+ // is kept in the list for backward compatibility with existing repos.
40
+ function resolveMemoryConfigPath(cwd) {
41
+ const candidates = [
42
+ process.env.AGENT_MEMORY_CONFIG,
43
+ join(cwd, ".agent", "memory.json"),
44
+ join(cwd, ".opencode", "memory.json"),
45
+ join(cwd, ".claude", "memory.json"),
46
+ join(cwd, "memory.json"),
47
+ ];
48
+ for (const candidate of candidates) {
49
+ if (candidate && existsSync(candidate)) {
50
+ return candidate;
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+ function getMemoryConfig(cwd = process.cwd()) {
56
+ const configPath = resolveMemoryConfigPath(cwd);
57
+ if (!configPath) {
58
+ return {
59
+ scopes: ["identity"],
60
+ anythingllm_workspaces: [],
61
+ };
62
+ }
63
+ const raw = readFileSync(configPath, "utf8");
64
+ const parsed = JSON.parse(raw);
65
+ return {
66
+ scopes: parsed.scopes?.length ? Array.from(new Set(parsed.scopes)) : ["identity"],
67
+ anythingllm_workspaces: parsed.anythingllm_workspaces ?? [],
68
+ };
69
+ }
70
+ function getSearchScopes(scope, config) {
71
+ if (scope === "all") {
72
+ return config.scopes?.length ? config.scopes : ["identity"];
73
+ }
74
+ return [scope];
75
+ }
76
+ // Scopes name a single db file directly under the base dir, so they must be a
77
+ // bare kebab-case token. Rejecting separators, dots, and absolute paths here —
78
+ // the one chokepoint every tool resolves through — stops a `../` or absolute
79
+ // `scope` from escaping the base dir and reading/creating arbitrary .db files.
80
+ const SCOPE_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
81
+ function assertSafeScope(scope) {
82
+ if (scope === "project") {
83
+ return;
84
+ }
85
+ if (!SCOPE_PATTERN.test(scope)) {
86
+ throw new Error(`Invalid scope "${scope}": must be kebab-case (^[a-z0-9][a-z0-9-]*$), "project", or "all".`);
87
+ }
88
+ }
89
+ function resolveScopePath(scope, cwd = process.cwd()) {
90
+ if (scope === "project") {
91
+ return resolve(cwd, ".agentfs", "project.db");
92
+ }
93
+ // "all" is a read-only virtual scope: memory_read/memory_search fan it out
94
+ // over the configured scopes (via getSearchScopes) before resolving. It has
95
+ // no db file of its own, so resolving it directly — which only the write,
96
+ // list, delete, supersede, and history paths do — is a bug: it would create
97
+ // or read a literal `all.db` invisible to the fan-out readers. Reject it.
98
+ if (scope === "all") {
99
+ throw new Error('Scope "all" fans out over configured scopes for reads only (memory_read / memory_search); '
100
+ + "name a concrete scope for writes, lists, deletes, supersedes, and history.");
101
+ }
102
+ assertSafeScope(scope);
103
+ const baseDir = getBaseDir();
104
+ const dbPath = resolve(baseDir, `${scope}.db`);
105
+ // Defense in depth: even if the pattern is ever loosened, never resolve
106
+ // outside the base dir. `relative` catches `..` escapes (which `join` would
107
+ // silently normalize away) as well as absolute overrides.
108
+ const rel = relative(baseDir, dbPath);
109
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
110
+ throw new Error(`Invalid scope "${scope}": resolves outside the base directory.`);
111
+ }
112
+ return dbPath;
113
+ }
114
+ function ensureParentDir(filePath) {
115
+ mkdirSync(dirname(filePath), { recursive: true });
116
+ }
117
+ function normalizeValue(value) {
118
+ if (typeof value === "string") {
119
+ return value;
120
+ }
121
+ if (value === undefined || value === null) {
122
+ return "";
123
+ }
124
+ return JSON.stringify(value);
125
+ }
126
+ async function openScope(scope, create, cwd) {
127
+ const dbPath = resolveScopePath(scope, cwd);
128
+ if (!create && !existsSync(dbPath)) {
129
+ return null;
130
+ }
131
+ if (create) {
132
+ ensureParentDir(dbPath);
133
+ }
134
+ return AgentFS.open({ id: scope, path: dbPath });
135
+ }
136
+ function isHistoryKey(key) {
137
+ return key === HISTORY_NAMESPACE || key.startsWith(`${HISTORY_NAMESPACE}/`);
138
+ }
139
+ function historyPrefix(baseKey) {
140
+ return `${HISTORY_NAMESPACE}/${baseKey}/`;
141
+ }
142
+ function historySnapshotKey(baseKey, timestamp) {
143
+ return `${historyPrefix(baseKey)}${timestamp}`;
144
+ }
145
+ function visibleEntries(entries, includeHistory) {
146
+ if (includeHistory) {
147
+ return entries;
148
+ }
149
+ return entries.filter((entry) => !isHistoryKey(entry.key));
150
+ }
151
+ async function listEntries(scope, prefix = "", options = {}) {
152
+ const agent = await openScope(scope, false, options.cwd);
153
+ if (!agent) {
154
+ return [];
155
+ }
156
+ try {
157
+ const entries = await agent.kv.list(prefix);
158
+ return visibleEntries(entries.map(({ key, value }) => ({
159
+ scope,
160
+ key,
161
+ value: normalizeValue(value),
162
+ })), options.includeHistory ?? false);
163
+ }
164
+ finally {
165
+ await agent.close();
166
+ }
167
+ }
168
+ async function readKey(scope, key) {
169
+ const agent = await openScope(scope, false);
170
+ if (!agent) {
171
+ return [];
172
+ }
173
+ try {
174
+ const value = await agent.kv.get(key);
175
+ if (value === undefined) {
176
+ return [];
177
+ }
178
+ return [{ scope, key, value: normalizeValue(value) }];
179
+ }
180
+ finally {
181
+ await agent.close();
182
+ }
183
+ }
184
+ function searchEntries(entries, query) {
185
+ const needle = query.toLowerCase();
186
+ return entries.filter(({ key, value }) => {
187
+ return key.toLowerCase().includes(needle) || value.toLowerCase().includes(needle);
188
+ });
189
+ }
190
+ function asToolText(value) {
191
+ return JSON.stringify(value, null, 2);
192
+ }
193
+ function truncateOutput(value, maxLength = 12000) {
194
+ if (value.length <= maxLength) {
195
+ return value;
196
+ }
197
+ return `${value.slice(0, maxLength)}\n...[truncated ${value.length - maxLength} chars]`;
198
+ }
199
+ function stripMarkdownFences(value) {
200
+ return value
201
+ .trim()
202
+ .replace(/^```(?:json)?\s*/i, "")
203
+ .replace(/\s*```$/, "")
204
+ .trim();
205
+ }
206
+ function parseJsonResponse(value) {
207
+ const stripped = stripMarkdownFences(value);
208
+ try {
209
+ return JSON.parse(stripped);
210
+ }
211
+ catch {
212
+ const firstBrace = stripped.indexOf("{");
213
+ const lastBrace = stripped.lastIndexOf("}");
214
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
215
+ return JSON.parse(stripped.slice(firstBrace, lastBrace + 1));
216
+ }
217
+ throw new Error(`Failed to parse JSON response: ${truncateOutput(stripped, 1000)}`);
218
+ }
219
+ }
220
+ function sanitizeExtractionCandidates(value, fallbackCategory) {
221
+ if (!Array.isArray(value)) {
222
+ return [];
223
+ }
224
+ return value
225
+ .map((item) => {
226
+ if (!item || typeof item !== "object") {
227
+ return null;
228
+ }
229
+ const raw = item;
230
+ const key = typeof raw.key === "string" ? raw.key.trim() : "";
231
+ const candidateValue = typeof raw.value === "string" ? raw.value.trim() : "";
232
+ if (!key || !candidateValue) {
233
+ return null;
234
+ }
235
+ const category = typeof raw.category === "string" && raw.category.trim()
236
+ ? raw.category.trim()
237
+ : fallbackCategory;
238
+ const importance = typeof raw.importance === "number"
239
+ ? Math.max(0, Math.min(1, raw.importance))
240
+ : undefined;
241
+ return {
242
+ key,
243
+ value: candidateValue,
244
+ category,
245
+ importance,
246
+ };
247
+ })
248
+ .filter((candidate) => candidate !== null);
249
+ }
250
+ async function extractMemoryCandidates(content, modelOverride, category) {
251
+ const apiKey = process.env.CAIRN_LLM_API_KEY;
252
+ if (!apiKey) {
253
+ throw new Error("CAIRN_LLM_API_KEY is not set.");
254
+ }
255
+ const rawUrl = process.env.CAIRN_LLM_API_URL;
256
+ if (!rawUrl) {
257
+ throw new Error("CAIRN_LLM_API_URL is not set.");
258
+ }
259
+ const apiUrl = rawUrl.trim().replace(/\/+$/, "");
260
+ const model = (modelOverride ?? process.env.CAIRN_LLM_EXTRACTION_MODEL)?.trim();
261
+ if (!model) {
262
+ throw new Error("CAIRN_LLM_EXTRACTION_MODEL is not set.");
263
+ }
264
+ const systemPrompt = [
265
+ "You extract durable memory candidates from development notes.",
266
+ "Return ONLY valid JSON, no markdown fences.",
267
+ "Schema: {\"candidates\":[{\"key\":\"decisions/cache-rule\",\"value\":\"...\",\"category\":\"decision\",\"importance\":0.92}]}",
268
+ "Only include genuinely reusable knowledge.",
269
+ "Skip trivial status notes, temporary branch details, and duplicated points.",
270
+ "Prefer short kebab-case keys with a useful prefix such as decisions/, pitfalls/, patterns/, bugs/, constraints/, preferences/, conventions/.",
271
+ "Do not invent dates unless they are explicitly present in the source text.",
272
+ category ? `Bias extraction toward category: ${category}.` : "",
273
+ ].filter(Boolean).join(" ");
274
+ const response = await fetch(`${apiUrl}/chat/completions`, {
275
+ method: "POST",
276
+ headers: {
277
+ Authorization: `Bearer ${apiKey}`,
278
+ "Content-Type": "application/json",
279
+ },
280
+ body: JSON.stringify({
281
+ model,
282
+ messages: [
283
+ { role: "system", content: systemPrompt },
284
+ { role: "user", content },
285
+ ],
286
+ temperature: 0.1,
287
+ max_tokens: 1200,
288
+ }),
289
+ signal: AbortSignal.timeout(120000),
290
+ });
291
+ if (!response.ok) {
292
+ const text = await response.text();
293
+ throw new Error(`Extraction request failed with ${response.status}: ${text}`);
294
+ }
295
+ const payload = (await response.json());
296
+ const rawContent = payload.choices?.[0]?.message?.content;
297
+ if (!rawContent) {
298
+ throw new Error("Extraction model returned no content.");
299
+ }
300
+ const parsed = parseJsonResponse(rawContent);
301
+ return {
302
+ model,
303
+ candidates: sanitizeExtractionCandidates(parsed.candidates, category),
304
+ };
305
+ }
306
+ async function runCommand(command, args, timeoutMs, env = process.env) {
307
+ return new Promise((resolvePromise, reject) => {
308
+ const child = spawn(command, args, {
309
+ cwd: infraRoot,
310
+ env,
311
+ stdio: ["ignore", "pipe", "pipe"],
312
+ });
313
+ let stdout = "";
314
+ let stderr = "";
315
+ let timedOut = false;
316
+ const timer = setTimeout(() => {
317
+ timedOut = true;
318
+ child.kill("SIGTERM");
319
+ }, timeoutMs);
320
+ child.stdout?.on("data", (chunk) => {
321
+ stdout += chunk.toString("utf8");
322
+ });
323
+ child.stderr?.on("data", (chunk) => {
324
+ stderr += chunk.toString("utf8");
325
+ });
326
+ child.on("error", (error) => {
327
+ clearTimeout(timer);
328
+ reject(error);
329
+ });
330
+ child.on("close", (exitCode) => {
331
+ clearTimeout(timer);
332
+ resolvePromise({
333
+ exitCode,
334
+ stdout: truncateOutput(stdout),
335
+ stderr: truncateOutput(stderr),
336
+ timedOut,
337
+ });
338
+ });
339
+ });
340
+ }
341
+ async function readStdin() {
342
+ const chunks = [];
343
+ for await (const chunk of input) {
344
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
345
+ }
346
+ return Buffer.concat(chunks).toString("utf8");
347
+ }
348
+ function defaultAnythingLLMWorkspace(config) {
349
+ return config.anythingllm_workspaces?.find((workspace) => workspace !== "engineering-patterns");
350
+ }
351
+ async function callAnythingLLM(workspace, query) {
352
+ const apiKey = process.env.ANYTHINGLLM_API_KEY;
353
+ if (!apiKey) {
354
+ throw new Error("ANYTHINGLLM_API_KEY is not set.");
355
+ }
356
+ const baseUrl = process.env.ANYTHINGLLM_BASE_URL ?? "http://localhost:3001";
357
+ const response = await fetch(`${baseUrl}/api/v1/workspace/${encodeURIComponent(workspace)}/chat`, {
358
+ method: "POST",
359
+ headers: {
360
+ Authorization: `Bearer ${apiKey}`,
361
+ "Content-Type": "application/json",
362
+ },
363
+ body: JSON.stringify({
364
+ message: query,
365
+ mode: "query",
366
+ }),
367
+ signal: AbortSignal.timeout(120000),
368
+ });
369
+ if (!response.ok) {
370
+ const text = await response.text();
371
+ throw new Error(`AnythingLLM request failed with ${response.status}: ${text}`);
372
+ }
373
+ const payload = (await response.json());
374
+ const directText = [
375
+ payload.textResponse,
376
+ payload.response,
377
+ payload.message,
378
+ payload.text,
379
+ ].find((value) => typeof value === "string");
380
+ if (typeof directText === "string") {
381
+ return directText;
382
+ }
383
+ return JSON.stringify(payload, null, 2);
384
+ }
385
+ function entryText(entry) {
386
+ return entry.value ? `${entry.key}\n${entry.value}` : entry.key;
387
+ }
388
+ function embeddingCachePath(scope) {
389
+ return join(getBaseDir(), ".embeddings", `${hashText(resolveScopePath(scope))}.json`);
390
+ }
391
+ async function semanticSearch(scope, query, topK, minScore) {
392
+ const config = getMemoryConfig();
393
+ const scopes = getSearchScopes(scope, config);
394
+ const embeddingConfig = getEmbeddingConfig();
395
+ const perScopeEntries = await Promise.all(scopes.map(async (candidate) => ({ scope: candidate, entries: await listEntries(candidate) })));
396
+ const allEntries = perScopeEntries.flatMap((group) => group.entries);
397
+ if (allEntries.length === 0) {
398
+ return { results: [], mode: embeddingConfig ? "semantic" : "substring" };
399
+ }
400
+ const substringFallback = () => ({
401
+ results: searchEntries(allEntries, query)
402
+ .map((entry) => ({ ...entry, score: 1 }))
403
+ .slice(0, topK),
404
+ mode: "substring",
405
+ });
406
+ if (!embeddingConfig) {
407
+ return substringFallback();
408
+ }
409
+ try {
410
+ const caches = [];
411
+ const vectors = new Map();
412
+ for (const group of perScopeEntries) {
413
+ const cache = new EmbeddingCache(embeddingCachePath(group.scope), embeddingConfig.model);
414
+ caches.push(cache);
415
+ const misses = [];
416
+ for (const entry of group.entries) {
417
+ const text = entryText(entry);
418
+ const contentHash = hashText(text);
419
+ const cached = cache.get(entry.key, contentHash);
420
+ if (cached) {
421
+ vectors.set(entry, cached);
422
+ }
423
+ else {
424
+ misses.push({ entry, text, hash: contentHash });
425
+ }
426
+ }
427
+ if (misses.length) {
428
+ const fresh = await embedTexts(embeddingConfig, misses.map((miss) => miss.text));
429
+ misses.forEach((miss, index) => {
430
+ const vector = fresh[index];
431
+ if (vector) {
432
+ cache.set(miss.entry.key, miss.hash, vector);
433
+ vectors.set(miss.entry, vector);
434
+ }
435
+ });
436
+ }
437
+ }
438
+ const [queryVector] = await embedTexts(embeddingConfig, [query]);
439
+ for (const cache of caches) {
440
+ cache.save();
441
+ }
442
+ const ranked = allEntries
443
+ .map((entry) => {
444
+ const vector = vectors.get(entry);
445
+ const score = vector && queryVector ? cosineSimilarity(queryVector, vector) : 0;
446
+ return { ...entry, score };
447
+ })
448
+ .filter((entry) => entry.score >= minScore)
449
+ .sort((left, right) => right.score - left.score)
450
+ .slice(0, topK);
451
+ return { results: ranked, mode: "semantic", model: embeddingConfig.model };
452
+ }
453
+ catch {
454
+ // Embedding endpoint failure — degrade gracefully to substring matching.
455
+ return substringFallback();
456
+ }
457
+ }
458
+ // Confines a candidate wiki source file to inside `<repoRoot>/.planning/wiki/sources`
459
+ // via relative()-based containment (Phase 2 SEC-0001 idiom, reused from
460
+ // opencode/plugins/memory-recall.ts) — `resolve() === join()` misses `../`
461
+ // traversal, so this checks the relative path instead.
462
+ function isContained(baseDir, candidate) {
463
+ const rel = relative(baseDir, candidate);
464
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
465
+ }
466
+ const CROSSREF_MIN_STEM_LENGTH = 4;
467
+ // Basename minus extension, lowercased. Mirrors memory-recall.sh's stem
468
+ // derivation (Security V12): a citation path is used ONLY to derive this
469
+ // search token, never concatenated into a filesystem read path.
470
+ function citationStem(citationPath) {
471
+ const base = citationPath.split("/").pop() ?? citationPath;
472
+ const dot = base.lastIndexOf(".");
473
+ return (dot > 0 ? base.slice(0, dot) : base).toLowerCase();
474
+ }
475
+ // Cross-ref enrichment (D-01/D-02/D-04, CTX-08): per-citation deterministic
476
+ // stem match against the EXPLORED repo's project memory + wiki -- never the
477
+ // server's own cwd (Pitfall 2, hence the cwd passthrough into listEntries).
478
+ // Wrapped end-to-end in try/catch: any failure (missing db, missing wiki
479
+ // dir, read error) returns the citations unchanged with no refs -- fail-open,
480
+ // never degrades or fails the exploration result itself.
481
+ async function crossReferenceCitations(citations, repoRoot) {
482
+ try {
483
+ const stemByPath = new Map();
484
+ for (const citation of citations) {
485
+ const stem = citationStem(citation.path);
486
+ if (stem.length >= CROSSREF_MIN_STEM_LENGTH) {
487
+ stemByPath.set(citation.path, stem);
488
+ }
489
+ }
490
+ if (stemByPath.size === 0) {
491
+ return citations;
492
+ }
493
+ let memoryEntries = [];
494
+ try {
495
+ memoryEntries = await listEntries("project", "", { cwd: repoRoot });
496
+ }
497
+ catch {
498
+ memoryEntries = [];
499
+ }
500
+ let wikiPages = [];
501
+ try {
502
+ const wikiDir = join(repoRoot, ".planning", "wiki", "sources");
503
+ wikiPages = readdirSync(wikiDir)
504
+ .filter((name) => name.endsWith(".md"))
505
+ .map((name) => join(wikiDir, name))
506
+ .filter((fullPath) => isContained(wikiDir, fullPath))
507
+ .map((fullPath) => ({ name: fullPath.split("/").pop(), content: readFileSync(fullPath, "utf8") }));
508
+ }
509
+ catch {
510
+ wikiPages = [];
511
+ }
512
+ return citations.map((citation) => {
513
+ const stem = stemByPath.get(citation.path);
514
+ if (!stem) {
515
+ return citation;
516
+ }
517
+ const memoryRefs = memoryEntries
518
+ .filter((entry) => entry.key.toLowerCase().includes(stem) || entry.value.toLowerCase().includes(stem))
519
+ .map((entry) => entry.key);
520
+ const wikiRefs = wikiPages
521
+ .filter((page) => page.name.toLowerCase().includes(stem) || page.content.toLowerCase().includes(stem))
522
+ .map((page) => page.name);
523
+ const enriched = { ...citation };
524
+ if (memoryRefs.length > 0) {
525
+ enriched.memory_refs = memoryRefs;
526
+ }
527
+ if (wikiRefs.length > 0) {
528
+ enriched.wiki_refs = wikiRefs;
529
+ }
530
+ return enriched;
531
+ });
532
+ }
533
+ catch {
534
+ return citations;
535
+ }
536
+ }
537
+ // Compact marker appended to a citation's rendered line only when it has at
538
+ // least one memory or wiki ref (D-03) -- a ref-less citation's rendering is
539
+ // byte-for-byte unchanged from the pre-phase output.
540
+ function renderCrossRefMarker(memoryRefs, wikiRefs) {
541
+ const parts = [];
542
+ if (memoryRefs && memoryRefs.length > 0) {
543
+ parts.push(`memory: ${memoryRefs.join(", ")}`);
544
+ }
545
+ if (wikiRefs && wikiRefs.length > 0) {
546
+ parts.push(`wiki: ${wikiRefs.join(", ")}`);
547
+ }
548
+ return parts.length > 0 ? ` <- ${parts.join(" - ")}` : "";
549
+ }
550
+ // Compact citation rendering (D-02): reduce the full Evidence.citations array
551
+ // to the lean `path:start-end` text list — the actual token-economy payoff of
552
+ // this tool. Empty citations from a successful run are first-class (D-04),
553
+ // never conflated with failure; surface turns/tool_calls for transparency
554
+ // (Pitfall #1 — an unreachable-but-configured endpoint looks identical).
555
+ function renderCitations(evidence) {
556
+ if (evidence.citations.length === 0) {
557
+ return `(no citations found; turns=${evidence.stats.turns}, ` +
558
+ `tool_calls=${evidence.stats.tool_calls})`;
559
+ }
560
+ return evidence.citations
561
+ .map((c) => `${c.path}:${c.start_line}-${c.end_line}${renderCrossRefMarker(c.memory_refs, c.wiki_refs)}`)
562
+ .join("\n");
563
+ }
564
+ // Shared handler body for context_explore (D-06): both the registered MCP
565
+ // tool callback and the `explore` CLI subcommand call this, so cache (CTX-10)
566
+ // behaves identically from either invocation path. `repoRoot` passed in is
567
+ // ALREADY the resolved absolute path -- callers own resolving repo_root from
568
+ // their own param/env/cwd conventions before calling this.
569
+ async function runContextExplore(args) {
570
+ const { query, repoRoot, timeoutSeconds } = args;
571
+ // --- Precondition tier: throw (config/environment problems, D-04) ---
572
+ const binaryPath = process.env.CAIRN_EXPLORE_BINARY;
573
+ if (!binaryPath) {
574
+ throw new Error("CAIRN_EXPLORE_BINARY is not set.");
575
+ }
576
+ if (!existsSync(binaryPath)) {
577
+ throw new Error(`CAIRN_EXPLORE_BINARY does not exist: ${binaryPath}`);
578
+ }
579
+ if (!existsSync(repoRoot)) {
580
+ throw new Error(`repo_root does not exist: ${repoRoot}`);
581
+ }
582
+ // --- Cache check (D-09/CTX-10): wraps the spawn, BEFORE it happens.
583
+ // CAIRN_EXPLORE_CACHE=0 is the kill-switch (always spawn, cached:false).
584
+ // Cache key/read/write errors fail open to a normal spawn -- never throw
585
+ // into the execution tier below.
586
+ const cacheEnabled = process.env.CAIRN_EXPLORE_CACHE !== "0";
587
+ let probe;
588
+ if (cacheEnabled) {
589
+ try {
590
+ const normalizedQuery = normalizeExploreQuery(query);
591
+ const { head, dirtyHash } = computeRepoState(repoRoot);
592
+ probe = { key: exploreCacheKey(normalizedQuery, repoRoot, head, dirtyHash), head, dirtyHash };
593
+ }
594
+ catch {
595
+ probe = undefined;
596
+ }
597
+ }
598
+ let evidence;
599
+ let cached = false;
600
+ if (probe) {
601
+ const hit = readExploreCache(probe.key);
602
+ if (hit) {
603
+ evidence = hit.evidence;
604
+ cached = true;
605
+ }
606
+ }
607
+ // --- Execution tier: return { ok: false, ... } (runtime problems, D-04) ---
608
+ if (!evidence) {
609
+ const result = await runCommand(binaryPath, ["explore", "--query", query, "--repo-root", repoRoot], (timeoutSeconds ?? 120) * 1000, { ...process.env, NO_COLOR: "1" });
610
+ if (result.timedOut || result.exitCode !== 0) {
611
+ const payload = {
612
+ ok: false,
613
+ error: result.timedOut
614
+ ? "token_miser explore timed out"
615
+ : "token_miser explore exited non-zero",
616
+ stderr: result.stderr,
617
+ exitCode: result.exitCode,
618
+ timedOut: result.timedOut,
619
+ };
620
+ return { payload, text: asToolText(payload) };
621
+ }
622
+ try {
623
+ evidence = JSON.parse(result.stdout.trim());
624
+ }
625
+ catch {
626
+ const payload = {
627
+ ok: false,
628
+ error: "malformed Evidence JSON",
629
+ stderr: result.stderr,
630
+ exitCode: result.exitCode,
631
+ };
632
+ return { payload, text: asToolText(payload) };
633
+ }
634
+ if (probe) {
635
+ writeExploreCache(probe.key, {
636
+ createdAt: new Date().toISOString(),
637
+ query: normalizeExploreQuery(query),
638
+ repoRoot,
639
+ head: probe.head,
640
+ dirtyHash: probe.dirtyHash,
641
+ evidence: evidence,
642
+ });
643
+ }
644
+ }
645
+ // --- Success shaping (D-02 dual output; cached flag per CTX-10) ---
646
+ // By this point evidence is always assigned: either a cache hit set it
647
+ // above, or the execution tier above returned early on failure.
648
+ const finalEvidence = evidence;
649
+ // --- Cross-ref enrichment (D-01/D-12, CTX-08): recomputed on EVERY
650
+ // return, cache hit and cache miss alike, since memory/wiki evolve
651
+ // independently of repo HEAD. Never part of the cached entry (D-12) --
652
+ // only the raw evidence above was written to cache.
653
+ const enrichedCitations = await crossReferenceCitations(finalEvidence.citations, repoRoot);
654
+ const payload = { ok: true, ...finalEvidence, citations: enrichedCitations, cached };
655
+ return { payload, text: renderCitations({ citations: enrichedCitations, stats: finalEvidence.stats }) };
656
+ }
657
+ // Resolves the repo root for the `explore` CLI subcommand when
658
+ // CAIRN_EXPLORE_REPO_ROOT is unset -- mirrors `git rev-parse --show-toplevel`,
659
+ // argv-array only (V5), never a shell string.
660
+ function gitToplevel(cwd) {
661
+ // stdio ignores the child's stderr so a non-git cwd doesn't leak git's
662
+ // "fatal: not a git repository" diagnostic onto the server's own stderr;
663
+ // the caller's try/catch already surfaces a clean error message instead.
664
+ return execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
665
+ encoding: "utf8",
666
+ stdio: ["ignore", "pipe", "ignore"],
667
+ }).trim();
668
+ }
669
+ // Factory: each MCP client/session needs its own McpServer instance (the SDK
670
+ // only allows one connected transport per server). All instances share the
671
+ // module-level helpers + AgentFS below. Enables a single long-lived process to
672
+ // serve many concurrent clients (centralized AgentFS on the VPS).
673
+ function createMemoryServer() {
674
+ const server = new McpServer({ name: "cairn-memory", version: "0.1.0" });
675
+ server.registerTool("memory_read", {
676
+ description: "Read an exact key or search memory entries across AgentFS scopes.",
677
+ // Plain object schema — a .refine() wrapper (ZodEffects) makes the SDK
678
+ // publish an empty JSON Schema, hiding the parameters from clients.
679
+ inputSchema: z.object({
680
+ scope: z.string(),
681
+ key: z.string().optional(),
682
+ query: z.string().optional(),
683
+ }),
684
+ annotations: {
685
+ readOnlyHint: true,
686
+ idempotentHint: true,
687
+ },
688
+ }, async ({ scope, key, query }) => {
689
+ if (Boolean(key) === Boolean(query)) {
690
+ throw new Error("Provide exactly one of key or query.");
691
+ }
692
+ const config = getMemoryConfig();
693
+ const scopes = getSearchScopes(scope, config);
694
+ const results = key
695
+ ? (await Promise.all(scopes.map((candidate) => readKey(candidate, key)))).flat()
696
+ : searchEntries((await Promise.all(scopes.map((candidate) => listEntries(candidate)))).flat(), query ?? "");
697
+ const sorted = results.sort((left, right) => {
698
+ return `${left.scope}:${left.key}`.localeCompare(`${right.scope}:${right.key}`);
699
+ });
700
+ return {
701
+ content: [{ type: "text", text: asToolText(sorted) }],
702
+ structuredContent: { results: sorted },
703
+ };
704
+ });
705
+ server.registerTool("memory_write", {
706
+ description: "Write a memory entry to a scoped AgentFS database and optionally promote it.",
707
+ inputSchema: z.object({
708
+ scope: z.string(),
709
+ key: z.string().min(1),
710
+ value: z.string(),
711
+ promote_to: z.string().optional(),
712
+ }),
713
+ }, async ({ scope, key, value, promote_to }) => {
714
+ if (isHistoryKey(key)) {
715
+ throw new Error(`Keys under ${HISTORY_NAMESPACE}/ are reserved for memory history.`);
716
+ }
717
+ const targets = promote_to && promote_to !== scope ? [scope, promote_to] : [scope];
718
+ // Collision-safe: in the unified store, writes from different repos/machines
719
+ // can share a key. If a different value already exists, preserve the old one
720
+ // into history before overwriting so no memory is ever lost. Identical-value
721
+ // writes are a no-op. The response surfaces any collision so it can be
722
+ // disambiguated (rename keys, memory_history to recover).
723
+ const collisions = [];
724
+ for (const target of targets) {
725
+ const agent = await openScope(target, true);
726
+ if (!agent) {
727
+ throw new Error(`Unable to open scope ${target}.`);
728
+ }
729
+ try {
730
+ const previous = await agent.kv.get(key);
731
+ const previousNorm = previous === undefined ? undefined : normalizeValue(previous);
732
+ if (previousNorm !== undefined && previousNorm !== value) {
733
+ const supersededAt = new Date().toISOString();
734
+ const snapshotKey = historySnapshotKey(key, supersededAt);
735
+ await agent.kv.set(snapshotKey, {
736
+ value: previousNorm,
737
+ superseded_at: supersededAt,
738
+ superseded_reason: "collision-safe write in unified store",
739
+ });
740
+ collisions.push({ scope: target, snapshot_key: snapshotKey, previous_value: previousNorm });
741
+ }
742
+ await agent.kv.set(key, value);
743
+ }
744
+ finally {
745
+ await agent.close();
746
+ }
747
+ }
748
+ const payload = { ok: true, scope, key, promote_to, collisions };
749
+ return {
750
+ content: [{ type: "text", text: asToolText(payload) }],
751
+ structuredContent: payload,
752
+ };
753
+ });
754
+ server.registerTool("memory_list", {
755
+ description: "List keys from a scoped AgentFS database.",
756
+ inputSchema: z.object({
757
+ scope: z.string(),
758
+ prefix: z.string().optional(),
759
+ }),
760
+ annotations: {
761
+ readOnlyHint: true,
762
+ idempotentHint: true,
763
+ },
764
+ }, async ({ scope, prefix }) => {
765
+ const entries = await listEntries(scope, prefix ?? "");
766
+ const keys = entries.map((entry) => entry.key).sort();
767
+ return {
768
+ content: [{ type: "text", text: asToolText(keys) }],
769
+ structuredContent: { keys },
770
+ };
771
+ });
772
+ server.registerTool("memory_delete", {
773
+ description: "Delete a key from a scoped AgentFS database.",
774
+ inputSchema: z.object({
775
+ scope: z.string(),
776
+ key: z.string().min(1),
777
+ }),
778
+ }, async ({ scope, key }) => {
779
+ const agent = await openScope(scope, false);
780
+ if (agent) {
781
+ try {
782
+ await agent.kv.delete(key);
783
+ }
784
+ finally {
785
+ await agent.close();
786
+ }
787
+ }
788
+ return {
789
+ content: [{ type: "text", text: asToolText({ ok: true, scope, key }) }],
790
+ structuredContent: { ok: true, scope, key },
791
+ };
792
+ });
793
+ server.registerTool("memory_search", {
794
+ description: "Semantic search across AgentFS memory scopes using the configured embedding endpoint, ranked by cosine similarity. Falls back to substring matching when embeddings are unavailable. Use this to find memory by meaning rather than by exact key.",
795
+ inputSchema: z.object({
796
+ scope: z.string(),
797
+ query: z.string().min(1),
798
+ top_k: z.number().int().min(1).max(50).optional(),
799
+ min_score: z.number().min(0).max(1).optional(),
800
+ }),
801
+ annotations: {
802
+ readOnlyHint: true,
803
+ },
804
+ }, async ({ scope, query, top_k, min_score }) => {
805
+ const { results, mode, model } = await semanticSearch(scope, query, top_k ?? 8, min_score ?? 0);
806
+ const payload = { mode, model, count: results.length, results };
807
+ return {
808
+ content: [{ type: "text", text: asToolText(payload) }],
809
+ structuredContent: payload,
810
+ };
811
+ });
812
+ server.registerTool("memory_extract", {
813
+ description: "Extract durable memory candidates from a session summary or selected text. Review the returned candidates before writing them.",
814
+ inputSchema: z.object({
815
+ scope: z.string(),
816
+ content: z.string().min(1),
817
+ model: z.string().min(1).optional(),
818
+ category: z.enum(["decision", "preference", "pattern", "pitfall", "constraint", "bug", "convention"]).optional(),
819
+ }),
820
+ }, async ({ scope, content, model, category }) => {
821
+ const extracted = await extractMemoryCandidates(content, model, category);
822
+ const payload = {
823
+ scope,
824
+ model: extracted.model,
825
+ count: extracted.candidates.length,
826
+ candidates: extracted.candidates.map((candidate) => ({ scope, ...candidate })),
827
+ };
828
+ return {
829
+ content: [{ type: "text", text: asToolText(payload) }],
830
+ structuredContent: payload,
831
+ };
832
+ });
833
+ server.registerTool("memory_supersede", {
834
+ description: "Preserve the current value of a memory entry in hidden history, then write a new live value to the base key.",
835
+ inputSchema: z.object({
836
+ scope: z.string(),
837
+ key: z.string().min(1),
838
+ value: z.string(),
839
+ reason: z.string().optional(),
840
+ }),
841
+ }, async ({ scope, key, value, reason }) => {
842
+ if (isHistoryKey(key)) {
843
+ throw new Error(`Keys under ${HISTORY_NAMESPACE}/ are reserved for memory history.`);
844
+ }
845
+ const agent = await openScope(scope, true);
846
+ if (!agent) {
847
+ throw new Error(`Unable to open scope ${scope}.`);
848
+ }
849
+ try {
850
+ const previous = await agent.kv.get(key);
851
+ if (previous === undefined) {
852
+ await agent.kv.set(key, value);
853
+ const payload = { ok: true, scope, key, created: true, snapshot_key: null };
854
+ return {
855
+ content: [{ type: "text", text: asToolText(payload) }],
856
+ structuredContent: payload,
857
+ };
858
+ }
859
+ const supersededAt = new Date().toISOString();
860
+ const snapshotKey = historySnapshotKey(key, supersededAt);
861
+ await agent.kv.set(snapshotKey, {
862
+ value: normalizeValue(previous),
863
+ superseded_at: supersededAt,
864
+ superseded_reason: reason ?? null,
865
+ });
866
+ await agent.kv.set(key, value);
867
+ const payload = {
868
+ ok: true,
869
+ scope,
870
+ key,
871
+ created: false,
872
+ snapshot_key: snapshotKey,
873
+ previous_value: normalizeValue(previous),
874
+ };
875
+ return {
876
+ content: [{ type: "text", text: asToolText(payload) }],
877
+ structuredContent: payload,
878
+ };
879
+ }
880
+ finally {
881
+ await agent.close();
882
+ }
883
+ });
884
+ server.registerTool("memory_history", {
885
+ description: "Read prior versions of a memory entry from the hidden history namespace.",
886
+ inputSchema: z.object({
887
+ scope: z.string(),
888
+ key: z.string().min(1),
889
+ }),
890
+ annotations: {
891
+ readOnlyHint: true,
892
+ idempotentHint: true,
893
+ },
894
+ }, async ({ scope, key }) => {
895
+ const current = await readKey(scope, key);
896
+ const history = (await listEntries(scope, historyPrefix(key), { includeHistory: true }))
897
+ .sort((left, right) => left.key.localeCompare(right.key));
898
+ const payload = {
899
+ scope,
900
+ key,
901
+ current: current[0]?.value ?? null,
902
+ history,
903
+ };
904
+ return {
905
+ content: [{ type: "text", text: asToolText(payload) }],
906
+ structuredContent: payload,
907
+ };
908
+ });
909
+ server.registerTool("domain_knowledge_query", {
910
+ description: "Query an AnythingLLM workspace in query mode for domain knowledge.",
911
+ inputSchema: z.object({
912
+ workspace: z.string().min(1),
913
+ query: z.string().min(1),
914
+ }),
915
+ annotations: {
916
+ readOnlyHint: true,
917
+ },
918
+ }, async ({ workspace, query }) => {
919
+ const answer = await callAnythingLLM(workspace, query);
920
+ return {
921
+ content: [{ type: "text", text: answer }],
922
+ structuredContent: { workspace, answer },
923
+ };
924
+ });
925
+ server.registerTool("domain_knowledge_sync", {
926
+ description: "Upload and embed configured project documentation into an AnythingLLM workspace. Uses anythingllm-projects.json so include/exclude rules are honored. Use mode='replace' with confirm_replace=true when stale workspace docs must be removed before re-embedding.",
927
+ inputSchema: z.object({
928
+ workspace: z.string().min(1).optional(),
929
+ mode: z.enum(["incremental", "full", "replace"]).optional(),
930
+ confirm_replace: z.boolean().optional(),
931
+ timeout_seconds: z.number().int().min(30).max(3600).optional(),
932
+ }),
933
+ }, async ({ workspace, mode, confirm_replace, timeout_seconds }) => {
934
+ const syncMode = mode ?? "incremental";
935
+ const config = getMemoryConfig();
936
+ const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(config);
937
+ if (!workspaceSlug) {
938
+ throw new Error("No AnythingLLM workspace provided and no project workspace found in memory config.");
939
+ }
940
+ if (syncMode === "replace" && confirm_replace !== true) {
941
+ throw new Error("mode='replace' removes currently embedded workspace docs. Set confirm_replace=true to proceed.");
942
+ }
943
+ const args = [
944
+ anythingllmSyncScript,
945
+ "--project", workspaceSlug,
946
+ ];
947
+ if (syncMode === "full") {
948
+ args.push("--full");
949
+ }
950
+ else if (syncMode === "replace") {
951
+ args.push("--replace");
952
+ }
953
+ const result = await runCommand("python3", args, (timeout_seconds ?? 900) * 1000);
954
+ const ok = result.exitCode === 0 && !result.timedOut;
955
+ return {
956
+ content: [{
957
+ type: "text",
958
+ text: asToolText({
959
+ ok,
960
+ workspace: workspaceSlug,
961
+ mode: syncMode,
962
+ ...result,
963
+ }),
964
+ }],
965
+ structuredContent: {
966
+ ok,
967
+ workspace: workspaceSlug,
968
+ mode: syncMode,
969
+ ...result,
970
+ },
971
+ };
972
+ });
973
+ server.registerTool("context_explore", {
974
+ description: "Delegate a natural-language repo-exploration query to the external token_miser explore binary (FastContext-backed). Returns compact path:line-range citations. Requires CAIRN_EXPLORE_BINARY (absolute path to the token_miser binary) and a repo_root (per-call param or CAIRN_EXPLORE_REPO_ROOT env). Thin adapter — token_miser owns all exploration logic.",
975
+ inputSchema: z.object({
976
+ query: z.string().min(1),
977
+ repo_root: z.string().min(1).optional(),
978
+ timeout_seconds: z.number().int().min(10).max(600).optional(),
979
+ }),
980
+ }, async ({ query, repo_root, timeout_seconds }) => {
981
+ // repo_root resolution stays here (tool-specific: per-call param vs
982
+ // CAIRN_EXPLORE_REPO_ROOT env) -- runContextExplore receives an
983
+ // already-resolved absolute path and owns the shared precondition/
984
+ // execution/cache tiers (D-06).
985
+ const rawRoot = repo_root ?? process.env.CAIRN_EXPLORE_REPO_ROOT;
986
+ if (!rawRoot) {
987
+ throw new Error("No repo_root provided and CAIRN_EXPLORE_REPO_ROOT is not set.");
988
+ }
989
+ // Always resolve to an ABSOLUTE path before it crosses the process
990
+ // boundary (Pitfall #3, D-01) — a relative repo_root would resolve
991
+ // against runCommand's hardcoded cwd (infraRoot), not the caller's intent.
992
+ const resolvedRoot = resolve(expandHome(rawRoot));
993
+ const { payload, text } = await runContextExplore({
994
+ query,
995
+ repoRoot: resolvedRoot,
996
+ timeoutSeconds: timeout_seconds,
997
+ });
998
+ return {
999
+ content: [{ type: "text", text }],
1000
+ structuredContent: payload,
1001
+ };
1002
+ });
1003
+ server.registerTool("route_check", {
1004
+ description: "Check reachability of the external token_miser routing/tiering proxy via its /health endpoint. Requires CAIRN_ROUTE_ENDPOINT (base URL of an already-running token_miser instance). Thin adapter — token_miser owns all routing/tiering logic; this tool neither hosts a proxy nor learns which tier serves a request.",
1005
+ inputSchema: z.object({
1006
+ timeout_seconds: z.number().int().min(1).max(60).optional(),
1007
+ }),
1008
+ }, async ({ timeout_seconds }) => {
1009
+ // --- Precondition tier: throw ---
1010
+ const rawEndpoint = process.env.CAIRN_ROUTE_ENDPOINT;
1011
+ if (!rawEndpoint) {
1012
+ throw new Error("CAIRN_ROUTE_ENDPOINT is not set.");
1013
+ }
1014
+ let endpoint;
1015
+ try {
1016
+ endpoint = new URL(rawEndpoint);
1017
+ }
1018
+ catch {
1019
+ throw new Error(`CAIRN_ROUTE_ENDPOINT is not a valid URL: ${rawEndpoint}`);
1020
+ }
1021
+ const base = endpoint.toString().replace(/\/+$/, "");
1022
+ // --- Execution tier: return { ok: false, ... } ---
1023
+ let response;
1024
+ try {
1025
+ response = await fetch(`${base}/health`, {
1026
+ signal: AbortSignal.timeout((timeout_seconds ?? 10) * 1000),
1027
+ });
1028
+ }
1029
+ catch (e) {
1030
+ const payload = {
1031
+ ok: false,
1032
+ error: e instanceof Error && e.name === "TimeoutError"
1033
+ ? "token_miser /health timed out"
1034
+ : "token_miser /health request failed",
1035
+ detail: e instanceof Error ? e.message : String(e),
1036
+ };
1037
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1038
+ }
1039
+ if (!response.ok) {
1040
+ const payload = { ok: false, error: "token_miser /health returned non-2xx", status: response.status };
1041
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1042
+ }
1043
+ let body;
1044
+ try {
1045
+ body = await response.json();
1046
+ }
1047
+ catch {
1048
+ const payload = { ok: false, error: "malformed /health JSON" };
1049
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1050
+ }
1051
+ const payload = { ok: true, status: body.status, cluster_healthy: body.cluster_healthy ?? null };
1052
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1053
+ });
1054
+ return server;
1055
+ }
1056
+ // One-shot CLI: `node dist/index.js wakeup` prints project-scope memory for the
1057
+ // SessionStart hook. Reads a file-snapshot copy of ./.agentfs/project.db so it
1058
+ // never contends with a running cairn-memory MCP that holds the exclusive lock
1059
+ // (AgentFS/Turso uses SQLite locking). Best-effort: silent + exit 0 on any
1060
+ // error or outside a managed repo, so it is safe to call from anywhere.
1061
+ const cliCommand = process.argv[2];
1062
+ if (cliCommand === "wakeup") {
1063
+ try {
1064
+ const src = resolveScopePath("project");
1065
+ if (existsSync(src)) {
1066
+ const snapshotDir = mkdtempSync(join(tmpdir(), "wakeup-"));
1067
+ const copy = join(snapshotDir, "project.db");
1068
+ for (const suffix of ["", "-wal", "-shm"]) {
1069
+ if (existsSync(src + suffix)) {
1070
+ copyFileSync(src + suffix, copy + suffix);
1071
+ }
1072
+ }
1073
+ const agent = await AgentFS.open({ id: "project", path: copy });
1074
+ try {
1075
+ const entries = await agent.kv.list("");
1076
+ const visible = entries.filter(({ key }) => !isHistoryKey(key));
1077
+ if (visible.length) {
1078
+ // Compact index: key + one-line preview. The agent pulls full
1079
+ // detail on demand with memory_read / memory_search, so this
1080
+ // stays small even when the project DB holds many facts.
1081
+ const lines = visible.map(({ key, value }) => {
1082
+ const preview = normalizeValue(value).replace(/\s+/g, " ").slice(0, 100);
1083
+ return `- ${key}: ${preview}`;
1084
+ });
1085
+ const header = `(${visible.length} project memory facts; use /recall or memory_read for full detail)`;
1086
+ process.stdout.write(truncateOutput([header, ...lines].join("\n"), 4000) + "\n");
1087
+ }
1088
+ }
1089
+ finally {
1090
+ await agent.close();
1091
+ rmSync(snapshotDir, { recursive: true, force: true });
1092
+ }
1093
+ }
1094
+ }
1095
+ catch {
1096
+ // Best-effort wakeup: never fail a session start over memory retrieval.
1097
+ }
1098
+ process.exit(0);
1099
+ }
1100
+ if (cliCommand === "extract") {
1101
+ try {
1102
+ const model = process.argv[3]?.trim() || undefined;
1103
+ const category = process.argv[4]?.trim() || undefined;
1104
+ const content = (await readStdin()).trim();
1105
+ if (!content) {
1106
+ throw new Error("No input provided on stdin.");
1107
+ }
1108
+ const extracted = await extractMemoryCandidates(content, model, category);
1109
+ output.write(`${JSON.stringify({
1110
+ model: extracted.model,
1111
+ count: extracted.candidates.length,
1112
+ candidates: extracted.candidates,
1113
+ }, null, 2)}\n`);
1114
+ process.exit(0);
1115
+ }
1116
+ catch (error) {
1117
+ const message = error instanceof Error ? error.message : String(error);
1118
+ process.stderr.write(`${message}\n`);
1119
+ process.exit(1);
1120
+ }
1121
+ }
1122
+ // One-shot CLI: `node dist/index.js explore "<query>"` runs the SAME
1123
+ // runContextExplore path as the MCP tool handler (D-06) -- a pre-task hook
1124
+ // invokes this without an MCP session. Short internal timeout (well inside a
1125
+ // hook's own budget) so a cache-miss query never outlives the caller's patience.
1126
+ if (cliCommand === "explore") {
1127
+ try {
1128
+ const query = process.argv[3];
1129
+ if (!query) {
1130
+ throw new Error('Usage: node dist/index.js explore "<query>"');
1131
+ }
1132
+ const rawRoot = process.env.CAIRN_EXPLORE_REPO_ROOT;
1133
+ const repoRoot = rawRoot ? resolve(expandHome(rawRoot)) : gitToplevel(process.cwd());
1134
+ const { payload } = await runContextExplore({ query, repoRoot, timeoutSeconds: 20 });
1135
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
1136
+ process.exit(0);
1137
+ }
1138
+ catch (error) {
1139
+ const message = error instanceof Error ? error.message : String(error);
1140
+ process.stderr.write(`${message}\n`);
1141
+ process.exit(1);
1142
+ }
1143
+ }
1144
+ const httpPort = parseInt(process.env.MCP_HTTP_PORT ?? "", 10);
1145
+ if (httpPort > 0) {
1146
+ const httpHost = process.env.MCP_HTTP_HOST ?? "127.0.0.1";
1147
+ // HTTP mode exposes every memory tool over the network, so it is guarded:
1148
+ // a bearer token is mandatory (fail closed), CORS is opt-in per origin, and
1149
+ // the Host header is validated to block DNS-rebinding. See docs/operating.md.
1150
+ const httpToken = process.env.CAIRN_MEMORY_HTTP_TOKEN?.trim();
1151
+ if (!httpToken) {
1152
+ process.stderr.write("cairn-memory: HTTP mode requires CAIRN_MEMORY_HTTP_TOKEN — refusing to start an unauthenticated network server.\n");
1153
+ process.exit(1);
1154
+ }
1155
+ const allowedOrigins = (process.env.CAIRN_MEMORY_HTTP_ALLOWED_ORIGINS ?? "")
1156
+ .split(",").map((value) => value.trim()).filter(Boolean);
1157
+ const configuredHosts = (process.env.CAIRN_MEMORY_HTTP_ALLOWED_HOSTS ?? "")
1158
+ .split(",").map((value) => value.trim()).filter(Boolean);
1159
+ const allowedHosts = new Set(configuredHosts.length > 0
1160
+ ? configuredHosts
1161
+ : [`${httpHost}:${httpPort}`, `localhost:${httpPort}`, `127.0.0.1:${httpPort}`]);
1162
+ const tokenMatches = (header) => {
1163
+ const prefix = "Bearer ";
1164
+ if (!header || !header.startsWith(prefix)) {
1165
+ return false;
1166
+ }
1167
+ const provided = Buffer.from(header.slice(prefix.length));
1168
+ const expected = Buffer.from(httpToken);
1169
+ // Length check first: timingSafeEqual throws on length mismatch.
1170
+ return provided.length === expected.length && timingSafeEqual(provided, expected);
1171
+ };
1172
+ const originAllowed = (origin) => origin && allowedOrigins.includes(origin) ? origin : null;
1173
+ // Session-based streamable HTTP: one transport per session, keyed by the
1174
+ // mcp-session-id header the client sends after initialize. This is how real
1175
+ // remote MCP servers work (e.g. context7). Lets a long-lived process serve
1176
+ // many clients/sessions against one AgentFS store.
1177
+ const sessions = new Map();
1178
+ const handleWeb = async (request) => {
1179
+ const sessionId = request.headers.get("mcp-session-id") ?? undefined;
1180
+ const existing = sessionId ? sessions.get(sessionId) : undefined;
1181
+ if (existing) {
1182
+ return existing.handleRequest(request);
1183
+ }
1184
+ // New session (first request = initialize). The transport mints a session
1185
+ // id and the SDK returns it via the response header; client echoes it back.
1186
+ const transport = new WebStandardStreamableHTTPServerTransport({
1187
+ sessionIdGenerator: () => randomUUID(),
1188
+ onsessioninitialized: (id) => { sessions.set(id, transport); },
1189
+ onsessionclosed: (id) => { sessions.delete(id); },
1190
+ });
1191
+ const session = createMemoryServer();
1192
+ await session.connect(transport);
1193
+ return transport.handleRequest(request);
1194
+ };
1195
+ const httpServer = createServer(async (req, res) => {
1196
+ const allowOrigin = originAllowed(req.headers.origin);
1197
+ if (allowOrigin) {
1198
+ res.setHeader("Access-Control-Allow-Origin", allowOrigin);
1199
+ res.setHeader("Vary", "Origin");
1200
+ res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS");
1201
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, Accept, Authorization");
1202
+ }
1203
+ if (req.method === "OPTIONS") {
1204
+ res.writeHead(allowOrigin ? 204 : 403).end();
1205
+ return;
1206
+ }
1207
+ // DNS-rebinding protection: only serve requests whose Host we expect.
1208
+ if (!req.headers.host || !allowedHosts.has(req.headers.host)) {
1209
+ res.writeHead(403).end("host not allowed");
1210
+ return;
1211
+ }
1212
+ // Authentication: a valid bearer token is mandatory on every request.
1213
+ if (!tokenMatches(req.headers.authorization)) {
1214
+ res.writeHead(401, { "WWW-Authenticate": "Bearer" }).end("unauthorized");
1215
+ return;
1216
+ }
1217
+ try {
1218
+ const headers = new Headers(req.headers);
1219
+ let body = null;
1220
+ if (req.method !== "GET" && req.method !== "DELETE") {
1221
+ const chunks = [];
1222
+ for await (const chunk of req)
1223
+ chunks.push(chunk);
1224
+ body = Buffer.concat(chunks);
1225
+ }
1226
+ const request = new Request(`http://${req.headers.host}${req.url}`, {
1227
+ method: req.method,
1228
+ headers,
1229
+ body,
1230
+ });
1231
+ const response = await handleWeb(request);
1232
+ const outHeaders = {};
1233
+ response.headers.forEach((v, k) => { outHeaders[k] = v; });
1234
+ res.writeHead(response.status, outHeaders);
1235
+ res.end(Buffer.from(await response.arrayBuffer()));
1236
+ }
1237
+ catch (err) {
1238
+ res.writeHead(500).end(err instanceof Error ? err.message : String(err));
1239
+ }
1240
+ });
1241
+ httpServer.listen(httpPort, httpHost, () => {
1242
+ process.stderr.write(`cairn-memory MCP (streamable HTTP) listening on ${httpHost}:${httpPort}\n`);
1243
+ });
1244
+ process.on("SIGINT", async () => { httpServer.close(); for (const t of sessions.values()) {
1245
+ await t.close();
1246
+ } process.exit(0); });
1247
+ }
1248
+ else {
1249
+ const server = createMemoryServer();
1250
+ const transport = new StdioServerTransport();
1251
+ process.on("SIGINT", async () => {
1252
+ await server.close();
1253
+ process.exit(0);
1254
+ });
1255
+ await server.connect(transport);
1256
+ }