@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.
- package/CHANGELOG.md +49 -0
- package/docs/containers.md +4 -0
- package/docs/domain-knowledge.md +42 -4
- package/docs/learning/CURRICULUM-MAP.md +3 -3
- package/docs/learning/FEATURE-GUIDE.md +8 -1
- 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/L04-memory-fundamentals.md +12 -0
- package/docs/learning/lessons/L09-domain-knowledge.md +11 -0
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +2 -2
- package/docs/learning/video-scripts/V04-memory-retrieval-supplement.md +13 -0
- package/docs/learning/video-scripts/V09-workspace-routing-supplement.md +36 -0
- package/docs/mcp-tool-profiles.md +7 -0
- package/docs/memory-retrieval.md +84 -2
- package/docs/operating.md +5 -0
- package/docs/privacy-and-data-flow.md +13 -4
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/anythingllm-workspace.js +109 -0
- package/mcp-memory-server/dist/index.js +41 -6
- package/mcp-memory-server/dist/mcp-tool-catalog.js +4 -1
- package/mcp-memory-server/dist/memory-reranker.js +109 -0
- package/mcp-memory-server/package.json +4 -2
- package/package.json +2 -2
- package/scripts/doctor.sh +17 -0
- package/scripts/probe-memory-reranker.mjs +22 -0
- package/scripts/test-container-health.mjs +2 -0
- package/scripts/test-container.sh +21 -1
- package/scripts/test-dependency-security.mjs +50 -0
- package/scripts/test-dependency-security.sh +4 -0
- package/scripts/test-doctor.sh +28 -0
- package/scripts/test-windows-native.mjs +5 -0
- package/scripts/verify-dependency-security.mjs +22 -2
- 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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/scripts/test-doctor.sh
CHANGED
|
@@ -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; });
|