@cairnkeep/cli 2.23.0 → 2.24.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 (44) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/docs/containers.md +4 -0
  3. package/docs/domain-knowledge.md +42 -4
  4. package/docs/learning/CURRICULUM-MAP.md +3 -3
  5. package/docs/learning/FEATURE-GUIDE.md +8 -1
  6. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  7. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  8. package/docs/learning/lessons/L02-installation.md +2 -2
  9. package/docs/learning/lessons/L03-first-project.md +1 -1
  10. package/docs/learning/lessons/L04-memory-fundamentals.md +12 -0
  11. package/docs/learning/lessons/L09-domain-knowledge.md +11 -0
  12. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  13. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  14. package/docs/learning/lessons/L21-context-packs.md +1 -1
  15. package/docs/learning/lessons/L22-native-windows.md +2 -2
  16. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  17. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  18. package/docs/learning/lessons/L25-playbooks.md +1 -1
  19. package/docs/learning/lessons/L26-context-intelligence.md +1 -1
  20. package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
  21. package/docs/learning/lessons/L28-security-assurance.md +2 -2
  22. package/docs/learning/video-scripts/V04-memory-retrieval-supplement.md +13 -0
  23. package/docs/learning/video-scripts/V09-workspace-routing-supplement.md +36 -0
  24. package/docs/mcp-tool-profiles.md +7 -0
  25. package/docs/memory-retrieval.md +84 -2
  26. package/docs/operating.md +5 -0
  27. package/docs/privacy-and-data-flow.md +13 -4
  28. package/examples/eval/bundled-fake.json +1 -1
  29. package/mcp-memory-server/dist/anythingllm-workspace.js +109 -0
  30. package/mcp-memory-server/dist/index.js +41 -6
  31. package/mcp-memory-server/dist/mcp-tool-catalog.js +4 -1
  32. package/mcp-memory-server/dist/memory-reranker.js +109 -0
  33. package/mcp-memory-server/package.json +4 -2
  34. package/package.json +2 -2
  35. package/scripts/doctor.sh +17 -0
  36. package/scripts/probe-memory-reranker.mjs +22 -0
  37. package/scripts/test-container-health.mjs +2 -0
  38. package/scripts/test-container.sh +21 -1
  39. package/scripts/test-dependency-security.mjs +50 -0
  40. package/scripts/test-dependency-security.sh +4 -0
  41. package/scripts/test-doctor.sh +28 -0
  42. package/scripts/test-windows-native.mjs +5 -0
  43. package/scripts/verify-dependency-security.mjs +22 -2
  44. package/scripts/windows-platform.mjs +8 -0
