@cairnkeep/cli 2.8.0 → 2.11.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 (89) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +82 -27
  3. package/bin/cairn +6 -153
  4. package/bin/cairn-container +8 -0
  5. package/docs/building-an-overlay.md +41 -4
  6. package/docs/containers.md +10 -0
  7. package/docs/context-packs.md +94 -0
  8. package/docs/harness-compatibility.md +37 -3
  9. package/docs/learning/CURRICULUM-MAP.md +16 -5
  10. package/docs/learning/FEATURE-GUIDE.md +15 -4
  11. package/docs/learning/README.md +8 -2
  12. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  13. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  14. package/docs/learning/lessons/L02-installation.md +11 -2
  15. package/docs/learning/lessons/L03-first-project.md +1 -1
  16. package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
  17. package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
  18. package/docs/learning/lessons/L21-context-packs.md +79 -0
  19. package/docs/learning/lessons/L22-native-windows.md +83 -0
  20. package/docs/learning/lessons/L23-guided-setup.md +138 -0
  21. package/docs/learning/tracks/operator.md +10 -3
  22. package/docs/learning/tracks/quickstart.md +7 -3
  23. package/docs/mcp-tool-profiles.md +43 -0
  24. package/docs/native-windows.md +122 -0
  25. package/docs/operating.md +157 -18
  26. package/docs/privacy-and-data-flow.md +51 -1
  27. package/docs/storage.md +29 -0
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
  30. package/mcp-memory-server/dist/artifact-store.js +196 -55
  31. package/mcp-memory-server/dist/capability-cli.js +2 -1
  32. package/mcp-memory-server/dist/capability-config.js +4 -3
  33. package/mcp-memory-server/dist/capability-harness.js +34 -7
  34. package/mcp-memory-server/dist/context-pack-cli.js +185 -0
  35. package/mcp-memory-server/dist/context-pack.js +889 -0
  36. package/mcp-memory-server/dist/eval-cli.js +6 -5
  37. package/mcp-memory-server/dist/eval-plan.js +35 -13
  38. package/mcp-memory-server/dist/eval-process.js +11 -4
  39. package/mcp-memory-server/dist/eval-report.js +8 -5
  40. package/mcp-memory-server/dist/eval-runner.js +2 -0
  41. package/mcp-memory-server/dist/eval-schema.js +1 -0
  42. package/mcp-memory-server/dist/graph-cli.js +7 -1
  43. package/mcp-memory-server/dist/index.js +87 -37
  44. package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
  45. package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
  46. package/mcp-memory-server/dist/mcp-tool-profile.js +164 -0
  47. package/mcp-memory-server/dist/note-store.js +12 -3
  48. package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
  49. package/mcp-memory-server/dist/platform-security.js +102 -0
  50. package/mcp-memory-server/dist/skill-store.js +27 -12
  51. package/mcp-memory-server/package.json +4 -1
  52. package/package.json +5 -4
  53. package/pi/extensions/cairnkeep-memory.ts +61 -0
  54. package/schemas/cairnkeep-setup-policy.schema.json +55 -0
  55. package/schemas/cairnkeep-setup.schema.json +36 -0
  56. package/schemas/context-pack.schema.json +33 -0
  57. package/schemas/eval-report.schema.json +1 -0
  58. package/scripts/cairn-cli.mjs +119 -0
  59. package/scripts/cairn-container-cli.mjs +121 -0
  60. package/scripts/cairn-hook.mjs +199 -0
  61. package/scripts/completion.sh +33 -5
  62. package/scripts/doctor.sh +96 -0
  63. package/scripts/fix-permissions.mjs +12 -0
  64. package/scripts/memory-wiki-audit.mjs +95 -0
  65. package/scripts/phase26-test-manifest.mjs +26 -0
  66. package/scripts/run-repository-tests.mjs +76 -0
  67. package/scripts/setup-core.mjs +310 -0
  68. package/scripts/setup-reconcile.mjs +331 -0
  69. package/scripts/setup.mjs +306 -0
  70. package/scripts/sync-pi-assets.sh +19 -4
  71. package/scripts/test-cli-dispatch.sh +6 -0
  72. package/scripts/test-completion.sh +44 -0
  73. package/scripts/test-doctor.sh +66 -0
  74. package/scripts/test-learning-docs.sh +53 -3
  75. package/scripts/test-package-install.sh +81 -0
  76. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  77. package/scripts/test-phase18-capability-lifecycle.sh +1 -1
  78. package/scripts/test-pi-lifecycle.mjs +190 -0
  79. package/scripts/test-setup-compatibility.mjs +186 -0
  80. package/scripts/test-setup-output.sh +131 -0
  81. package/scripts/test-setup-overlay.sh +113 -0
  82. package/scripts/test-setup-preflight.mjs +281 -0
  83. package/scripts/test-setup-reconcile.mjs +257 -0
  84. package/scripts/test-uninstall.sh +71 -0
  85. package/scripts/test-windows-native.mjs +136 -0
  86. package/scripts/uninstall.sh +95 -7
  87. package/scripts/verify-pi-mcp-bridge.mjs +588 -0
  88. package/scripts/windows-platform.mjs +725 -0
  89. package/templates/env.example.template +11 -0
