@cairnkeep/cli 2.7.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/README.md +51 -2
- package/bin/cairn +19 -1
- package/docs/context-packs.md +94 -0
- package/docs/design/meta-agent.md +39 -35
- package/docs/harness-compatibility.md +7 -1
- package/docs/learning/CURRICULUM-MAP.md +14 -4
- package/docs/learning/FEATURE-GUIDE.md +11 -4
- package/docs/learning/README.md +8 -2
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L17-agent-boundary.md +12 -8
- package/docs/learning/lessons/L18-local-code-graph.md +97 -0
- package/docs/learning/lessons/L19-validated-skill-improvement.md +118 -0
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
- package/docs/learning/lessons/L21-context-packs.md +79 -0
- package/docs/learning/tracks/evidence-and-evaluation.md +5 -3
- package/docs/learning/tracks/operator.md +4 -2
- package/docs/learning/tracks/practitioner.md +5 -4
- package/docs/mcp-tool-profiles.md +43 -0
- package/docs/operating.md +61 -0
- package/docs/privacy-and-data-flow.md +59 -1
- package/docs/skill-improvement.md +124 -0
- package/docs/storage.md +42 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/context-pack-cli.js +185 -0
- package/mcp-memory-server/dist/context-pack.js +830 -0
- package/mcp-memory-server/dist/eval-plan.js +22 -5
- package/mcp-memory-server/dist/eval-process.js +31 -0
- package/mcp-memory-server/dist/eval-report.js +1 -0
- package/mcp-memory-server/dist/eval-runner.js +12 -4
- package/mcp-memory-server/dist/eval-schema.js +2 -1
- package/mcp-memory-server/dist/eval-workspace.js +50 -1
- package/mcp-memory-server/dist/index.js +68 -17
- package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
- package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
- package/mcp-memory-server/dist/mcp-tool-profile.js +162 -0
- package/mcp-memory-server/dist/skill-cli.js +236 -0
- package/mcp-memory-server/dist/skill-evaluation.js +244 -0
- package/mcp-memory-server/dist/skill-schema.js +164 -0
- package/mcp-memory-server/dist/skill-store.js +583 -0
- package/mcp-memory-server/package.json +4 -1
- package/package.json +1 -1
- package/schemas/context-pack.schema.json +33 -0
- package/schemas/eval-report.schema.json +2 -1
- package/schemas/skill-adapter.schema.json +39 -0
- package/schemas/skill-proposal-protocol.schema.json +97 -0
- package/scripts/completion.sh +62 -5
- package/scripts/doctor.sh +39 -0
- package/scripts/test-cli-dispatch.sh +6 -0
- package/scripts/test-completion.sh +16 -0
- package/scripts/test-learning-docs.sh +13 -3
- package/scripts/test-package-install.sh +35 -0
- package/scripts/test-skill-cli.sh +30 -0
- package/scripts/test-uninstall.sh +21 -0
- package/scripts/uninstall.sh +26 -1
- package/templates/env.example.template +11 -0
- package/templates/graph-policy.md.template +3 -1
|
@@ -186,7 +186,7 @@ function validateBundledBinding(taskSetPath, taskSet, taskSetBytes) {
|
|
|
186
186
|
binding_path: realpathSync(bindingPath),
|
|
187
187
|
};
|
|
188
188
|
}
|
|
189
|
-
function validateArms(arms) {
|
|
189
|
+
function validateArms(arms, experimentKind) {
|
|
190
190
|
if (arms.length < 1 || arms.length > 2)
|
|
191
191
|
throw new Error("Evaluation requires one or two arms.");
|
|
192
192
|
const seen = new Set();
|
|
@@ -201,8 +201,12 @@ function validateArms(arms) {
|
|
|
201
201
|
}
|
|
202
202
|
if (arm.id === "baseline" && arm.disabled_capability !== null)
|
|
203
203
|
throw new Error("Baseline cannot disable a capability.");
|
|
204
|
-
if (arm.id === "treatment" && arm.disabled_capability === null)
|
|
205
|
-
throw new Error("
|
|
204
|
+
if (arm.id === "treatment" && experimentKind === "ablation" && arm.disabled_capability === null) {
|
|
205
|
+
throw new Error("Ablation treatment must disable exactly one capability.");
|
|
206
|
+
}
|
|
207
|
+
if (experimentKind === "skill_candidate" && arm.disabled_capability !== null) {
|
|
208
|
+
throw new Error("Skill-candidate arms cannot disable capabilities.");
|
|
209
|
+
}
|
|
206
210
|
return { id: arm.id, disabled_capability: arm.disabled_capability };
|
|
207
211
|
});
|
|
208
212
|
}
|
|
@@ -214,7 +218,10 @@ export function isEvalEnabled(value = process.env.CAIRN_EVAL) {
|
|
|
214
218
|
}
|
|
215
219
|
export function buildEvalSchedule(options) {
|
|
216
220
|
const taskSet = evalTaskSetSchema.parse(options.taskSet);
|
|
217
|
-
const
|
|
221
|
+
const inferredKind = options.arms.length === 1 ? "two_pass" : options.arms.some(({ disabled_capability }) => disabled_capability !== null)
|
|
222
|
+
? "ablation"
|
|
223
|
+
: "skill_candidate";
|
|
224
|
+
const arms = validateArms(options.arms, inferredKind);
|
|
218
225
|
if (!Number.isSafeInteger(options.repetitions) || options.repetitions < 1 || options.repetitions > MAX_REPETITIONS) {
|
|
219
226
|
throw new Error(`Evaluation repetitions must be an integer from 1 to ${MAX_REPETITIONS}.`);
|
|
220
227
|
}
|
|
@@ -282,7 +289,15 @@ export function validateEvalInputs(options) {
|
|
|
282
289
|
}
|
|
283
290
|
const repetitions = options.repetitions ?? 1;
|
|
284
291
|
const seed = options.seed ?? "cairn-eval-v1";
|
|
285
|
-
const
|
|
292
|
+
const suppliedArms = options.arms ?? [{ id: "baseline", disabled_capability: null }];
|
|
293
|
+
const experimentKind = options.experimentKind
|
|
294
|
+
?? (suppliedArms.length === 1 ? "two_pass" : "ablation");
|
|
295
|
+
if (experimentKind === "two_pass" && suppliedArms.length !== 1)
|
|
296
|
+
throw new Error("Two-pass evaluation requires one arm.");
|
|
297
|
+
if ((experimentKind === "ablation" || experimentKind === "skill_candidate") && suppliedArms.length !== 2) {
|
|
298
|
+
throw new Error(`${experimentKind} evaluation requires two arms.`);
|
|
299
|
+
}
|
|
300
|
+
const arms = validateArms(suppliedArms, experimentKind);
|
|
286
301
|
const passes = ["run1", "run2"];
|
|
287
302
|
const schedule = buildEvalSchedule({ taskSet, arms, repetitions, passes, seed });
|
|
288
303
|
const adapterBase = dirname(adapterInput.path);
|
|
@@ -304,6 +319,7 @@ export function validateEvalInputs(options) {
|
|
|
304
319
|
repetitions,
|
|
305
320
|
seed,
|
|
306
321
|
arms,
|
|
322
|
+
experiment_kind: experimentKind,
|
|
307
323
|
passes,
|
|
308
324
|
concurrency: 1,
|
|
309
325
|
task_set_commit: taskSetCommit,
|
|
@@ -320,6 +336,7 @@ export function validateEvalInputs(options) {
|
|
|
320
336
|
repetitions,
|
|
321
337
|
seed,
|
|
322
338
|
arms,
|
|
339
|
+
experiment_kind: experimentKind,
|
|
323
340
|
passes: ["run1", "run2"],
|
|
324
341
|
concurrency: 1,
|
|
325
342
|
invocation_count: schedule.invocation_count,
|
|
@@ -246,6 +246,37 @@ function hasTrailingJsonDocument(value) {
|
|
|
246
246
|
}
|
|
247
247
|
return false;
|
|
248
248
|
}
|
|
249
|
+
export async function runBoundedJsonProcess(options) {
|
|
250
|
+
const request = options.request_schema.safeParse(options.request);
|
|
251
|
+
if (!request.success)
|
|
252
|
+
throw new EvalProcessError("invalid_request");
|
|
253
|
+
const commandResult = await runCommandInternal({
|
|
254
|
+
command: options.command,
|
|
255
|
+
cwd: options.cwd,
|
|
256
|
+
env: options.env,
|
|
257
|
+
timeout_ms: options.timeout_ms,
|
|
258
|
+
max_stdout_bytes: options.max_stdout_bytes,
|
|
259
|
+
kill_grace_ms: options.kill_grace_ms,
|
|
260
|
+
signal: options.signal,
|
|
261
|
+
stdout_mode: "raw",
|
|
262
|
+
stdin_document: Buffer.from(JSON.stringify(request.data), "utf8"),
|
|
263
|
+
});
|
|
264
|
+
if (commandResult.exit_code !== 0 || commandResult.signal !== null || commandResult.cleanup !== "closed") {
|
|
265
|
+
throw new EvalProcessError("adapter_error", commandResult);
|
|
266
|
+
}
|
|
267
|
+
let parsed;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(commandResult.stdout ?? "");
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
throw new EvalProcessError(hasTrailingJsonDocument(commandResult.stdout ?? "") ? "multiple_json" : "invalid_json", commandResult);
|
|
273
|
+
}
|
|
274
|
+
const result = options.result_schema.safeParse(parsed);
|
|
275
|
+
if (!result.success)
|
|
276
|
+
throw new EvalProcessError("invalid_result", commandResult);
|
|
277
|
+
const { stdout: _stdout, ...processResult } = commandResult;
|
|
278
|
+
return { ...processResult, result: result.data };
|
|
279
|
+
}
|
|
249
280
|
export async function runBoundedJsonAdapter(options) {
|
|
250
281
|
const request = evalAdapterRequestSchema.safeParse(options.request);
|
|
251
282
|
if (!request.success)
|
|
@@ -578,6 +578,7 @@ export function renderEvalReport(value) {
|
|
|
578
578
|
`Cairn evaluation report: ${report.experiment_id}`,
|
|
579
579
|
`status: ${report.status}`,
|
|
580
580
|
`evidence: ${report.evidence.evidence_scope === "offline-framework" ? "framework-only" : "live-evaluation"}`,
|
|
581
|
+
`MCP tool profile digest: ${report.evidence.mcp_tool_profile_digest ?? "not-recorded"}`,
|
|
581
582
|
"All effects are estimates; small samples, missing intervals, and intervals crossing zero are inconclusive.",
|
|
582
583
|
"",
|
|
583
584
|
"Aggregates",
|
|
@@ -9,7 +9,8 @@ import { CAPABILITY_IDS, capabilityStatusSchema, } from "./capability-schema.js"
|
|
|
9
9
|
import { canonicalDigest, evalReportSchema, evalReportRuntimeContractDigest, } from "./eval-schema.js";
|
|
10
10
|
import { EvalProcessError, runBoundedCommand, runBoundedJsonAdapter, } from "./eval-process.js";
|
|
11
11
|
import { checkpointEvalReport, createEvalReportStore, } from "./eval-report.js";
|
|
12
|
-
import { cleanupEvalWorkspace, createEvalWorkspace, runTaskPreparation, runTaskVerifier, } from "./eval-workspace.js";
|
|
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;
|
|
@@ -557,6 +558,8 @@ export async function runEvalObservation(options) {
|
|
|
557
558
|
observation.missing_reasons.push("preparation_failed");
|
|
558
559
|
}
|
|
559
560
|
else {
|
|
561
|
+
if (options.workspace_overlay)
|
|
562
|
+
await applyEvalWorkspaceOverlay(workspace, options.workspace_overlay);
|
|
560
563
|
const request = {
|
|
561
564
|
schema_version: 1,
|
|
562
565
|
experiment_id: options.report.experiment_id,
|
|
@@ -698,7 +701,7 @@ export async function runEvalObservation(options) {
|
|
|
698
701
|
await checkpoint(options.report_store, options.report);
|
|
699
702
|
return { observation, snapshot: distilledSnapshot, cancelled: observation.terminal_state === "cancelled" };
|
|
700
703
|
}
|
|
701
|
-
function initialReport(plan, experimentId) {
|
|
704
|
+
function initialReport(plan, experimentId, fallbackExperimentKind) {
|
|
702
705
|
const now = new Date().toISOString();
|
|
703
706
|
const sourceCommit = plan.task_set_commit ?? (plan.source.kind === "git" ? plan.source.revision : plan.task_set_digest);
|
|
704
707
|
const missingness = { count: 0, reasons: [] };
|
|
@@ -706,7 +709,7 @@ function initialReport(plan, experimentId) {
|
|
|
706
709
|
schema_version: 1,
|
|
707
710
|
experiment_id: experimentId,
|
|
708
711
|
status: "partial",
|
|
709
|
-
experiment_kind: plan.
|
|
712
|
+
experiment_kind: plan.experiment_kind ?? fallbackExperimentKind,
|
|
710
713
|
task_set_digest: plan.task_set_digest,
|
|
711
714
|
adapter_config_digest: plan.adapter_config_digest,
|
|
712
715
|
source_revision: plan.source.kind === "git" ? plan.source.revision : plan.task_set_digest,
|
|
@@ -733,6 +736,7 @@ function initialReport(plan, experimentId) {
|
|
|
733
736
|
package_version: plan.source.kind === "bundled_fake" ? plan.source.package_version : "0.1.0",
|
|
734
737
|
runtime_id: "node-local",
|
|
735
738
|
task_set_digest: plan.task_set_digest,
|
|
739
|
+
mcp_tool_profile_digest: resolveMcpToolProfile({ projectRoot: process.cwd() }).profile_digest,
|
|
736
740
|
report_digest: canonicalDigest({ experiment_id: experimentId, plan_digest: plan.plan_digest }),
|
|
737
741
|
schema_digests: [...evalReportSchemaDigests()],
|
|
738
742
|
note_snapshot_digests: [],
|
|
@@ -750,7 +754,7 @@ async function runExperiment(options, capabilityArms) {
|
|
|
750
754
|
});
|
|
751
755
|
if (reportStore.experiment_id !== experimentId)
|
|
752
756
|
throw new Error("report_experiment_mismatch");
|
|
753
|
-
const report = initialReport(options.plan, experimentId);
|
|
757
|
+
const report = initialReport(options.plan, experimentId, capabilityArms ? "ablation" : "two_pass");
|
|
754
758
|
const snapshotsByTask = new Map();
|
|
755
759
|
const notesByTask = new Map();
|
|
756
760
|
const snapshots = [];
|
|
@@ -782,6 +786,7 @@ async function runExperiment(options, capabilityArms) {
|
|
|
782
786
|
distill_command: options.distill_command,
|
|
783
787
|
distill_timeout_ms: options.distill_timeout_ms,
|
|
784
788
|
capability_runtime: capabilityRuntime,
|
|
789
|
+
workspace_overlay: options.workspace_overlays?.[row.arm],
|
|
785
790
|
});
|
|
786
791
|
executed.push(row.observation_id);
|
|
787
792
|
if (result.snapshot) {
|
|
@@ -813,6 +818,9 @@ export async function runTwoPassExperiment(options) {
|
|
|
813
818
|
return runExperiment(options);
|
|
814
819
|
}
|
|
815
820
|
export async function runCapabilityAblation(options) {
|
|
821
|
+
if (options.plan.experiment_kind !== undefined && options.plan.experiment_kind !== "ablation") {
|
|
822
|
+
throw new Error("ablation_experiment_kind_required");
|
|
823
|
+
}
|
|
816
824
|
const arms = buildAblationArms(options.disabled_capability);
|
|
817
825
|
const intended = arms.map(({ id, disabled_capability }) => ({ id, disabled_capability }));
|
|
818
826
|
if (canonicalDigest(options.plan.arms) !== canonicalDigest(intended))
|
|
@@ -13,7 +13,7 @@ export const EVAL_OBSERVATION_TERMINAL_STATES = [
|
|
|
13
13
|
"invalid_result",
|
|
14
14
|
];
|
|
15
15
|
export const EVAL_PASS_STATES = ["passed", "failed", "unknown"];
|
|
16
|
-
export const EVAL_EXPERIMENT_KINDS = ["two_pass", "ablation"];
|
|
16
|
+
export const EVAL_EXPERIMENT_KINDS = ["two_pass", "ablation", "skill_candidate"];
|
|
17
17
|
const MAX_TEXT_BYTES = 1024 * 1024;
|
|
18
18
|
const MAX_COMMAND_ARGS = 256;
|
|
19
19
|
const MAX_TASKS = 10_000;
|
|
@@ -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),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { constants, existsSync } from "node:fs";
|
|
2
3
|
import { chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, writeFile, } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
@@ -155,7 +156,7 @@ export async function createEvalWorkspace(options) {
|
|
|
155
156
|
};
|
|
156
157
|
try {
|
|
157
158
|
if (options.plan.source.kind === "git") {
|
|
158
|
-
const added = await gitCommand(options.plan.source.repository_root, ["worktree", "add", "--detach", workspace.source_path, options.plan.source.revision], "exit-only");
|
|
159
|
+
const added = await gitCommand(options.plan.source.repository_root, ["worktree", "add", "--quiet", "--detach", workspace.source_path, options.plan.source.revision], "exit-only");
|
|
159
160
|
requireSuccessfulCommand(added, "workspace_add_failed");
|
|
160
161
|
workspace.worktree_registered = true;
|
|
161
162
|
await verifyGitWorkspace(workspace);
|
|
@@ -192,6 +193,54 @@ export async function runTaskPreparation(workspace, options = {}) {
|
|
|
192
193
|
signal: options.signal,
|
|
193
194
|
});
|
|
194
195
|
}
|
|
196
|
+
export async function applyEvalWorkspaceOverlay(workspace, overlay) {
|
|
197
|
+
const segments = overlay.relative_path.split("/");
|
|
198
|
+
if (overlay.relative_path.startsWith("/") || overlay.relative_path.endsWith("/")
|
|
199
|
+
|| overlay.relative_path.includes("\\") || segments.some((segment) => segment === "" || segment === "." || segment === "..")
|
|
200
|
+
|| /[\u0000-\u001f\u007f]/.test(overlay.relative_path)) {
|
|
201
|
+
throw new Error("workspace_overlay_path_invalid");
|
|
202
|
+
}
|
|
203
|
+
const target = resolve(workspace.source_path, overlay.relative_path);
|
|
204
|
+
if (!isContained(workspace.source_path, target) || target === workspace.source_path) {
|
|
205
|
+
throw new Error("workspace_overlay_path_escape");
|
|
206
|
+
}
|
|
207
|
+
let cursor = workspace.source_path;
|
|
208
|
+
for (const segment of segments.slice(0, -1)) {
|
|
209
|
+
cursor = join(cursor, segment);
|
|
210
|
+
try {
|
|
211
|
+
const info = await lstat(cursor);
|
|
212
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
213
|
+
throw new Error("workspace_overlay_parent_unsafe");
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
if (error.code !== "ENOENT")
|
|
217
|
+
throw error;
|
|
218
|
+
await mkdir(cursor, { mode: 0o700 });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const info = await lstat(target);
|
|
223
|
+
if (info.isSymbolicLink() || !info.isFile())
|
|
224
|
+
throw new Error("workspace_overlay_target_unsafe");
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error.code !== "ENOENT")
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
const parent = await realpath(dirname(target));
|
|
231
|
+
const source = await realpath(workspace.source_path);
|
|
232
|
+
if (!isContained(source, parent))
|
|
233
|
+
throw new Error("workspace_overlay_parent_escape");
|
|
234
|
+
const bytes = Buffer.from(overlay.content, "utf8");
|
|
235
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
236
|
+
if (digest !== overlay.digest)
|
|
237
|
+
throw new Error("workspace_overlay_digest_mismatch");
|
|
238
|
+
await writeFile(target, bytes, { mode: 0o600 });
|
|
239
|
+
await chmod(target, 0o600);
|
|
240
|
+
const storedDigest = createHash("sha256").update(await readFile(target)).digest("hex");
|
|
241
|
+
if (storedDigest !== overlay.digest)
|
|
242
|
+
throw new Error("workspace_overlay_write_mismatch");
|
|
243
|
+
}
|
|
195
244
|
function verifierReason(code) {
|
|
196
245
|
return `verifier_${code}`;
|
|
197
246
|
}
|
|
@@ -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) {
|
|
@@ -822,8 +825,18 @@ function gitToplevel(cwd) {
|
|
|
822
825
|
// only allows one connected transport per server). All instances share the
|
|
823
826
|
// module-level helpers + AgentFS below. Enables a single long-lived process to
|
|
824
827
|
// serve many concurrent clients within one trusted server-side storage domain.
|
|
825
|
-
function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
828
|
+
function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProfile = resolveMcpToolProfile({ projectRoot: process.cwd() })) {
|
|
826
829
|
const server = new McpServer({ name: "cairn-memory", version: "0.1.0" });
|
|
830
|
+
const registerTool = (name, config, callback) => {
|
|
831
|
+
const metadata = metadataForTool(name);
|
|
832
|
+
if (!profileAllowsTool(toolProfile, name))
|
|
833
|
+
return;
|
|
834
|
+
server.registerTool(name, {
|
|
835
|
+
...config,
|
|
836
|
+
title: metadata.title,
|
|
837
|
+
annotations: metadata.annotations,
|
|
838
|
+
}, callback);
|
|
839
|
+
};
|
|
827
840
|
const capabilityEnabled = (id) => capabilitySnapshot?.capabilities.find((state) => state.id === id)?.enabled ?? true;
|
|
828
841
|
const registerCapabilityTool = (id, name, config, callback) => {
|
|
829
842
|
if (!capabilityEnabled(id))
|
|
@@ -840,15 +853,22 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
840
853
|
},
|
|
841
854
|
}, callback)
|
|
842
855
|
: callback;
|
|
843
|
-
|
|
856
|
+
registerTool(name, config, handler);
|
|
844
857
|
};
|
|
845
858
|
const memoryConfig = () => context.memoryConfig ?? getMemoryConfig();
|
|
846
859
|
const scopeOptions = { projectId: context.projectId };
|
|
847
860
|
const typedNodesEnabled = isTypedMemoryNodesEnabled();
|
|
848
861
|
const artifactToolsEnabled = isArtifactStoreEnabled() && (!context.remote || isArtifactHttpEnabled());
|
|
862
|
+
const contextPackToolsEnabled = contextPacksEnabled() && (!context.remote || contextPackHttpEnabled());
|
|
849
863
|
if (artifactToolsEnabled && context.remote && !context.projectId) {
|
|
850
864
|
throw new ClientContextError("Remote artifact access requires X-Cairn-Project.");
|
|
851
865
|
}
|
|
866
|
+
if (contextPackToolsEnabled && context.remote && !context.projectId) {
|
|
867
|
+
throw new ClientContextError("Remote context pack access requires X-Cairn-Project.");
|
|
868
|
+
}
|
|
869
|
+
const contextPackProject = context.remote
|
|
870
|
+
? { projectId: context.projectId }
|
|
871
|
+
: { projectRoot: process.cwd() };
|
|
852
872
|
let fallbackArtifactSessionRef;
|
|
853
873
|
const resolveArtifactSessionRef = (sessionRef) => {
|
|
854
874
|
if (sessionRef)
|
|
@@ -892,7 +912,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
892
912
|
generatedFileInputContentSchema,
|
|
893
913
|
]),
|
|
894
914
|
}).strict();
|
|
895
|
-
|
|
915
|
+
registerTool("artifact_write", {
|
|
896
916
|
description: "Write one bounded inline artifact to the immutable project artifact store.",
|
|
897
917
|
inputSchema: artifactWriteToolSchema,
|
|
898
918
|
annotations: {
|
|
@@ -920,7 +940,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
920
940
|
structuredContent: payload,
|
|
921
941
|
};
|
|
922
942
|
});
|
|
923
|
-
|
|
943
|
+
registerTool("artifact_read", {
|
|
924
944
|
description: "Read one artifact by exact ID or an unambiguous ID prefix.",
|
|
925
945
|
inputSchema: z.object({ artifact_id: artifactIdentifierSchema }).strict(),
|
|
926
946
|
annotations: {
|
|
@@ -936,7 +956,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
936
956
|
structuredContent: payload,
|
|
937
957
|
};
|
|
938
958
|
});
|
|
939
|
-
|
|
959
|
+
registerTool("artifact_list", {
|
|
940
960
|
description: "List artifact metadata newest first with exact filters and opaque pagination.",
|
|
941
961
|
inputSchema: z.object({
|
|
942
962
|
session_ref: artifactSessionRefSchema.optional(),
|
|
@@ -976,7 +996,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
976
996
|
structuredContent: payload,
|
|
977
997
|
};
|
|
978
998
|
});
|
|
979
|
-
|
|
999
|
+
registerTool("artifact_delete", {
|
|
980
1000
|
description: "Hard-delete one artifact by exact ID or an unambiguous ID prefix.",
|
|
981
1001
|
inputSchema: z.object({ artifact_id: artifactIdentifierSchema }).strict(),
|
|
982
1002
|
annotations: {
|
|
@@ -1013,7 +1033,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1013
1033
|
};
|
|
1014
1034
|
});
|
|
1015
1035
|
}
|
|
1016
|
-
|
|
1036
|
+
registerTool("memory_read", {
|
|
1017
1037
|
description: "Read an exact key or search memory entries across AgentFS scopes.",
|
|
1018
1038
|
// Plain object schema — a .refine() wrapper (ZodEffects) makes the SDK
|
|
1019
1039
|
// publish an empty JSON Schema, hiding the parameters from clients.
|
|
@@ -1151,7 +1171,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1151
1171
|
structuredContent: payload,
|
|
1152
1172
|
};
|
|
1153
1173
|
});
|
|
1154
|
-
|
|
1174
|
+
registerTool("memory_list", {
|
|
1155
1175
|
description: "List keys from a scoped AgentFS database.",
|
|
1156
1176
|
inputSchema: z.object({
|
|
1157
1177
|
scope: z.string(),
|
|
@@ -1189,7 +1209,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1189
1209
|
structuredContent: typedNodesEnabled ? { keys, nodes } : { keys },
|
|
1190
1210
|
};
|
|
1191
1211
|
});
|
|
1192
|
-
|
|
1212
|
+
registerTool("memory_delete", {
|
|
1193
1213
|
description: "Delete a key from a scoped AgentFS database.",
|
|
1194
1214
|
inputSchema: z.object({
|
|
1195
1215
|
scope: z.string(),
|
|
@@ -1273,7 +1293,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1273
1293
|
structuredContent: payload,
|
|
1274
1294
|
};
|
|
1275
1295
|
});
|
|
1276
|
-
|
|
1296
|
+
registerTool("memory_extract", {
|
|
1277
1297
|
description: "Extract durable memory candidates from a session summary or selected text. Review the returned candidates before writing them.",
|
|
1278
1298
|
inputSchema: z.object({
|
|
1279
1299
|
scope: z.string(),
|
|
@@ -1294,7 +1314,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1294
1314
|
structuredContent: payload,
|
|
1295
1315
|
};
|
|
1296
1316
|
});
|
|
1297
|
-
|
|
1317
|
+
registerTool("memory_supersede", {
|
|
1298
1318
|
description: "Preserve the current value of a memory entry in hidden history, then write a new live value to the base key.",
|
|
1299
1319
|
inputSchema: z.object({
|
|
1300
1320
|
scope: z.string(),
|
|
@@ -1380,7 +1400,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1380
1400
|
await agent.close();
|
|
1381
1401
|
}
|
|
1382
1402
|
});
|
|
1383
|
-
|
|
1403
|
+
registerTool("memory_apply_reviewed", {
|
|
1384
1404
|
description: "Idempotently apply a human-reviewed memory revision with durable provenance.",
|
|
1385
1405
|
inputSchema: z.object({
|
|
1386
1406
|
scope: z.string(),
|
|
@@ -1484,7 +1504,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1484
1504
|
await agent.close();
|
|
1485
1505
|
}
|
|
1486
1506
|
});
|
|
1487
|
-
|
|
1507
|
+
registerTool("memory_invalidate_reviewed", {
|
|
1488
1508
|
description: "Invalidate a reviewed revision, removing it only when the live value still matches.",
|
|
1489
1509
|
inputSchema: z.object({
|
|
1490
1510
|
scope: z.string(),
|
|
@@ -1609,7 +1629,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1609
1629
|
await agent.close();
|
|
1610
1630
|
}
|
|
1611
1631
|
});
|
|
1612
|
-
|
|
1632
|
+
registerTool("memory_history", {
|
|
1613
1633
|
description: "Read prior versions of a memory entry from the hidden history namespace.",
|
|
1614
1634
|
inputSchema: z.object({
|
|
1615
1635
|
scope: z.string(),
|
|
@@ -1678,7 +1698,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1678
1698
|
};
|
|
1679
1699
|
});
|
|
1680
1700
|
if (typedNodesEnabled) {
|
|
1681
|
-
|
|
1701
|
+
registerTool("memory_import", {
|
|
1682
1702
|
description: "Validate, plan, and atomically import a bounded batch of typed memory nodes.",
|
|
1683
1703
|
inputSchema: memoryImportEnvelopeSchema,
|
|
1684
1704
|
annotations: {
|
|
@@ -1730,7 +1750,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1730
1750
|
};
|
|
1731
1751
|
});
|
|
1732
1752
|
}
|
|
1733
|
-
|
|
1753
|
+
registerTool("domain_knowledge_query", {
|
|
1734
1754
|
description: "Query an AnythingLLM workspace in query mode for domain knowledge.",
|
|
1735
1755
|
inputSchema: z.object({
|
|
1736
1756
|
workspace: z.string().min(1).optional(),
|
|
@@ -1750,7 +1770,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1750
1770
|
structuredContent: { workspace: workspaceSlug, answer },
|
|
1751
1771
|
};
|
|
1752
1772
|
});
|
|
1753
|
-
|
|
1773
|
+
registerTool("domain_knowledge_sync", {
|
|
1754
1774
|
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
1775
|
inputSchema: z.object({
|
|
1756
1776
|
workspace: z.string().min(1).optional(),
|
|
@@ -1879,6 +1899,37 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
|
|
|
1879
1899
|
const payload = { ok: true, status: body.status, cluster_healthy: body.cluster_healthy ?? null };
|
|
1880
1900
|
return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
|
|
1881
1901
|
});
|
|
1902
|
+
if (contextPackToolsEnabled) {
|
|
1903
|
+
registerTool("context_pack_list", {
|
|
1904
|
+
description: "List documents and explicitly approved skills from context packs enabled for this project.",
|
|
1905
|
+
inputSchema: z.object({}).strict(),
|
|
1906
|
+
}, async () => {
|
|
1907
|
+
const payload = await listVisibleContext(contextPackProject);
|
|
1908
|
+
return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
|
|
1909
|
+
});
|
|
1910
|
+
registerTool("context_pack_search", {
|
|
1911
|
+
description: "Search documents and explicitly approved skills in context packs enabled for this project.",
|
|
1912
|
+
inputSchema: z.object({
|
|
1913
|
+
query: z.string().min(1).max(4096),
|
|
1914
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
1915
|
+
}).strict(),
|
|
1916
|
+
}, async ({ query, limit }) => {
|
|
1917
|
+
const payload = await searchVisibleContext(query, { ...contextPackProject, limit });
|
|
1918
|
+
return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
|
|
1919
|
+
});
|
|
1920
|
+
registerTool("context_pack_read", {
|
|
1921
|
+
description: "Read a bounded UTF-8 range from one enabled context-pack document or approved skill.",
|
|
1922
|
+
inputSchema: z.object({
|
|
1923
|
+
pack: z.string().min(1).max(128),
|
|
1924
|
+
path: z.string().min(1).max(512),
|
|
1925
|
+
offset: z.number().int().min(0).optional(),
|
|
1926
|
+
max_bytes: z.number().int().min(1).max(8192).optional(),
|
|
1927
|
+
}).strict(),
|
|
1928
|
+
}, async ({ pack, path, offset, max_bytes }) => {
|
|
1929
|
+
const payload = await readVisibleContext(pack, path, { ...contextPackProject, offset, maxBytes: max_bytes });
|
|
1930
|
+
return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1882
1933
|
return server;
|
|
1883
1934
|
}
|
|
1884
1935
|
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
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { MCP_TOOL_CATALOG, MCP_TOOL_NAMES } from "./mcp-tool-catalog.js";
|
|
2
|
+
import { resetMcpToolProfile, resolveMcpToolProfile, setMcpToolProfile } from "./mcp-tool-profile.js";
|
|
3
|
+
function usage() {
|
|
4
|
+
process.stderr.write("Usage: cairn mcp-tools list [--json] | status [--project PATH] [--json] | set full|read-only [--project PATH] | set custom --tool NAME... [--project PATH] | reset [--project PATH]\n");
|
|
5
|
+
process.exit(2);
|
|
6
|
+
}
|
|
7
|
+
function option(args, name) {
|
|
8
|
+
const index = args.indexOf(name);
|
|
9
|
+
if (index < 0)
|
|
10
|
+
return undefined;
|
|
11
|
+
const value = args[index + 1];
|
|
12
|
+
if (!value || value.startsWith("--"))
|
|
13
|
+
usage();
|
|
14
|
+
args.splice(index, 2);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function flags(args, name) {
|
|
18
|
+
const values = [];
|
|
19
|
+
for (let index = 0; index < args.length;) {
|
|
20
|
+
if (args[index] !== name) {
|
|
21
|
+
index += 1;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
args.splice(index, 1);
|
|
25
|
+
let consumed = 0;
|
|
26
|
+
while (index < args.length && !args[index].startsWith("--")) {
|
|
27
|
+
values.push(args[index]);
|
|
28
|
+
args.splice(index, 1);
|
|
29
|
+
consumed += 1;
|
|
30
|
+
}
|
|
31
|
+
if (consumed === 0)
|
|
32
|
+
usage();
|
|
33
|
+
}
|
|
34
|
+
return values;
|
|
35
|
+
}
|
|
36
|
+
function output(value, json, human) {
|
|
37
|
+
process.stdout.write(`${json ? JSON.stringify(value) : human}\n`);
|
|
38
|
+
}
|
|
39
|
+
async function main() {
|
|
40
|
+
const args = process.argv.slice(2);
|
|
41
|
+
const command = args.shift();
|
|
42
|
+
const jsonIndex = args.indexOf("--json");
|
|
43
|
+
const json = jsonIndex >= 0;
|
|
44
|
+
if (json)
|
|
45
|
+
args.splice(jsonIndex, 1);
|
|
46
|
+
const projectRoot = option(args, "--project") ?? process.cwd();
|
|
47
|
+
if (command === "list") {
|
|
48
|
+
if (args.length)
|
|
49
|
+
usage();
|
|
50
|
+
const tools = MCP_TOOL_NAMES.map((name) => ({ name, title: MCP_TOOL_CATALOG[name].title, annotations: MCP_TOOL_CATALOG[name].annotations }));
|
|
51
|
+
output({ schema_version: 1, tools }, json, tools.map(({ name, annotations }) => `${name}\t${annotations.readOnlyHint ? "read-only" : "mutation"}`).join("\n"));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (command === "status") {
|
|
55
|
+
if (args.length)
|
|
56
|
+
usage();
|
|
57
|
+
const status = resolveMcpToolProfile({ projectRoot });
|
|
58
|
+
output(status, json, `Profile: ${status.mode}\nSource: ${status.source}\nDigest: ${status.profile_digest}\nTools: ${status.allowed_tools.join(", ")}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (command === "set") {
|
|
62
|
+
const mode = args.shift();
|
|
63
|
+
if (!mode || !["full", "read-only", "custom"].includes(mode))
|
|
64
|
+
usage();
|
|
65
|
+
const allowedTools = flags(args, "--tool");
|
|
66
|
+
if (args.length || (mode === "custom" && allowedTools.length === 0) || (mode !== "custom" && allowedTools.length > 0))
|
|
67
|
+
usage();
|
|
68
|
+
const status = await setMcpToolProfile({ projectRoot, mode, allowedTools });
|
|
69
|
+
output(status, json, `MCP tool profile set to ${mode} (${status.profile_digest}).`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (command === "reset") {
|
|
73
|
+
if (args.length)
|
|
74
|
+
usage();
|
|
75
|
+
await resetMcpToolProfile(projectRoot);
|
|
76
|
+
const status = resolveMcpToolProfile({ projectRoot });
|
|
77
|
+
output(status, json, `Project MCP tool profile reset; effective profile is ${status.mode} from ${status.source}.`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
usage();
|
|
81
|
+
}
|
|
82
|
+
main().catch((error) => {
|
|
83
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
});
|