@@ -0,0 +1,109 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { readStableJson } from "./stable-file.js";
5
+ /** Shared workspace that is never a project default; it holds cross-project patterns. */
6
+ const SHARED_WORKSPACE = "engineering-patterns";
7
+ const WORKSPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
8
+ const MAX_MAPPING_BYTES = 1024 * 1024;
9
+ const MAX_PROJECTS = 4096;
10
+ function expandHome(value) {
11
+ if (value === "~")
12
+ return homedir();
13
+ if (value.startsWith("~/"))
14
+ return join(homedir(), value.slice(2));
15
+ return value;
16
+ }
17
+ /**
18
+ * Locate `anythingllm-projects.json`.
19
+ *
20
+ * Mirrors the precedence in `examples/anythingllm/sync_to_anythingllm.py` so the
21
+ * resolver and the sync script can never disagree about which mapping is live.
22
+ */
23
+ export function anythingllmProjectsFile(env = process.env, legacyDir) {
24
+ const override = env.CAIRN_ANYTHINGLLM_PROJECTS_FILE;
25
+ if (override) {
26
+ const explicit = resolve(expandHome(override));
27
+ return existsSync(explicit) ? explicit : undefined;
28
+ }
29
+ if (legacyDir) {
30
+ const legacy = join(legacyDir, "anythingllm-projects.json");
31
+ if (existsSync(legacy))
32
+ return legacy;
33
+ }
34
+ const configHome = env.XDG_CONFIG_HOME
35
+ ? resolve(expandHome(env.XDG_CONFIG_HOME))
36
+ : join(homedir(), ".config");
37
+ const standard = join(configHome, "cairnkeep", "anythingllm-projects.json");
38
+ if (existsSync(standard))
39
+ return standard;
40
+ return undefined;
41
+ }
42
+ /**
43
+ * Workspace slug declared for the project containing `cwd`, if any.
44
+ *
45
+ * The deepest matching `path` wins so a checkout nested inside another project
46
+ * resolves to its own workspace rather than its parent's.
47
+ */
48
+ export function workspaceForDirectory(cwd, env = process.env, legacyDir) {
49
+ const file = anythingllmProjectsFile(env, legacyDir);
50
+ if (!file)
51
+ return undefined;
52
+ let entries;
53
+ try {
54
+ const parsed = readStableJson(file, { label: "Workspace mapping", maxBytes: MAX_MAPPING_BYTES });
55
+ if (!Array.isArray(parsed?.projects) || parsed.projects.length > MAX_PROJECTS)
56
+ return undefined;
57
+ entries = parsed.projects;
58
+ }
59
+ catch {
60
+ // A malformed or unreadable mapping must not break workspace resolution;
61
+ // fall back to the configured list rather than failing the tool call.
62
+ return undefined;
63
+ }
64
+ let here;
65
+ try {
66
+ here = realpathSync.native(cwd);
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ let bestSlug;
72
+ let bestDepth = -1;
73
+ let ambiguous = false;
74
+ for (const entry of entries) {
75
+ const slug = typeof entry?.slug === "string" ? entry.slug : "";
76
+ const path = typeof entry?.path === "string" ? entry.path : "";
77
+ if (!WORKSPACE_PATTERN.test(slug) || slug === SHARED_WORKSPACE || !isAbsolute(path))
78
+ continue;
79
+ let root;
80
+ try {
81
+ root = realpathSync.native(path);
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ const rel = relative(root, here);
87
+ const contains = rel === "" || (rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\") && !isAbsolute(rel));
88
+ if (contains && root.length > bestDepth) {
89
+ bestSlug = slug;
90
+ bestDepth = root.length;
91
+ ambiguous = false;
92
+ }
93
+ else if (contains && root.length === bestDepth && slug !== bestSlug) {
94
+ ambiguous = true;
95
+ }
96
+ }
97
+ if (ambiguous)
98
+ throw new Error("Ambiguous project workspace mapping; specify workspace explicitly.");
99
+ return bestSlug;
100
+ }
101
+ /**
102
+ * Default workspace for a tool call: the project containing `cwd` when it is
103
+ * declared, otherwise the previous behaviour so existing single-project setups
104
+ * keep working unchanged.
105
+ */
106
+ export function defaultAnythingLLMWorkspace(config, cwd = process.cwd(), env = process.env, legacyDir) {
107
+ return (workspaceForDirectory(cwd, env, legacyDir) ??
108
+ config.anythingllm_workspaces?.find((workspace) => workspace !== SHARED_WORKSPACE));
109
+ }
@@ -11,10 +11,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
12
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
13
13
  import { z } from "zod";
14
+ import { defaultAnythingLLMWorkspace as resolveDefaultAnythingLLMWorkspace } from "./anythingllm-workspace.js";
14
15
  import { artifactIdSchema, artifactKindSchema, artifactMediaTypeSchema, artifactNodeRefSchema, artifactProvenanceSchema, artifactSessionRefSchema, compactionSummaryInputContentSchema, diffContentSchema, generatedFileInputContentSchema, isArtifactHttpEnabled, isArtifactStoreEnabled, testOutputContentSchema, } from "./artifact-schema.js";
15
16
  import { isCapabilityContractEnabled, resolveCapabilityStatus, } from "./capability-config.js";
16
17
  import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText, } from "./embeddings.js";
17
18
  import { queryDomainKnowledge, resolveDomainRetrievalProvider } from "./domain-retrieval-provider.js";
19
+ import { getMemoryRerankerConfig, memoryRerankCandidateCount, rerankMemoryResults } from "./memory-reranker.js";
18
20
  import { formatHostAuthority, normalizeHostAuthority, normalizeHttpHost, parseHttpPort, resolveHttpToken } from "./http-security.js";
19
21
  import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
20
22
  import { canonicalTagsSchema, isTypedMemoryNodesEnabled, memoryImportEnvelopeSchema, noteNodeSchema, nodeTypeSchema, } from "./node-schema.js";
@@ -360,8 +362,14 @@ async function readStdin() {
360
362
  }
361
363
  return Buffer.concat(chunks).toString("utf8");
362
364
  }
363
- function defaultAnythingLLMWorkspace(config) {
364
- return config.anythingllm_workspaces?.find((workspace) => workspace !== "engineering-patterns");
365
+ function defaultAnythingLLMWorkspace(config, remote = false) {
366
+ // HTTP callers route by session metadata, never by the server's checkout.
367
+ if (remote || resolveDomainRetrievalProvider() !== "anythingllm") {
368
+ return config.anythingllm_workspaces?.find((workspace) => workspace !== "engineering-patterns");
369
+ }
370
+ // `anythingllm-projects.json` may also sit beside the bundled sync script,
371
+ // which is the legacy location the script still honours.
372
+ return resolveDefaultAnythingLLMWorkspace(config, process.cwd(), process.env, dirname(anythingllmSyncScript));
365
373
  }
366
374
  function entryText(entry) {
367
375
  const metadata = [entry.node_type, ...(entry.tags ?? [])].filter(Boolean).join("\n");
@@ -370,7 +378,7 @@ function entryText(entry) {
370
378
  function embeddingCachePath(scope, projectId) {
371
379
  return join(getBaseDir(), ".embeddings", `${hashText(resolveScopePath(scope, { projectId }))}.json`);
372
380
  }
373
- async function semanticSearch(scope, query, topK, minScore, config = getMemoryConfig(), projectId, options = {}) {
381
+ async function semanticSearchCandidates(scope, query, topK, minScore, config = getMemoryConfig(), projectId, options = {}) {
374
382
  const scopes = getSearchScopes(scope, config);
375
383
  const embeddingConfig = getEmbeddingConfig();
376
384
  const perScopeEntries = await Promise.all(scopes.map(async (candidate) => ({
@@ -473,6 +481,32 @@ async function semanticSearch(scope, query, topK, minScore, config = getMemoryCo
473
481
  return substringFallback();
474
482
  }
475
483
  }
484
+ async function semanticSearch(...args) {
485
+ const reranker = getMemoryRerankerConfig();
486
+ if (!reranker)
487
+ return semanticSearchCandidates(...args);
488
+ const [, query, topK] = args;
489
+ const options = args[6] ?? {};
490
+ const candidateArgs = [...args];
491
+ candidateArgs[2] = memoryRerankCandidateCount(topK, reranker);
492
+ // The existing path still owns scope, current-value, typed and relevance
493
+ // admission. The external ranker never sees records rejected by that path.
494
+ const candidate = await semanticSearchCandidates(...candidateArgs);
495
+ const exactKeys = new Set([query, ...(options.aliases ?? [])].map(value => value.normalize('NFKC').toLowerCase().trim()));
496
+ const pinned = (entry) => options.strategy === 'hybrid'
497
+ ? exactKeys.has(entry.key.normalize('NFKC').toLowerCase())
498
+ : Boolean(options.typed && isExactTextHit(entry, query));
499
+ const { results } = await rerankMemoryResults(candidate.results, query, topK, reranker, pinned);
500
+ const retrieval = candidate.retrieval;
501
+ if (retrieval?.possible_conflicts) {
502
+ const keys = new Set(results.map(entry => entry.key));
503
+ // Explain only selected keys, including when the larger candidate pool
504
+ // falls back. Do not leak explanations for unreturned candidates.
505
+ return { ...candidate, results, retrieval: { ...retrieval,
506
+ possible_conflicts: retrieval.possible_conflicts.filter(row => keys.has(row.key)) } };
507
+ }
508
+ return { ...candidate, results };
509
+ }
476
510
  // Confines a candidate wiki source file to inside `<repoRoot>/.planning/wiki/sources`
477
511
  // via relative()-based containment (Phase 2 SEC-0001 idiom, reused from
478
512
  // opencode/plugins/memory-recall.ts) — `resolve() === join()` misses `../`
@@ -701,8 +735,9 @@ function gitToplevel(cwd) {
701
735
  // serve many concurrent clients within one trusted server-side storage domain.
702
736
  function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProfile = resolveMcpToolProfile({ projectRoot: process.cwd() })) {
703
737
  const server = new McpServer({ name: "cairn-memory", version: "0.1.0" });
738
+ const memoryReranking = getMemoryRerankerConfig() !== null;
704
739
  const registerTool = (name, config, callback) => {
705
- const metadata = metadataForTool(name);
740
+ const metadata = metadataForTool(name, { memoryReranking });
706
741
  if (!profileAllowsTool(toolProfile, name))
707
742
  return;
708
743
  server.registerTool(name, {
@@ -1452,7 +1487,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1452
1487
  readOnlyHint: true,
1453
1488
  },
1454
1489
  }, async ({ workspace, query }) => {
1455
- const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(memoryConfig());
1490
+ const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(memoryConfig(), context.remote);
1456
1491
  if (!workspaceSlug) {
1457
1492
  throw new Error("No AnythingLLM workspace provided and no project workspace found in memory config.");
1458
1493
  }
@@ -1480,7 +1515,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
1480
1515
  }
1481
1516
  const syncMode = mode ?? "incremental";
1482
1517
  const config = memoryConfig();
1483
- const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(config);
1518
+ const workspaceSlug = workspace ?? defaultAnythingLLMWorkspace(config, context.remote);
1484
1519
  if (!workspaceSlug) {
1485
1520
  throw new Error("No AnythingLLM workspace provided and no project workspace found in memory config.");
1486
1521
  }
@@ -45,8 +45,11 @@ export const MCP_TOOL_NAMES = Object.freeze(Object.keys(MCP_TOOL_CATALOG));
45
45
  export function isCairnToolName(value) {
46
46
  return Object.hasOwn(MCP_TOOL_CATALOG, value);
47
47
  }
48
- export function metadataForTool(name) {
48
+ export function metadataForTool(name, options = {}) {
49
49
  if (!isCairnToolName(name))
50
50
  throw new Error(`MCP tool ${name} has no annotation contract.`);
51
+ if (name === 'memory_search' && options.memoryReranking) {
52
+ return { ...MCP_TOOL_CATALOG[name], annotations: { ...MCP_TOOL_CATALOG[name].annotations, openWorldHint: true } };
53
+ }
51
54
  return MCP_TOOL_CATALOG[name];
52
55
  }
@@ -0,0 +1,109 @@
1
+ import { z } from 'zod';
2
+ const MAX_CANDIDATES = 50;
3
+ const MAX_TEXT_BYTES = 4096;
4
+ const MAX_REQUEST_BYTES = 256 * 1024;
5
+ const MAX_RESPONSE_BYTES = 64 * 1024;
6
+ const responseSchema = z.object({ results: z.array(z.object({
7
+ index: z.number().int().min(0).max(MAX_CANDIDATES - 1),
8
+ relevance_score: z.number().finite(),
9
+ })).max(MAX_CANDIDATES) });
10
+ /** Configuration alone never grants permission: explicit opt-in is required. */
11
+ export function getMemoryRerankerConfig(env = process.env) {
12
+ if (env.CAIRN_MEMORY_RERANK !== '1')
13
+ return null;
14
+ try {
15
+ const url = new URL(env.CAIRN_MEMORY_RERANK_URL ?? '');
16
+ const model = env.CAIRN_MEMORY_RERANK_MODEL?.trim();
17
+ const rawTimeout = env.CAIRN_MEMORY_RERANK_TIMEOUT_MS ?? '2000';
18
+ const timeoutMs = Number(rawTimeout);
19
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash
20
+ || !model || Buffer.byteLength(model) > 256 || !/^\d+$/.test(rawTimeout)
21
+ || !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000)
22
+ return null;
23
+ const apiKey = env.CAIRN_MEMORY_RERANK_API_KEY?.trim();
24
+ if (apiKey && (apiKey.length > 8192 || /[\r\n]/.test(apiKey)))
25
+ return null;
26
+ return { apiUrl: url.href.replace(/\/+$/, ''), model, ...(apiKey ? { apiKey } : {}), timeoutMs };
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ export function memoryRerankCandidateCount(topK, config) {
33
+ if (!Number.isInteger(topK) || topK < 1 || topK > MAX_CANDIDATES)
34
+ throw new Error('Reranking requires a bounded result count.');
35
+ return config ? Math.min(MAX_CANDIDATES, Math.max(32, topK * 4)) : topK;
36
+ }
37
+ function textPrefix(value, maxUnits) {
38
+ let end = Math.min(value.length, maxUnits);
39
+ const last = value.charCodeAt(end - 1);
40
+ if (end < value.length && last >= 0xD800 && last <= 0xDBFF)
41
+ end--;
42
+ return value.slice(0, end);
43
+ }
44
+ function rankingText(entry) {
45
+ // Bound allocation before UTF-8 encoding, then discard an incomplete final
46
+ // code point rather than introducing replacement characters at the cutoff.
47
+ const text = `${textPrefix(entry.key, 1024)}\n${textPrefix(entry.value, MAX_TEXT_BYTES)}`;
48
+ const bytes = Buffer.from(text);
49
+ return new TextDecoder().decode(bytes.subarray(0, MAX_TEXT_BYTES), { stream: true });
50
+ }
51
+ async function readRankingResponse(response) {
52
+ if (!response.ok || !response.body || Number(response.headers.get('content-length')) > MAX_RESPONSE_BYTES) {
53
+ await response.body?.cancel();
54
+ throw new Error('Reranker response unavailable.');
55
+ }
56
+ const reader = response.body.getReader();
57
+ const chunks = [];
58
+ let size = 0;
59
+ try {
60
+ while (true) {
61
+ const { done, value } = await reader.read();
62
+ if (done)
63
+ break;
64
+ size += value.byteLength;
65
+ if (size > MAX_RESPONSE_BYTES)
66
+ throw new Error('Reranker response exceeds limit.');
67
+ chunks.push(value);
68
+ }
69
+ const bytes = Buffer.concat(chunks, size);
70
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
71
+ }
72
+ finally {
73
+ await reader.cancel().catch(() => { });
74
+ reader.releaseLock();
75
+ }
76
+ }
77
+ /** Reorder admitted records only. No input object, score, scope or value is changed. */
78
+ export async function rerankMemoryResults(entries, query, topK, config, pinned = () => false) {
79
+ const baseline = entries.slice(0, topK);
80
+ if (!config)
81
+ return { results: baseline, status: 'disabled' };
82
+ if (entries.length < 2 || entries.length > MAX_CANDIDATES || !query.trim() || Buffer.byteLength(query) > MAX_TEXT_BYTES) {
83
+ return { results: baseline, status: 'skipped' };
84
+ }
85
+ try {
86
+ const body = JSON.stringify({ model: config.model, query, documents: entries.map(rankingText), top_n: entries.length });
87
+ if (Buffer.byteLength(body) > MAX_REQUEST_BYTES)
88
+ return { results: baseline, status: 'skipped' };
89
+ const response = await fetch(`${config.apiUrl}/rerank`, {
90
+ method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(config.timeoutMs),
91
+ headers: { 'Content-Type': 'application/json', ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}) },
92
+ body,
93
+ });
94
+ const parsed = responseSchema.parse(await readRankingResponse(response));
95
+ if (parsed.results.length !== entries.length
96
+ || new Set(parsed.results.map(row => row.index)).size !== entries.length
97
+ || parsed.results.some(row => row.index >= entries.length))
98
+ throw new Error('Invalid ranking indices.');
99
+ const scores = new Map(parsed.results.map(row => [row.index, row.relevance_score]));
100
+ const ordered = entries.map((entry, index) => ({ entry, index, pinned: pinned(entry) }));
101
+ ordered.sort((a, b) => Number(b.pinned) - Number(a.pinned)
102
+ || scores.get(b.index) - scores.get(a.index) || a.index - b.index);
103
+ return { results: ordered.slice(0, topK).map(row => row.entry), status: 'reranked' };
104
+ }
105
+ catch {
106
+ // Never return endpoint errors, response bodies or credentials to clients.
107
+ return { results: baseline, status: 'failed' };
108
+ }
109
+ }
@@ -16,10 +16,11 @@
16
16
  "check:extract": "node scripts/smoke-extract-cli.mjs",
17
17
  "check:search": "node scripts/smoke-search-e2e.mjs",
18
18
  "check:memory-retrieval": "node scripts/smoke-memory-retrieval.mjs && node scripts/smoke-memory-retrieval-mcp.mjs && node scripts/run-memory-retrieval-study.mjs --check --summary",
19
+ "check:memory-reranker": "node scripts/smoke-memory-reranker.mjs && node scripts/smoke-memory-reranker-mcp.mjs && node scripts/run-memory-reranker-study.mjs --check --summary",
19
20
  "study:memory-retrieval": "npm run build && node scripts/run-memory-retrieval-study.mjs --summary",
20
21
  "check:scope-guard": "node scripts/smoke-scope-guard.mjs",
21
22
  "check:http-guard": "node scripts/smoke-http-guard.mjs",
22
- "check:remote-context": "node scripts/smoke-remote-context.mjs",
23
+ "check:remote-context": "node scripts/smoke-remote-context.mjs && node scripts/smoke-remote-context-ports.mjs",
23
24
  "check:explore-guard": "node scripts/smoke-explore-guard.mjs",
24
25
  "check:explore-cache": "node scripts/smoke-explore-cache.mjs",
25
26
  "check:explore-crossref": "node scripts/smoke-explore-crossref.mjs",
@@ -58,6 +59,7 @@
58
59
  "check:context-pack-v216-compat": "node scripts/smoke-context-pack-v216-compat.mjs",
59
60
  "check:context-usage": "node scripts/smoke-context-usage.mjs",
60
61
  "check:domain-retrieval": "node scripts/smoke-domain-retrieval.mjs",
62
+ "check:anythingllm-workspace": "node scripts/smoke-anythingllm-workspace.mjs && node scripts/smoke-anythingllm-workspace-mcp.mjs",
61
63
  "check:retrieval-benchmark": "node scripts/smoke-retrieval-benchmark.mjs && node scripts/run-retrieval-benchmark.mjs --check",
62
64
  "benchmark:retrieval": "npm run build && node scripts/run-retrieval-benchmark.mjs",
63
65
  "check:work-evidence": "node scripts/smoke-work-evidence.mjs",
@@ -77,7 +79,7 @@
77
79
  "@modelcontextprotocol/sdk": "^1.32.1",
78
80
  "agentfs-sdk": "^0.6.4",
79
81
  "yaml": "2.9.1",
80
- "zod": "^4.6.5"
82
+ "zod": "^4.6.7"
81
83
  },
82
84
  "devDependencies": {
83
85
  "@types/node": "^22.20.5",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.23.0",
3
+ "version": "2.24.0",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  "@modelcontextprotocol/sdk": "^1.32.1",
42
42
  "agentfs-sdk": "^0.6.4",
43
43
  "yaml": "2.9.1",
44
- "zod": "^4.6.5"
44
+ "zod": "^4.6.7"
45
45
  },
46
46
  "scripts": {
47
47
  "build:server": "npm --prefix mcp-memory-server ci --include=dev --global=false --dry-run=false && npm --prefix mcp-memory-server run build --global=false --dry-run=false",
package/scripts/doctor.sh CHANGED
@@ -112,6 +112,23 @@ else
112
112
  skip "embedding endpoint (CAIRN_MEMORY_EMBEDDING_URL unset)"
113
113
  fi
114
114
 
115
+ # Optional learned reranking: use the runtime validator and a synthetic request.
116
+ # Never print provider bodies, credentials, URLs or user memory in diagnostics.
117
+ if [[ "${CAIRN_MEMORY_RERANK:-}" == 1 ]]; then
118
+ if node "$CAIRN_ROOT/scripts/probe-memory-reranker.mjs" >/dev/null 2>&1; then
119
+ pass "memory reranker accepted a synthetic ranking request"
120
+ else
121
+ rerank_status=$?
122
+ if [[ "$rerank_status" == 2 ]]; then
123
+ fail "memory reranker configuration invalid — set CAIRN_MEMORY_RERANK_URL and CAIRN_MEMORY_RERANK_MODEL; timeout must be 100–10000 ms"
124
+ else
125
+ fail "memory reranker unavailable or returned invalid ranking data — search falls back to its existing order"
126
+ fi
127
+ fi
128
+ else
129
+ skip "memory reranker (CAIRN_MEMORY_RERANK unset/not 1)"
130
+ fi
131
+
115
132
  # 4. Git provider (collaboration commands). Report configuration only — auth is
116
133
  # provider- and host-specific and lives in the wrapper, not the core.
117
134
  case "${CAIRN_GIT_PROVIDER:-}" in
@@ -0,0 +1,22 @@
1
+ // Exit codes are a value-free doctor protocol: 0 disabled/healthy,
2
+ // 2 invalid opt-in configuration, 3 unavailable or invalid ranking response.
3
+ import { getMemoryRerankerConfig, rerankMemoryResults } from '../mcp-memory-server/dist/memory-reranker.js';
4
+ import { realpathSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export async function probeMemoryReranker() {
8
+ if (process.env.CAIRN_MEMORY_RERANK !== '1') return 0;
9
+ const config = getMemoryRerankerConfig();
10
+ if (!config) return 2;
11
+ const result = await rerankMemoryResults([
12
+ { key: 'health/policy', value: 'Corrections append a new release ledger entry.' },
13
+ { key: 'health/theme', value: 'The release ledger interface uses a dark theme.' },
14
+ ], 'How are release ledger corrections recorded?', 2, config);
15
+ return result.status === 'reranked' ? 0 : 3;
16
+ }
17
+
18
+ let invokedDirectly = false;
19
+ try { invokedDirectly = Boolean(process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)); } catch {}
20
+ if (invokedDirectly) {
21
+ process.exitCode = await probeMemoryReranker();
22
+ }
@@ -11,6 +11,8 @@ assert.match(quadlet, /^Secret=cairnkeep-http-token,type=mount,target=http-token
11
11
  "the managed container secret must satisfy the probe's private-file contract");
12
12
  assert.match(containerfile, /COPY --chmod=644 containers\/healthcheck\.mjs \/opt\/cairnkeep\/containers\/healthcheck\.mjs/, "probe imports must resolve within the packaged server");
13
13
  assert.match(containerfile, /ln -s \/opt\/cairnkeep\/containers\/healthcheck\.mjs \/usr\/local\/lib\/cairnkeep\/container-healthcheck\.mjs/, "keep the established container probe path");
14
+ assert.match(containerfile, /COPY --chmod=644 containers\/healthcheck\.mjs[^\n]*\nRUN chmod 755 \/opt\/cairnkeep\/containers\b/,
15
+ "normalize the probe directory's traversal mode after COPY for unprivileged runtime users");
14
16
  assert.match(source, /parseHttpPort/, "health destination must use the server's numeric port parser");
15
17
  assert.match(source, /resolveHttpToken/, "health tokens must use the server's bounded resolver");
16
18
  const { containerHealthcheck } = await import("../containers/healthcheck.mjs");
@@ -43,6 +43,21 @@ image_user=$("$ENGINE" image inspect --format '{{.Config.User}}' "$server_image"
43
43
  exit 1
44
44
  }
45
45
 
46
+ # Check the real legacy entrypoint before a failing credential probe can mask
47
+ # a module-load or directory-traversal error as an expected authentication denial.
48
+ "$ENGINE" run --rm --entrypoint node "$server_image" --input-type=module -e '
49
+ import assert from "node:assert/strict";
50
+ import { accessSync, constants, realpathSync } from "node:fs";
51
+ import { pathToFileURL } from "node:url";
52
+ assert.equal(process.getuid(), 10001);
53
+ accessSync("/opt/cairnkeep/containers", constants.R_OK | constants.X_OK);
54
+ const target = realpathSync("/usr/local/lib/cairnkeep/container-healthcheck.mjs");
55
+ assert.equal(target, "/opt/cairnkeep/containers/healthcheck.mjs");
56
+ accessSync(target, constants.R_OK);
57
+ await import(pathToFileURL(target).href);
58
+ console.log("container health entrypoint: readable, searchable and importable as runtime UID");
59
+ '
60
+
46
61
  "$ENGINE" volume create "$volume" >/dev/null
47
62
  node scripts/probe-memory-server.mjs --command \
48
63
  "$ENGINE" run --rm -i --volume "$volume:/data" "$server_image" stdio
@@ -112,10 +127,15 @@ done
112
127
  cat "$tmp/write.out"
113
128
  # Exec probes see the original container environment, not the entrypoint's
114
129
  # loaded secret. The deliberately public bind fixture must be rejected.
115
- if "$ENGINE" exec "$name" node /usr/local/lib/cairnkeep/container-healthcheck.mjs; then
130
+ if "$ENGINE" exec "$name" node /usr/local/lib/cairnkeep/container-healthcheck.mjs >"$tmp/health.out" 2>"$tmp/health.err"; then
116
131
  echo "FAIL: healthcheck accepted a non-private token file" >&2
117
132
  exit 1
118
133
  fi
134
+ [[ ! -s "$tmp/health.out" && ! -s "$tmp/health.err" ]] || {
135
+ cat "$tmp/health.out" "$tmp/health.err" >&2
136
+ echo "FAIL: healthcheck must reject unsafe credentials without a runtime error" >&2
137
+ exit 1
138
+ }
119
139
  # Exercise the same legacy entrypoint against a private, container-owned file.
120
140
  "$ENGINE" exec "$name" sh -c 'umask 077; cat /run/secrets/http-token > /tmp/health-token; CAIRN_MEMORY_HTTP_TOKEN_FILE=/tmp/health-token node /usr/local/lib/cairnkeep/container-healthcheck.mjs; status=$?; rm -f /tmp/health-token; exit "$status"'
121
141
  "$ENGINE" rm -f "$name" >/dev/null
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "node:child_process";
7
+
8
+ const root = fileURLToPath(new URL("../", import.meta.url));
9
+ const policy = join(root, "scripts/verify-dependency-security.mjs");
10
+ const paths = ["package.json", "package-lock.json", "mcp-memory-server/package.json", "mcp-memory-server/package-lock.json"];
11
+ const baseline = Object.fromEntries(paths.map(path => [path, JSON.parse(readFileSync(join(root, path), "utf8"))]));
12
+ const scratch = mkdtempSync(join(tmpdir(), "cairn-dependency-policy-"));
13
+ function check(label, mutate, expected) {
14
+ const values = structuredClone(baseline);
15
+ mutate(values);
16
+ for (const path of paths) {
17
+ const target = join(scratch, path);
18
+ mkdirSync(dirname(target), { recursive: true });
19
+ writeFileSync(target, JSON.stringify(values[path]));
20
+ }
21
+ const result = spawnSync(process.execPath, [policy], { cwd: scratch, encoding: "utf8", timeout: 10000 });
22
+ assert.ifError(result.error);
23
+ assert.equal(result.status, expected ? 1 : 0, `${label}: ${result.stderr}`);
24
+ if (expected) assert.match(result.stderr, expected, label);
25
+ }
26
+ try {
27
+ check("valid dependency graph", () => {}, null);
28
+ for (const lockPath of ['package-lock.json', 'mcp-memory-server/package-lock.json']) {
29
+ for (const [name, vulnerable] of [['body-parser', '2.3.0'], ['fast-uri', '3.1.8'], ['ip-address', '10.7.3']]) {
30
+ check(`${lockPath} rejects vulnerable ${name}`, x => {
31
+ x[lockPath].packages[`node_modules/${name}`].version = vulnerable;
32
+ }, new RegExp(`resolved ${name}: requires`));
33
+ check(`${lockPath} rejects nested vulnerable ${name}`, x => {
34
+ x[lockPath].packages[`node_modules/fixture/node_modules/${name}`] = { version: vulnerable };
35
+ }, new RegExp(`resolved ${name}: requires`));
36
+ }
37
+ }
38
+ check("root manifest/lock mismatch", x => { x["package.json"].dependencies.zod = "^4.0.0"; }, /manifest and lock root Zod ranges differ/);
39
+ check("server manifest/lock mismatch", x => { x["mcp-memory-server/package-lock.json"].packages[""].dependencies.zod = "^4.0.0"; }, /manifest and lock root Zod ranges differ/);
40
+ check("matched server range differs from packaged CLI", x => {
41
+ x["mcp-memory-server/package.json"].dependencies.zod = "^4.0.0";
42
+ x["mcp-memory-server/package-lock.json"].packages[""].dependencies.zod = "^4.0.0";
43
+ }, /root and server Zod ranges differ/);
44
+ check("different resolved schema version", x => { x["mcp-memory-server/package-lock.json"].packages["node_modules/zod"].version = "4.0.0"; }, /root and server resolved Zod versions differ/);
45
+ check("missing direct schema", x => { delete x["package-lock.json"].packages["node_modules/zod"]; }, /direct Zod: version must be a string/);
46
+ check("existing SDK advisory check retained", x => { x["package-lock.json"].packages["node_modules/@modelcontextprotocol/sdk"].version = "1.0.0"; }, /resolved @modelcontextprotocol\/sdk: requires/);
47
+ console.log("PASS: dependency policy positive control, both-lock/nested advisory rejection and schema parity mutations");
48
+ } finally {
49
+ rmSync(scratch, { recursive: true, force: true });
50
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
4
+ node "$ROOT/scripts/test-dependency-security.mjs"
@@ -18,12 +18,14 @@ export HOME="$tmp/home"
18
18
  mkdir -p "$HOME"
19
19
  # Clean the inherited env so the test controls what is "configured".
20
20
  unset CAIRN_LLM_API_URL CAIRN_MEMORY_EMBEDDING_URL CAIRN_GIT_PROVIDER CAIRN_AGENTFS_BASE_DIR
21
+ unset CAIRN_MEMORY_RERANK CAIRN_MEMORY_RERANK_URL CAIRN_MEMORY_RERANK_MODEL CAIRN_MEMORY_RERANK_API_KEY CAIRN_MEMORY_RERANK_TIMEOUT_MS
21
22
 
22
23
  # 1. Nothing configured → only SKIP/PASS/WARN, exit 0.
23
24
  proj="$tmp/clean"; mkdir -p "$proj"
24
25
  ( cd "$proj" && "$doctor" ) >"$tmp/out1" 2>&1 || fail "doctor exited non-zero with nothing configured:\n$(cat "$tmp/out1")"
25
26
  grep -q "\[SKIP\]" "$tmp/out1" || fail "expected SKIP lines when nothing is configured"
26
27
  grep -q "typed metadata and note transaction state" "$tmp/out1" || fail "expected typed/note diagnostics in the public doctor output"
28
+ grep -qF "[SKIP] memory reranker" "$tmp/out1" || fail "expected default-off reranker diagnostic"
27
29
  grep -q "artifact store (not present" "$tmp/out1" || fail "expected absent opt-in artifact diagnostics"
28
30
  grep -q "capability configuration (not present — managed overrides are unused)" "$tmp/out1" ||
29
31
  fail "expected absent capability configuration diagnostics"
@@ -92,6 +94,12 @@ const server = createServer((request, response) => {
92
94
  request.on("end", () => {
93
95
  let body = {};
94
96
  try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch {}
97
+ if (request.url === "/v1/rerank") {
98
+ response.writeHead(200, { "Content-Type": "application/json" });
99
+ response.end(JSON.stringify({ results: body.model === "invalid-ranker" ? [] :
100
+ body.documents.map((_, index) => ({ index, relevance_score: -index })) }));
101
+ return;
102
+ }
95
103
  const routeValid = request.url === "/v1/embeddings";
96
104
  const keyValid = request.headers.authorization === "Bearer test-key";
97
105
  const modelValid = body.model === "test-embedding";
@@ -153,6 +161,26 @@ EOF
153
161
  fail "missing-model diagnostic leaked an upstream body or request id"
154
162
  fi
155
163
 
164
+ # Reranker diagnostics validate configuration and actual data, not HTTP status alone.
165
+ if [[ -n "${port:-}" ]]; then
166
+ ( cd "$proj" && CAIRN_MEMORY_RERANK=1 CAIRN_MEMORY_RERANK_URL="http://127.0.0.1:$port/v1" \
167
+ CAIRN_MEMORY_RERANK_MODEL=test-ranker "$doctor" ) >"$tmp/reranker-ok" 2>&1 ||
168
+ fail "doctor rejected a valid reranking response"
169
+ grep -qF "[PASS] memory reranker accepted a synthetic ranking request" "$tmp/reranker-ok" ||
170
+ fail "doctor did not probe reranking"
171
+ if ( cd "$proj" && CAIRN_MEMORY_RERANK=1 "$doctor" ) >"$tmp/reranker-config" 2>&1; then
172
+ fail "doctor accepted an incomplete opted-in reranker"
173
+ fi
174
+ grep -qF "[FAIL] memory reranker configuration invalid" "$tmp/reranker-config" ||
175
+ fail "doctor omitted the reranker configuration diagnostic"
176
+ if ( cd "$proj" && CAIRN_MEMORY_RERANK=1 CAIRN_MEMORY_RERANK_URL="http://127.0.0.1:$port/v1" \
177
+ CAIRN_MEMORY_RERANK_MODEL=invalid-ranker "$doctor" ) >"$tmp/reranker-invalid" 2>&1; then
178
+ fail "doctor mistook fallback for a healthy reranker"
179
+ fi
180
+ grep -qF "[FAIL] memory reranker unavailable or returned invalid ranking data" "$tmp/reranker-invalid" ||
181
+ fail "doctor omitted the invalid ranking response diagnostic"
182
+ fi
183
+
156
184
  # 5. Artifact doctor passes a valid store, repairs only derived state, and
157
185
  # refuses authoritative full-record corruption without leaking stored values.
158
186
  artifact_cli="$ROOT/mcp-memory-server/dist/artifact-cli.js"
@@ -88,10 +88,12 @@ try {
88
88
  writeFileSync(join(invalidDerivedPack, `${"e".repeat(64)}.json`), "{\"corrupt\":true}\n");
89
89
  process.env.CAIRN_PACK_BASE_DIR = packBase;
90
90
  const inheritedLlmUrl = process.env.CAIRN_LLM_API_URL;
91
+ const inheritedRerank = process.env.CAIRN_MEMORY_RERANK;
91
92
  const exitCodeBeforeDoctor = process.exitCode;
92
93
  const doctorWrite = process.stdout.write.bind(process.stdout);
93
94
  let doctorOutput = "";
94
95
  delete process.env.CAIRN_LLM_API_URL;
96
+ delete process.env.CAIRN_MEMORY_RERANK;
95
97
  process.stdout.write = ((chunk) => { doctorOutput += String(chunk); return true; });
96
98
  try {
97
99
  await runWindowsCommand({ command: "doctor", args: ["--repair"], root });
@@ -100,9 +102,12 @@ try {
100
102
  process.exitCode = exitCodeBeforeDoctor;
101
103
  if (inheritedLlmUrl === undefined) delete process.env.CAIRN_LLM_API_URL;
102
104
  else process.env.CAIRN_LLM_API_URL = inheritedLlmUrl;
105
+ if (inheritedRerank === undefined) delete process.env.CAIRN_MEMORY_RERANK;
106
+ else process.env.CAIRN_MEMORY_RERANK = inheritedRerank;
103
107
  }
104
108
  assert.equal(existsSync(invalidDerivedPack), false, "native Windows doctor forwards safe context-pack cache repair");
105
109
  assert.match(doctorOutput, /\[PASS\] context pack diagnostics passed/);
110
+ assert.match(doctorOutput, /\[SKIP\] memory reranker unset\/not 1/);
106
111
 
107
112
  let containerArgs = [];
108
113
  runNativeContainer(["stdio", "--image", "example/windows:1", "--volume", "windows-data"], root, (_engine, args) => { containerArgs = args; });