@@ -7,6 +7,7 @@ import { isEvalEnabled, loadEvalPlan } from "./eval-plan.js";
7
7
  import { diagnoseEvalReport, readEvalReport, renderEvalReport, } from "./eval-report.js";
8
8
  import { buildAblationArms, runCapabilityAblation, runTwoPassExperiment, } from "./eval-runner.js";
9
9
  import { canonicalJson, EVAL_SCHEMA_VERSION } from "./eval-schema.js";
10
+ import { privatePathIsSafe } from "./platform-security.js";
10
11
  process.stdout.on("error", (error) => {
11
12
  if (error.code === "EPIPE")
12
13
  process.exit(0);
@@ -126,7 +127,7 @@ async function evalRoot() {
126
127
  return null;
127
128
  throw error;
128
129
  }
129
- if (info.isSymbolicLink() || !info.isDirectory() || (info.mode & 0o077) !== 0) {
130
+ if (info.isSymbolicLink() || !info.isDirectory() || !privatePathIsSafe(root)) {
130
131
  throw new Error("unsafe_eval_root");
131
132
  }
132
133
  const resolvedRoot = await realpath(root);
@@ -185,7 +186,7 @@ async function diagnoseProjectReports(projectRoot) {
185
186
  }
186
187
  if (info.isSymbolicLink() || !info.isDirectory() || await realpath(directory) !== directory)
187
188
  return "unsafe";
188
- if (directory === root && (info.mode & 0o077) !== 0)
189
+ if (directory === root && !privatePathIsSafe(directory))
189
190
  return "unsafe";
190
191
  }
191
192
  const entries = await readdir(root, { withFileTypes: true });
@@ -201,7 +202,7 @@ async function diagnoseProjectReports(projectRoot) {
201
202
  if (!isContained(root, experimentPath) || experimentPath === root)
202
203
  return "unsafe";
203
204
  const experimentInfo = await lstat(experimentPath);
204
- if (experimentInfo.isSymbolicLink() || !experimentInfo.isDirectory() || (experimentInfo.mode & 0o077) !== 0
205
+ if (experimentInfo.isSymbolicLink() || !experimentInfo.isDirectory() || !privatePathIsSafe(experimentPath)
205
206
  || await realpath(experimentPath) !== experimentPath) {
206
207
  return "unsafe";
207
208
  }
@@ -247,7 +248,7 @@ async function safeReportStore(experimentId) {
247
248
  throw new Error("eval_report_not_found");
248
249
  throw error;
249
250
  }
250
- if (directory.isSymbolicLink() || !directory.isDirectory() || (directory.mode & 0o077) !== 0
251
+ if (directory.isSymbolicLink() || !directory.isDirectory() || !privatePathIsSafe(experimentPath)
251
252
  || await realpath(experimentPath) !== experimentPath) {
252
253
  throw new Error("unsafe_experiment_path");
253
254
  }
@@ -261,7 +262,7 @@ async function safeReportStore(experimentId) {
261
262
  throw new Error("eval_report_not_found");
262
263
  throw error;
263
264
  }
264
- if (reportInfo.isSymbolicLink() || !reportInfo.isFile() || (reportInfo.mode & 0o077) !== 0
265
+ if (reportInfo.isSymbolicLink() || !reportInfo.isFile() || !privatePathIsSafe(reportPath)
265
266
  || reportInfo.size > DEFAULT_REPORT_BYTES) {
266
267
  throw new Error("unsafe_eval_report");
267
268
  }
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
3
  import { accessSync, closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync, } from "node:fs";
4
- import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { CAPABILITY_IDS } from "./capability-schema.js";
7
7
  import { EVAL_SCHEMA_VERSION, canonicalDigest, canonicalJson, evalAdapterConfigSchema, evalTaskSetSchema, } from "./eval-schema.js";
@@ -16,6 +16,19 @@ function isContained(root, candidate) {
16
16
  const rel = relative(root, candidate);
17
17
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
18
18
  }
19
+ function isExistingPathContained(root, candidate) {
20
+ const rootInfo = lstatSync(root);
21
+ let current = realpathSync(candidate);
22
+ for (;;) {
23
+ const currentInfo = lstatSync(current);
24
+ if (currentInfo.dev === rootInfo.dev && currentInfo.ino === rootInfo.ino)
25
+ return true;
26
+ const parent = dirname(current);
27
+ if (parent === current)
28
+ return false;
29
+ current = parent;
30
+ }
31
+ }
19
32
  function assertRealDirectory(path, label) {
20
33
  const info = lstatSync(path);
21
34
  if (info.isSymbolicLink() || !info.isDirectory())
@@ -68,12 +81,16 @@ function resolveExactCommit(repository, revision) {
68
81
  return resolved;
69
82
  }
70
83
  function parseCommittedTaskSet(taskSetPath) {
71
- const manifestRepository = assertRealDirectory(git(dirname(taskSetPath), ["rev-parse", "--show-toplevel"], "Resolve task-set repository"), "Task-set repository");
72
- if (!isContained(manifestRepository, taskSetPath))
84
+ const manifestDirectory = dirname(taskSetPath);
85
+ const manifestRepository = assertRealDirectory(git(manifestDirectory, ["rev-parse", "--show-toplevel"], "Resolve task-set repository"), "Task-set repository");
86
+ if (!isExistingPathContained(manifestRepository, taskSetPath)) {
73
87
  throw new Error("The task-set manifest must be contained by its Git repository.");
74
- const manifestPath = relative(manifestRepository, taskSetPath).split(sep).join("/");
75
- if (!manifestPath || manifestPath.startsWith("../"))
88
+ }
89
+ const manifestPrefix = git(manifestDirectory, ["rev-parse", "--show-prefix"], "Resolve task-set repository prefix");
90
+ const manifestPath = `${manifestPrefix}${basename(taskSetPath)}`.split("\\").join("/");
91
+ if (!manifestPath || manifestPath.startsWith("../") || isAbsolute(manifestPath)) {
76
92
  throw new Error("The task-set manifest path is invalid.");
93
+ }
77
94
  git(manifestRepository, ["ls-files", "--error-unmatch", "--", manifestPath], "Require a tracked evaluation task set");
78
95
  const dirty = git(manifestRepository, ["status", "--porcelain", "--untracked-files=all", "--", manifestPath], "Check evaluation task set status");
79
96
  if (dirty !== "")
@@ -111,15 +128,20 @@ function resolvePathProgram(program, base) {
111
128
  function resolveProgram(program, base) {
112
129
  if (isAbsolute(program) || program.includes("/") || program.includes("\\"))
113
130
  return resolvePathProgram(program, base);
131
+ const names = process.platform === "win32" && extname(program) === ""
132
+ ? [program, ...[".EXE", ".COM"].map((extension) => `${program}${extension}`)]
133
+ : [program];
114
134
  for (const directory of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
115
- const candidate = join(directory, program);
116
- try {
117
- accessSync(candidate, constants.X_OK);
118
- if (lstatSync(candidate).isFile() || lstatSync(candidate).isSymbolicLink())
119
- return realpathSync(candidate);
120
- }
121
- catch {
122
- // Continue through PATH without executing the candidate.
135
+ for (const name of names) {
136
+ const candidate = join(directory, name);
137
+ try {
138
+ accessSync(candidate, constants.X_OK);
139
+ if (lstatSync(candidate).isFile() || lstatSync(candidate).isSymbolicLink())
140
+ return realpathSync(candidate);
141
+ }
142
+ catch {
143
+ // Continue through PATH without executing the candidate.
144
+ }
123
145
  }
124
146
  }
125
147
  throw new Error(`Evaluation program was not found on PATH: ${program}`);
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { evalAdapterRequestSchema, evalAdapterResultSchema, } from "./eval-schema.js";
3
3
  export const EVAL_PROCESS_ERROR_CODES = [
4
4
  "spawn_error",
@@ -33,7 +33,7 @@ export class EvalProcessError extends Error {
33
33
  const DEFAULT_KILL_GRACE_MS = 1_000;
34
34
  const MAX_TIMER_MS = 2_147_483_647;
35
35
  function terminationScope() {
36
- return process.platform === "win32" ? "child" : "process-group";
36
+ return process.platform === "win32" ? "process-tree" : "process-group";
37
37
  }
38
38
  function assertOptions(options) {
39
39
  if (!Number.isSafeInteger(options.timeout_ms) || options.timeout_ms <= 0 || options.timeout_ms > MAX_TIMER_MS) {
@@ -59,7 +59,9 @@ function signalChild(child, signal) {
59
59
  process.kill(-child.pid, signal);
60
60
  return true;
61
61
  }
62
- return child.kill(signal);
62
+ const args = ["/PID", String(child.pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])];
63
+ const result = spawnSync("taskkill.exe", args, { stdio: "ignore", windowsHide: true });
64
+ return result.status === 0 || child.kill(signal);
63
65
  }
64
66
  catch {
65
67
  try {
@@ -90,7 +92,12 @@ async function runCommandInternal(options) {
90
92
  let exitSignal = null;
91
93
  let graceTimer;
92
94
  let closeTimer;
93
- const child = spawn(options.command.program, options.command.args, {
95
+ // A shebang does not make a JavaScript file directly executable on
96
+ // Windows. Adapter contracts may point at JS programs, so launch
97
+ // those through the current runtime while preserving the declared
98
+ // command for hashing and provenance.
99
+ const javascriptProgram = /\.(?:cjs|mjs|js)$/i.test(options.command.program);
100
+ const child = spawn(javascriptProgram ? process.execPath : options.command.program, [...(javascriptProgram ? [options.command.program] : []), ...options.command.args], {
94
101
  cwd: options.cwd,
95
102
  env: options.env,
96
103
  shell: false,
@@ -1,9 +1,10 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm, } from "node:fs/promises";
3
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { canonicalDigest, canonicalJson, evalAggregateSchema, evalReportSchema, } from "./eval-schema.js";
6
6
  import { computeDifferenceInDifferences, computePairedEstimate, } from "./eval-statistics.js";
7
+ import { hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
7
8
  const DEFAULT_MAX_REPORT_BYTES = 16 * 1024 * 1024;
8
9
  const MAX_ALLOWED_REPORT_BYTES = 64 * 1024 * 1024;
9
10
  const DEFAULT_MAX_EXPERIMENTS = 10_000;
@@ -36,7 +37,7 @@ async function ensureSafeDirectory(path, privateMode = false) {
36
37
  if (info.isSymbolicLink() || !info.isDirectory())
37
38
  throw new Error("unsafe_report_directory");
38
39
  if (privateMode)
39
- await chmod(path, 0o700);
40
+ hardenPrivatePath(path);
40
41
  return;
41
42
  }
42
43
  catch (error) {
@@ -48,7 +49,7 @@ async function ensureSafeDirectory(path, privateMode = false) {
48
49
  throw new Error("unsafe_report_directory");
49
50
  await ensureSafeDirectory(parent, false);
50
51
  await mkdir(path, { mode: 0o700 });
51
- await chmod(path, 0o700);
52
+ hardenPrivatePath(path);
52
53
  }
53
54
  function assertLimits(maxReportBytes, maxExperiments) {
54
55
  if (!Number.isSafeInteger(maxReportBytes) || maxReportBytes < 1 || maxReportBytes > MAX_ALLOWED_REPORT_BYTES) {
@@ -206,8 +207,9 @@ export async function checkpointEvalReport(store, report, options = {}) {
206
207
  await handle.close();
207
208
  handle = undefined;
208
209
  injectFault(options, "after_close");
210
+ hardenPrivatePath(temporary);
209
211
  await rename(temporary, store.report_path);
210
- await chmod(store.report_path, 0o600);
212
+ hardenPrivatePath(store.report_path);
211
213
  injectFault(options, "after_rename");
212
214
  }
213
215
  finally {
@@ -239,7 +241,7 @@ export async function diagnoseEvalReport(store) {
239
241
  });
240
242
  if (!info)
241
243
  return { state: "absent", code: "report_absent" };
242
- if (info.isSymbolicLink() || !info.isFile() || (info.mode & 0o077) !== 0) {
244
+ if (info.isSymbolicLink() || !info.isFile() || !privatePathIsSafe(store.report_path)) {
243
245
  return { state: "unsafe", code: "report_unsafe" };
244
246
  }
245
247
  const report = await readEvalReport(store);
@@ -578,6 +580,7 @@ export function renderEvalReport(value) {
578
580
  `Cairn evaluation report: ${report.experiment_id}`,
579
581
  `status: ${report.status}`,
580
582
  `evidence: ${report.evidence.evidence_scope === "offline-framework" ? "framework-only" : "live-evaluation"}`,
583
+ `MCP tool profile digest: ${report.evidence.mcp_tool_profile_digest ?? "not-recorded"}`,
581
584
  "All effects are estimates; small samples, missing intervals, and intervals crossing zero are inconclusive.",
582
585
  "",
583
586
  "Aggregates",
@@ -10,6 +10,7 @@ import { canonicalDigest, evalReportSchema, evalReportRuntimeContractDigest, } f
10
10
  import { EvalProcessError, runBoundedCommand, runBoundedJsonAdapter, } from "./eval-process.js";
11
11
  import { checkpointEvalReport, createEvalReportStore, } from "./eval-report.js";
12
12
  import { applyEvalWorkspaceOverlay, cleanupEvalWorkspace, createEvalWorkspace, runTaskPreparation, runTaskVerifier, } from "./eval-workspace.js";
13
+ import { resolveMcpToolProfile } from "./mcp-tool-profile.js";
13
14
  const moduleDirectory = dirname(fileURLToPath(import.meta.url));
14
15
  const publishedReportSchemaPath = resolve(moduleDirectory, "..", "..", "schemas", "eval-report.schema.json");
15
16
  let reportSchemaDigestsCache;
@@ -735,6 +736,7 @@ function initialReport(plan, experimentId, fallbackExperimentKind) {
735
736
  package_version: plan.source.kind === "bundled_fake" ? plan.source.package_version : "0.1.0",
736
737
  runtime_id: "node-local",
737
738
  task_set_digest: plan.task_set_digest,
739
+ mcp_tool_profile_digest: resolveMcpToolProfile({ projectRoot: process.cwd() }).profile_digest,
738
740
  report_digest: canonicalDigest({ experiment_id: experimentId, plan_digest: plan.plan_digest }),
739
741
  schema_digests: [...evalReportSchemaDigests()],
740
742
  note_snapshot_digests: [],
@@ -309,6 +309,7 @@ const evidenceProvenanceSchema = z.strictObject({
309
309
  package_version: z.string().min(1).max(128),
310
310
  runtime_id: identifierSchema,
311
311
  task_set_digest: digestSchema,
312
+ mcp_tool_profile_digest: digestSchema.optional(),
312
313
  report_digest: digestSchema,
313
314
  schema_digests: z.array(digestSchema).length(2),
314
315
  note_snapshot_digests: z.array(digestSchema).max(MAX_TASKS),
@@ -90,7 +90,13 @@ function graphifyEnvironment() {
90
90
  return environment;
91
91
  }
92
92
  function executeGraphify(projectRoot, args, timeout) {
93
- const result = spawnSync("graphify", args, {
93
+ const configuredProgram = process.env.CAIRN_GRAPHIFY_BINARY?.trim();
94
+ const graphifyProgram = configuredProgram || "graphify";
95
+ const javascriptProgram = /\.(?:cjs|mjs|js)$/i.test(graphifyProgram);
96
+ const result = spawnSync(javascriptProgram ? process.execPath : graphifyProgram, [
97
+ ...(javascriptProgram ? [graphifyProgram] : []),
98
+ ...args,
99
+ ], {
94
100
  cwd: projectRoot,
95
101
  encoding: "utf8",
96
102
  shell: false,
@@ -17,6 +17,9 @@ import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashT
17
17
  import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
18
18
  import { canonicalTagsSchema, isTypedMemoryNodesEnabled, memoryImportEnvelopeSchema, noteNodeSchema, nodeTypeSchema, } from "./node-schema.js";
19
19
  import { attachNodeMetadata, applyReviewedTypedNode, commitMemoryImport, createTypedNode, deleteTypedNode, getTypedNode, invalidateReviewedTypedNode, listTypedHistory, planMemoryImport, supersedeTypedNode, } from "./node-store.js";
20
+ import { contextPackHttpEnabled, contextPacksEnabled, listVisibleContext, readVisibleContext, searchVisibleContext } from "./context-pack.js";
21
+ import { metadataForTool } from "./mcp-tool-catalog.js";
22
+ import { profileAllowsTool, resolveMcpToolProfile } from "./mcp-tool-profile.js";
20
23
  const MINIMUM_NODE_MAJOR = 22;
21
24
  const nodeMajor = Number.parseInt(process.versions.node, 10);
22
25
  if (!Number.isInteger(nodeMajor) || nodeMajor < MINIMUM_NODE_MAJOR) {
@@ -758,7 +761,18 @@ async function runContextExplore(args) {
758
761
  }
759
762
  // --- Execution tier: return { ok: false, ... } (runtime problems, D-04) ---
760
763
  if (!evidence) {
761
- const result = await runCommand(binaryPath, ["explore", "--query", query, "--repo-root", repoRoot], (timeoutSeconds ?? 120) * 1000, { ...process.env, NO_COLOR: "1" });
764
+ // Native Windows cannot execute a JavaScript file directly from a
765
+ // shebang. Treat JS helpers as Node programs while keeping compiled
766
+ // token_miser binaries byte-for-byte compatible on every platform.
767
+ const javascriptHelper = /\.(?:cjs|mjs|js)$/i.test(binaryPath);
768
+ const result = await runCommand(javascriptHelper ? process.execPath : binaryPath, [
769
+ ...(javascriptHelper ? [binaryPath] : []),
770
+ "explore",
771
+ "--query",
772
+ query,
773
+ "--repo-root",
774
+ repoRoot,
775
+ ], (timeoutSeconds ?? 120) * 1000, { ...process.env, NO_COLOR: "1" });
762
776
  if (result.timedOut || result.exitCode !== 0) {
763
777
  const payload = {
764
778
  ok: false,
@@ -822,8 +836,18 @@ function gitToplevel(cwd) {
822
836
  // only allows one connected transport per server). All instances share the
823
837
  // module-level helpers + AgentFS below. Enables a single long-lived process to
824
838
  // serve many concurrent clients within one trusted server-side storage domain.
825
- function buildMemoryServer(context, capabilitySnapshot, withCapability) {
839
+ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProfile = resolveMcpToolProfile({ projectRoot: process.cwd() })) {
826
840
  const server = new McpServer({ name: "cairn-memory", version: "0.1.0" });
841
+ const registerTool = (name, config, callback) => {
842
+ const metadata = metadataForTool(name);
843
+ if (!profileAllowsTool(toolProfile, name))
844
+ return;
845
+ server.registerTool(name, {
846
+ ...config,
847
+ title: metadata.title,
848
+ annotations: metadata.annotations,
849
+ }, callback);
850
+ };
827
851
  const capabilityEnabled = (id) => capabilitySnapshot?.capabilities.find((state) => state.id === id)?.enabled ?? true;
828
852
  const registerCapabilityTool = (id, name, config, callback) => {
829
853
  if (!capabilityEnabled(id))
@@ -840,15 +864,22 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
840
864
  },
841
865
  }, callback)
842
866
  : callback;
843
- server.registerTool(name, config, handler);
867
+ registerTool(name, config, handler);
844
868
  };
845
869
  const memoryConfig = () => context.memoryConfig ?? getMemoryConfig();
846
870
  const scopeOptions = { projectId: context.projectId };
847
871
  const typedNodesEnabled = isTypedMemoryNodesEnabled();
848
872
  const artifactToolsEnabled = isArtifactStoreEnabled() && (!context.remote || isArtifactHttpEnabled());
873
+ const contextPackToolsEnabled = contextPacksEnabled() && (!context.remote || contextPackHttpEnabled());
849
874
  if (artifactToolsEnabled && context.remote && !context.projectId) {
850
875
  throw new ClientContextError("Remote artifact access requires X-Cairn-Project.");
851
876
  }
877
+ if (contextPackToolsEnabled && context.remote && !context.projectId) {
878
+ throw new ClientContextError("Remote context pack access requires X-Cairn-Project.");
879
+ }
880
+ const contextPackProject = context.remote
881
+ ? { projectId: context.projectId }
882
+ : { projectRoot: process.cwd() };
852
883
  let fallbackArtifactSessionRef;
853
884
  const resolveArtifactSessionRef = (sessionRef) => {
854
885
  if (sessionRef)
@@ -892,7 +923,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
892
923
  generatedFileInputContentSchema,
893
924
  ]),
894
925
  }).strict();
895
- server.registerTool("artifact_write", {
926
+ registerTool("artifact_write", {
896
927
  description: "Write one bounded inline artifact to the immutable project artifact store.",
897
928
  inputSchema: artifactWriteToolSchema,
898
929
  annotations: {
@@ -920,7 +951,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
920
951
  structuredContent: payload,
921
952
  };
922
953
  });
923
- server.registerTool("artifact_read", {
954
+ registerTool("artifact_read", {
924
955
  description: "Read one artifact by exact ID or an unambiguous ID prefix.",
925
956
  inputSchema: z.object({ artifact_id: artifactIdentifierSchema }).strict(),
926
957
  annotations: {
@@ -936,7 +967,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
936
967
  structuredContent: payload,
937
968
  };
938
969
  });
939
- server.registerTool("artifact_list", {
970
+ registerTool("artifact_list", {
940
971
  description: "List artifact metadata newest first with exact filters and opaque pagination.",
941
972
  inputSchema: z.object({
942
973
  session_ref: artifactSessionRefSchema.optional(),
@@ -976,7 +1007,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
976
1007
  structuredContent: payload,
977
1008
  };
978
1009
  });
979
- server.registerTool("artifact_delete", {
1010
+ registerTool("artifact_delete", {
980
1011
  description: "Hard-delete one artifact by exact ID or an unambiguous ID prefix.",
981
1012
  inputSchema: z.object({ artifact_id: artifactIdentifierSchema }).strict(),
982
1013
  annotations: {
@@ -1013,7 +1044,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1013
1044
  };
1014
1045
  });
1015
1046
  }
1016
- server.registerTool("memory_read", {
1047
+ registerTool("memory_read", {
1017
1048
  description: "Read an exact key or search memory entries across AgentFS scopes.",
1018
1049
  // Plain object schema — a .refine() wrapper (ZodEffects) makes the SDK
1019
1050
  // publish an empty JSON Schema, hiding the parameters from clients.
@@ -1151,7 +1182,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1151
1182
  structuredContent: payload,
1152
1183
  };
1153
1184
  });
1154
- server.registerTool("memory_list", {
1185
+ registerTool("memory_list", {
1155
1186
  description: "List keys from a scoped AgentFS database.",
1156
1187
  inputSchema: z.object({
1157
1188
  scope: z.string(),
@@ -1189,7 +1220,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1189
1220
  structuredContent: typedNodesEnabled ? { keys, nodes } : { keys },
1190
1221
  };
1191
1222
  });
1192
- server.registerTool("memory_delete", {
1223
+ registerTool("memory_delete", {
1193
1224
  description: "Delete a key from a scoped AgentFS database.",
1194
1225
  inputSchema: z.object({
1195
1226
  scope: z.string(),
@@ -1273,7 +1304,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1273
1304
  structuredContent: payload,
1274
1305
  };
1275
1306
  });
1276
- server.registerTool("memory_extract", {
1307
+ registerTool("memory_extract", {
1277
1308
  description: "Extract durable memory candidates from a session summary or selected text. Review the returned candidates before writing them.",
1278
1309
  inputSchema: z.object({
1279
1310
  scope: z.string(),
@@ -1294,7 +1325,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1294
1325
  structuredContent: payload,
1295
1326
  };
1296
1327
  });
1297
- server.registerTool("memory_supersede", {
1328
+ registerTool("memory_supersede", {
1298
1329
  description: "Preserve the current value of a memory entry in hidden history, then write a new live value to the base key.",
1299
1330
  inputSchema: z.object({
1300
1331
  scope: z.string(),
@@ -1380,7 +1411,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1380
1411
  await agent.close();
1381
1412
  }
1382
1413
  });
1383
- server.registerTool("memory_apply_reviewed", {
1414
+ registerTool("memory_apply_reviewed", {
1384
1415
  description: "Idempotently apply a human-reviewed memory revision with durable provenance.",
1385
1416
  inputSchema: z.object({
1386
1417
  scope: z.string(),
@@ -1484,7 +1515,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1484
1515
  await agent.close();
1485
1516
  }
1486
1517
  });
1487
- server.registerTool("memory_invalidate_reviewed", {
1518
+ registerTool("memory_invalidate_reviewed", {
1488
1519
  description: "Invalidate a reviewed revision, removing it only when the live value still matches.",
1489
1520
  inputSchema: z.object({
1490
1521
  scope: z.string(),
@@ -1609,7 +1640,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1609
1640
  await agent.close();
1610
1641
  }
1611
1642
  });
1612
- server.registerTool("memory_history", {
1643
+ registerTool("memory_history", {
1613
1644
  description: "Read prior versions of a memory entry from the hidden history namespace.",
1614
1645
  inputSchema: z.object({
1615
1646
  scope: z.string(),
@@ -1678,7 +1709,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1678
1709
  };
1679
1710
  });
1680
1711
  if (typedNodesEnabled) {
1681
- server.registerTool("memory_import", {
1712
+ registerTool("memory_import", {
1682
1713
  description: "Validate, plan, and atomically import a bounded batch of typed memory nodes.",
1683
1714
  inputSchema: memoryImportEnvelopeSchema,
1684
1715
  annotations: {
@@ -1698,30 +1729,18 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1698
1729
  }
1699
1730
  // Resolve containment before any database is opened or directory is created.
1700
1731
  const scopePath = resolveScopePath(envelope.scope, scopeOptions);
1701
- const existing = await openScope(envelope.scope, false, scopeOptions);
1702
- let plan;
1703
- try {
1704
- plan = await planMemoryImport(existing, envelope);
1705
- }
1706
- finally {
1707
- await existing?.close();
1708
- }
1709
- if (envelope.dry_run) {
1710
- const payload = await commitMemoryImport(null, plan);
1711
- return {
1712
- content: [{ type: "text", text: asToolText(payload) }],
1713
- structuredContent: payload,
1714
- };
1715
- }
1716
1732
  const payload = await serializeScopeMutation(scopePath, async () => {
1717
- const agent = await openScope(envelope.scope, true, scopeOptions);
1718
- if (!agent)
1719
- throw new Error(`Unable to open scope ${envelope.scope}.`);
1733
+ const agent = await openScope(envelope.scope, !envelope.dry_run, scopeOptions);
1720
1734
  try {
1735
+ const plan = await planMemoryImport(agent, envelope);
1736
+ if (envelope.dry_run)
1737
+ return commitMemoryImport(null, plan);
1738
+ if (!agent)
1739
+ throw new Error(`Unable to open scope ${envelope.scope}.`);
1721
1740
  return await commitMemoryImport(agent, plan);
1722
1741
  }
1723
1742
  finally {
1724
- await agent.close();
1743
+ await agent?.close();
1725
1744
  }
1726
1745
  });
1727
1746
  return {
@@ -1730,7 +1749,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1730
1749
  };
1731
1750
  });
1732
1751
  }
1733
- server.registerTool("domain_knowledge_query", {
1752
+ registerTool("domain_knowledge_query", {
1734
1753
  description: "Query an AnythingLLM workspace in query mode for domain knowledge.",
1735
1754
  inputSchema: z.object({
1736
1755
  workspace: z.string().min(1).optional(),
@@ -1750,7 +1769,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1750
1769
  structuredContent: { workspace: workspaceSlug, answer },
1751
1770
  };
1752
1771
  });
1753
- server.registerTool("domain_knowledge_sync", {
1772
+ registerTool("domain_knowledge_sync", {
1754
1773
  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.",
1755
1774
  inputSchema: z.object({
1756
1775
  workspace: z.string().min(1).optional(),
@@ -1879,6 +1898,37 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1879
1898
  const payload = { ok: true, status: body.status, cluster_healthy: body.cluster_healthy ?? null };
1880
1899
  return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1881
1900
  });
1901
+ if (contextPackToolsEnabled) {
1902
+ registerTool("context_pack_list", {
1903
+ description: "List documents and explicitly approved skills from context packs enabled for this project.",
1904
+ inputSchema: z.object({}).strict(),
1905
+ }, async () => {
1906
+ const payload = await listVisibleContext(contextPackProject);
1907
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1908
+ });
1909
+ registerTool("context_pack_search", {
1910
+ description: "Search documents and explicitly approved skills in context packs enabled for this project.",
1911
+ inputSchema: z.object({
1912
+ query: z.string().min(1).max(4096),
1913
+ limit: z.number().int().min(1).max(100).optional(),
1914
+ }).strict(),
1915
+ }, async ({ query, limit }) => {
1916
+ const payload = await searchVisibleContext(query, { ...contextPackProject, limit });
1917
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1918
+ });
1919
+ registerTool("context_pack_read", {
1920
+ description: "Read a bounded UTF-8 range from one enabled context-pack document or approved skill.",
1921
+ inputSchema: z.object({
1922
+ pack: z.string().min(1).max(128),
1923
+ path: z.string().min(1).max(512),
1924
+ offset: z.number().int().min(0).optional(),
1925
+ max_bytes: z.number().int().min(1).max(8192).optional(),
1926
+ }).strict(),
1927
+ }, async ({ pack, path, offset, max_bytes }) => {
1928
+ const payload = await readVisibleContext(pack, path, { ...contextPackProject, offset, maxBytes: max_bytes });
1929
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1930
+ });
1931
+ }
1882
1932
  return server;
1883
1933
  }
1884
1934
  function createMemoryServer(context = {}) {
@@ -0,0 +1,47 @@
1
+ const observation = (title, openWorldHint = false) => ({
2
+ title,
3
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint },
4
+ });
5
+ const additive = (title, openWorldHint = false) => ({
6
+ title,
7
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint },
8
+ });
9
+ const mutation = (title, openWorldHint = false) => ({
10
+ title,
11
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint },
12
+ });
13
+ // This is the authoritative MCP catalog. Adding a registered tool without an
14
+ // entry is a startup error, so clients can safely classify every Cairnkeep tool.
15
+ export const MCP_TOOL_CATALOG = {
16
+ artifact_write: additive("Write artifact"),
17
+ artifact_read: observation("Read artifact"),
18
+ artifact_list: observation("List artifacts"),
19
+ artifact_delete: mutation("Delete artifact"),
20
+ memory_read: observation("Read memory"),
21
+ memory_write: mutation("Write memory"),
22
+ memory_list: observation("List memory"),
23
+ memory_delete: mutation("Delete memory"),
24
+ memory_search: observation("Search memory"),
25
+ memory_extract: observation("Extract memory candidates", true),
26
+ memory_supersede: mutation("Supersede memory"),
27
+ memory_apply_reviewed: mutation("Apply reviewed memory"),
28
+ memory_invalidate_reviewed: mutation("Invalidate reviewed memory"),
29
+ memory_history: observation("Read memory history"),
30
+ memory_import: mutation("Import memory"),
31
+ domain_knowledge_query: observation("Query domain knowledge", true),
32
+ domain_knowledge_sync: mutation("Synchronize domain knowledge", true),
33
+ context_explore: observation("Explore repository context", true),
34
+ route_check: observation("Check route", true),
35
+ context_pack_list: observation("List context packs"),
36
+ context_pack_search: observation("Search context packs"),
37
+ context_pack_read: observation("Read context pack file"),
38
+ };
39
+ export const MCP_TOOL_NAMES = Object.freeze(Object.keys(MCP_TOOL_CATALOG));
40
+ export function isCairnToolName(value) {
41
+ return Object.hasOwn(MCP_TOOL_CATALOG, value);
42
+ }
43
+ export function metadataForTool(name) {
44
+ if (!isCairnToolName(name))
45
+ throw new Error(`MCP tool ${name} has no annotation contract.`);
46
+ return MCP_TOOL_CATALOG[name];
47
+ }