@cairnkeep/cli 2.16.0 → 2.17.1
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 +34 -0
- package/README.md +1 -0
- package/docs/README.md +8 -0
- package/docs/agents.md +20 -0
- package/docs/context-intelligence.md +143 -0
- package/docs/context-packs.md +33 -0
- package/docs/domain-knowledge.md +29 -0
- package/docs/ecosystem.md +0 -1
- package/docs/learning/CURRICULUM-MAP.md +3 -1
- package/docs/learning/FEATURE-GUIDE.md +22 -3
- package/docs/learning/README.md +1 -0
- 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/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 +180 -0
- package/docs/learning/tracks/evidence-and-evaluation.md +1 -0
- package/docs/learning/tracks/operator.md +1 -0
- package/docs/learning/tracks/practitioner.md +1 -0
- package/docs/learning/video-scripts/V26-context-intelligence.md +144 -0
- package/docs/operating.md +44 -0
- package/docs/privacy-and-data-flow.md +22 -0
- package/docs/storage.md +21 -0
- package/docs/work-evidence.md +14 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/context-pack-cli.js +3 -1
- package/mcp-memory-server/dist/context-pack-retrieval.js +413 -0
- package/mcp-memory-server/dist/context-pack.js +171 -3
- package/mcp-memory-server/dist/domain-retrieval-provider.js +257 -0
- package/mcp-memory-server/dist/index.js +73 -387
- package/mcp-memory-server/dist/mcp-tool-catalog.js +2 -0
- package/mcp-memory-server/dist/memory-extraction.js +87 -0
- package/mcp-memory-server/dist/memory-proposal-cli.js +110 -0
- package/mcp-memory-server/dist/memory-proposal-schema.js +78 -0
- package/mcp-memory-server/dist/memory-proposal-store.js +273 -0
- package/mcp-memory-server/dist/platform-security.js +32 -26
- package/mcp-memory-server/dist/retrieval-benchmark.js +199 -0
- package/mcp-memory-server/dist/reviewed-memory-store.js +281 -0
- package/mcp-memory-server/dist/work-evidence-schema.js +17 -0
- package/mcp-memory-server/dist/work-evidence-store.js +57 -1
- package/mcp-memory-server/package.json +8 -1
- package/package.json +2 -2
- package/schemas/context-usage-receipt.schema.json +41 -0
- package/schemas/memory-proposal.schema.json +65 -0
- package/schemas/retrieval-benchmark.schema.json +64 -0
- package/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +17 -6
- package/scripts/doctor.sh +4 -2
- package/scripts/terminal-prompts.mjs +5 -3
- package/scripts/test-completion.sh +8 -1
- package/scripts/test-doctor.sh +21 -0
- package/scripts/test-release-sbom.sh +4 -1
- package/scripts/test-setup-preflight.mjs +13 -0
- package/scripts/test-windows-native.mjs +30 -0
- package/scripts/windows-platform.mjs +8 -5
- package/templates/PROJECT-INSTRUCTIONS.md.template +0 -1
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
function isDirectory(node) {
|
|
5
|
+
return "type" in node && node.type === "directory";
|
|
6
|
+
}
|
|
7
|
+
const CACHE_SCHEMA_VERSION = 1;
|
|
8
|
+
const DERIVATION_ALGORITHM = "deterministic-progressive-v1";
|
|
9
|
+
const ABSTRACT_MAX_BYTES = 512;
|
|
10
|
+
const OVERVIEW_MAX_BYTES = 2048;
|
|
11
|
+
const MAX_TRACE_EVENTS = 64;
|
|
12
|
+
const MAX_SELECTED_FILES = 32;
|
|
13
|
+
const MAX_SELECTED_DIRECTORIES = 12;
|
|
14
|
+
function sha256(value) {
|
|
15
|
+
return createHash("sha256").update(value).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
function compact(value) {
|
|
18
|
+
return value.replace(/\s+/g, " ").trim();
|
|
19
|
+
}
|
|
20
|
+
function boundedUtf8(value, maximum) {
|
|
21
|
+
const bytes = Buffer.from(value, "utf8");
|
|
22
|
+
if (bytes.length <= maximum)
|
|
23
|
+
return value;
|
|
24
|
+
const suffix = Buffer.from("…", "utf8");
|
|
25
|
+
if (suffix.length > maximum)
|
|
26
|
+
return "";
|
|
27
|
+
let end = maximum - suffix.length;
|
|
28
|
+
while (end > 0) {
|
|
29
|
+
try {
|
|
30
|
+
return `${new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, end)).trimEnd()}…`;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
end -= 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
function fileAbstract(file) {
|
|
39
|
+
const metadata = [file.title, file.description, file.keywords.join(", ")].map(compact).filter(Boolean);
|
|
40
|
+
return boundedUtf8(metadata.join(" — "), ABSTRACT_MAX_BYTES);
|
|
41
|
+
}
|
|
42
|
+
function fileOverview(file, abstract) {
|
|
43
|
+
const headings = [...file.text.matchAll(/^#{1,6}\s+(.+)$/gm)].map((match) => compact(match[1])).filter(Boolean);
|
|
44
|
+
const paragraphs = file.text
|
|
45
|
+
.split(/\n\s*\n/g)
|
|
46
|
+
.map((paragraph) => compact(paragraph.replace(/^#{1,6}\s+/, "")))
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.slice(0, 4);
|
|
49
|
+
const pieces = [abstract, headings.length ? `Sections: ${headings.join("; ")}` : "", ...paragraphs].filter(Boolean);
|
|
50
|
+
return boundedUtf8([...new Set(pieces)].join("\n"), OVERVIEW_MAX_BYTES);
|
|
51
|
+
}
|
|
52
|
+
function visibleSetDigest(files) {
|
|
53
|
+
return sha256([
|
|
54
|
+
DERIVATION_ALGORITHM,
|
|
55
|
+
...files.map(({ path, file_digest }) => `${path}\0${file_digest}`).sort((left, right) => left.localeCompare(right, "en")),
|
|
56
|
+
].join("\n"));
|
|
57
|
+
}
|
|
58
|
+
function validCache(value, packDigest, setDigest, files) {
|
|
59
|
+
if (!value || typeof value !== "object")
|
|
60
|
+
return false;
|
|
61
|
+
const row = value;
|
|
62
|
+
if (row.schema_version !== CACHE_SCHEMA_VERSION || row.algorithm !== DERIVATION_ALGORITHM || row.pack_digest !== packDigest || row.visible_set_digest !== setDigest || !Array.isArray(row.files))
|
|
63
|
+
return false;
|
|
64
|
+
const expected = new Map(files.map((file) => [file.path, file.file_digest]));
|
|
65
|
+
return row.files.length === files.length && row.files.every((file) => file && typeof file.path === "string" && expected.get(file.path) === file.file_digest
|
|
66
|
+
&& typeof file.abstract === "string" && Buffer.byteLength(file.abstract) <= ABSTRACT_MAX_BYTES
|
|
67
|
+
&& typeof file.overview === "string" && Buffer.byteLength(file.overview) <= OVERVIEW_MAX_BYTES);
|
|
68
|
+
}
|
|
69
|
+
async function writeCache(path, record) {
|
|
70
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
71
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
await writeFile(temporary, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
74
|
+
try {
|
|
75
|
+
await rename(temporary, path);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// Concurrent deterministic builders may publish the same record.
|
|
79
|
+
try {
|
|
80
|
+
await readFile(path);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await rm(temporary, { force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function enrichPackFiles(files, cacheBaseDirectory) {
|
|
92
|
+
const packDigest = files[0].pack_digest;
|
|
93
|
+
const setDigest = visibleSetDigest(files);
|
|
94
|
+
const cachePath = join(cacheBaseDirectory, packDigest, `${setDigest}.json`);
|
|
95
|
+
let cached;
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(await readFile(cachePath, "utf8"));
|
|
98
|
+
if (validCache(parsed, packDigest, setDigest, files))
|
|
99
|
+
cached = parsed;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Missing or corrupt derived data is rebuilt deterministically.
|
|
103
|
+
}
|
|
104
|
+
if (!cached) {
|
|
105
|
+
cached = {
|
|
106
|
+
schema_version: CACHE_SCHEMA_VERSION,
|
|
107
|
+
algorithm: DERIVATION_ALGORITHM,
|
|
108
|
+
pack_digest: packDigest,
|
|
109
|
+
visible_set_digest: setDigest,
|
|
110
|
+
files: files.map((file) => {
|
|
111
|
+
const abstract = fileAbstract(file);
|
|
112
|
+
return { path: file.path, file_digest: file.file_digest, abstract, overview: fileOverview(file, abstract) };
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
await writeCache(cachePath, cached);
|
|
116
|
+
}
|
|
117
|
+
const summaries = new Map(cached.files.map((file) => [file.path, file]));
|
|
118
|
+
return files.map((file) => ({ ...file, ...summaries.get(file.path) }));
|
|
119
|
+
}
|
|
120
|
+
function buildTree(pack, files) {
|
|
121
|
+
const root = { type: "directory", path: "", title: pack.title, abstract: "", overview: "", children: [] };
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
const segments = file.path.split("/");
|
|
124
|
+
let current = root;
|
|
125
|
+
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
126
|
+
const path = segments.slice(0, index + 1).join("/");
|
|
127
|
+
let child = current.children.find((candidate) => isDirectory(candidate) && candidate.path === path);
|
|
128
|
+
if (!child) {
|
|
129
|
+
child = { type: "directory", path, title: segments[index], abstract: "", overview: "", children: [] };
|
|
130
|
+
current.children.push(child);
|
|
131
|
+
}
|
|
132
|
+
current = child;
|
|
133
|
+
}
|
|
134
|
+
current.children.push(file);
|
|
135
|
+
}
|
|
136
|
+
const summarize = (directory) => {
|
|
137
|
+
for (const child of directory.children)
|
|
138
|
+
if (isDirectory(child))
|
|
139
|
+
summarize(child);
|
|
140
|
+
directory.children.sort((left, right) => {
|
|
141
|
+
const leftDirectory = isDirectory(left);
|
|
142
|
+
const rightDirectory = isDirectory(right);
|
|
143
|
+
if (leftDirectory !== rightDirectory)
|
|
144
|
+
return leftDirectory ? -1 : 1;
|
|
145
|
+
const leftPath = leftDirectory ? left.path : left.path;
|
|
146
|
+
const rightPath = rightDirectory ? right.path : right.path;
|
|
147
|
+
return leftPath.localeCompare(rightPath, "en");
|
|
148
|
+
});
|
|
149
|
+
const childAbstracts = directory.children.map((child) => child.abstract);
|
|
150
|
+
directory.abstract = boundedUtf8(`${directory.title}: ${childAbstracts.join(" | ")}`, ABSTRACT_MAX_BYTES);
|
|
151
|
+
directory.overview = boundedUtf8([directory.abstract, ...directory.children.map((child) => child.overview)].join("\n"), OVERVIEW_MAX_BYTES);
|
|
152
|
+
};
|
|
153
|
+
summarize(root);
|
|
154
|
+
root.abstract = boundedUtf8(`${pack.title}: ${pack.description} — ${root.abstract}`, ABSTRACT_MAX_BYTES);
|
|
155
|
+
root.overview = boundedUtf8(`${root.abstract}\n${root.overview}`, OVERVIEW_MAX_BYTES);
|
|
156
|
+
return root;
|
|
157
|
+
}
|
|
158
|
+
export async function loadProgressiveContext(files, cacheBaseDirectory) {
|
|
159
|
+
const grouped = new Map();
|
|
160
|
+
for (const file of files)
|
|
161
|
+
grouped.set(file.pack_digest, [...(grouped.get(file.pack_digest) ?? []), file]);
|
|
162
|
+
const packs = [];
|
|
163
|
+
for (const [packDigest, packFiles] of grouped) {
|
|
164
|
+
packFiles.sort((left, right) => left.path.localeCompare(right.path, "en"));
|
|
165
|
+
const enriched = await enrichPackFiles(packFiles, cacheBaseDirectory);
|
|
166
|
+
const first = packFiles[0];
|
|
167
|
+
const tree = buildTree({ title: first.pack_title, description: first.pack_description }, enriched);
|
|
168
|
+
packs.push({
|
|
169
|
+
pack_id: first.pack_id,
|
|
170
|
+
version: first.version,
|
|
171
|
+
pack_digest: packDigest,
|
|
172
|
+
title: first.pack_title,
|
|
173
|
+
abstract: tree.abstract,
|
|
174
|
+
overview: tree.overview,
|
|
175
|
+
files: enriched,
|
|
176
|
+
tree,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return packs.sort((left, right) => left.pack_digest.localeCompare(right.pack_digest, "en"));
|
|
180
|
+
}
|
|
181
|
+
function normalizedTokens(value) {
|
|
182
|
+
return [...new Set(value.toLocaleLowerCase("en").match(/[\p{L}\p{N}._/-]+/gu) ?? [])].slice(0, 24);
|
|
183
|
+
}
|
|
184
|
+
function matchScore(query, text) {
|
|
185
|
+
const normalizedText = text.toLocaleLowerCase("en");
|
|
186
|
+
let score = normalizedText.includes(query) ? 20 : 0;
|
|
187
|
+
for (const token of normalizedTokens(query))
|
|
188
|
+
if (token.length > 1 && normalizedText.includes(token))
|
|
189
|
+
score += 1;
|
|
190
|
+
return score;
|
|
191
|
+
}
|
|
192
|
+
export function selectHierarchicalFiles(query, packs) {
|
|
193
|
+
const normalized = query.trim().toLocaleLowerCase("en");
|
|
194
|
+
const allFiles = packs.flatMap((pack) => pack.files);
|
|
195
|
+
const exact = allFiles.filter((file) => file.path.toLocaleLowerCase("en") === normalized || file.title.toLocaleLowerCase("en") === normalized);
|
|
196
|
+
if (exact.length) {
|
|
197
|
+
return {
|
|
198
|
+
files: exact.slice(0, MAX_SELECTED_FILES),
|
|
199
|
+
exactLeafBypass: true,
|
|
200
|
+
events: exact.slice(0, MAX_TRACE_EVENTS).map((file) => ({ phase: "file", pack_digest: file.pack_digest, path: file.path, score: 100, reason: "exact-leaf-bypass" })),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const events = [];
|
|
204
|
+
const rankedPacks = packs.map((pack) => ({ pack, score: matchScore(normalized, `${pack.title}\n${pack.abstract}\n${pack.overview}`) }))
|
|
205
|
+
.sort((left, right) => right.score - left.score || left.pack.pack_digest.localeCompare(right.pack.pack_digest, "en"));
|
|
206
|
+
for (const { pack, score } of rankedPacks.slice(0, MAX_TRACE_EVENTS)) {
|
|
207
|
+
events.push({ phase: "pack", pack_digest: pack.pack_digest, score, reason: score ? "summary-match" : "summary-miss" });
|
|
208
|
+
}
|
|
209
|
+
const matchingPackDigests = new Set(rankedPacks.filter(({ score }) => score > 0).map(({ pack }) => pack.pack_digest));
|
|
210
|
+
const packCandidates = matchingPackDigests.size ? allFiles.filter((file) => matchingPackDigests.has(file.pack_digest)) : allFiles;
|
|
211
|
+
const directories = [];
|
|
212
|
+
const visit = (packDigest, node) => {
|
|
213
|
+
if (node.path)
|
|
214
|
+
directories.push({ pack_digest: packDigest, node, score: matchScore(normalized, `${node.path}\n${node.abstract}\n${node.overview}`) });
|
|
215
|
+
for (const child of node.children)
|
|
216
|
+
if (isDirectory(child))
|
|
217
|
+
visit(packDigest, child);
|
|
218
|
+
};
|
|
219
|
+
for (const pack of rankedPacks.map(({ pack }) => pack)) {
|
|
220
|
+
if (!matchingPackDigests.size || matchingPackDigests.has(pack.pack_digest))
|
|
221
|
+
visit(pack.pack_digest, pack.tree);
|
|
222
|
+
}
|
|
223
|
+
directories.sort((left, right) => right.score - left.score || left.pack_digest.localeCompare(right.pack_digest, "en") || left.node.path.localeCompare(right.node.path, "en"));
|
|
224
|
+
const selectedDirectories = directories.filter(({ score }) => score > 0).slice(0, MAX_SELECTED_DIRECTORIES);
|
|
225
|
+
for (const { pack_digest, node, score } of selectedDirectories) {
|
|
226
|
+
if (events.length >= MAX_TRACE_EVENTS)
|
|
227
|
+
break;
|
|
228
|
+
events.push({ phase: "directory", pack_digest, path: node.path, score, reason: "selected-summary-match" });
|
|
229
|
+
}
|
|
230
|
+
const directoryCandidates = selectedDirectories.length
|
|
231
|
+
? packCandidates.filter((file) => selectedDirectories.some(({ pack_digest, node }) => file.pack_digest === pack_digest && (file.path === node.path || file.path.startsWith(`${node.path}/`))))
|
|
232
|
+
: packCandidates;
|
|
233
|
+
const ranked = directoryCandidates.map((file) => ({ file, score: matchScore(normalized, `${file.path}\n${file.abstract}\n${file.overview}`) }))
|
|
234
|
+
.sort((left, right) => right.score - left.score || left.file.pack_digest.localeCompare(right.file.pack_digest, "en") || left.file.path.localeCompare(right.file.path, "en"));
|
|
235
|
+
let selected = ranked.filter(({ score }) => score > 0).slice(0, MAX_SELECTED_FILES);
|
|
236
|
+
if (!selected.length) {
|
|
237
|
+
selected = ranked.slice(0, MAX_SELECTED_FILES);
|
|
238
|
+
events.push({ phase: "fallback", reason: "no-summary-match" });
|
|
239
|
+
}
|
|
240
|
+
for (const { file, score } of selected) {
|
|
241
|
+
if (events.length >= MAX_TRACE_EVENTS)
|
|
242
|
+
break;
|
|
243
|
+
events.push({ phase: "file", pack_digest: file.pack_digest, path: file.path, score, reason: score ? "selected-summary-match" : "selected-fallback" });
|
|
244
|
+
}
|
|
245
|
+
return { files: selected.map(({ file }) => file), exactLeafBypass: false, events: events.slice(0, MAX_TRACE_EVENTS) };
|
|
246
|
+
}
|
|
247
|
+
export function publicTree(pack, detail) {
|
|
248
|
+
const project = (node) => {
|
|
249
|
+
if (isDirectory(node)) {
|
|
250
|
+
return { type: "directory", path: node.path, title: node.title, [detail]: node[detail], children: node.children.map(project) };
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
type: "file",
|
|
254
|
+
pack_id: node.pack_id,
|
|
255
|
+
version: node.version,
|
|
256
|
+
pack_digest: node.pack_digest,
|
|
257
|
+
path: node.path,
|
|
258
|
+
kind: node.kind,
|
|
259
|
+
file_digest: node.file_digest,
|
|
260
|
+
title: node.title,
|
|
261
|
+
[detail]: node[detail],
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
pack_id: pack.pack_id,
|
|
266
|
+
version: pack.version,
|
|
267
|
+
pack_digest: pack.pack_digest,
|
|
268
|
+
title: pack.title,
|
|
269
|
+
[detail]: pack[detail],
|
|
270
|
+
tree: project(pack.tree),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const CACHE_DIGEST = /^[a-f0-9]{64}$/;
|
|
274
|
+
const CACHE_RECORD_MAX_BYTES = 4 * 1024 * 1024;
|
|
275
|
+
const CACHE_MAX_FILES = 1024;
|
|
276
|
+
function exactKeys(value, expected) {
|
|
277
|
+
const actual = Object.keys(value).sort((left, right) => left.localeCompare(right, "en"));
|
|
278
|
+
const wanted = [...expected].sort((left, right) => left.localeCompare(right, "en"));
|
|
279
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
280
|
+
}
|
|
281
|
+
function safeCachedPath(value) {
|
|
282
|
+
return value.length > 0
|
|
283
|
+
&& !value.includes("\\")
|
|
284
|
+
&& !value.startsWith("/")
|
|
285
|
+
&& !value.split("/").some((part) => part === "" || part === "." || part === "..");
|
|
286
|
+
}
|
|
287
|
+
function validateCacheRecord(value, directoryDigest, fileDigest, installedFiles) {
|
|
288
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
289
|
+
return "record is not an object";
|
|
290
|
+
const record = value;
|
|
291
|
+
if (!exactKeys(record, ["schema_version", "algorithm", "pack_digest", "visible_set_digest", "files"]))
|
|
292
|
+
return "record has unknown or missing fields";
|
|
293
|
+
if (record.schema_version !== 1 || record.algorithm !== DERIVATION_ALGORITHM || record.pack_digest !== directoryDigest || record.visible_set_digest !== fileDigest)
|
|
294
|
+
return "record identity or algorithm does not match its path";
|
|
295
|
+
if (!Array.isArray(record.files) || record.files.length > CACHE_MAX_FILES)
|
|
296
|
+
return "record files are invalid or oversized";
|
|
297
|
+
const seen = new Set();
|
|
298
|
+
const visibleFiles = [];
|
|
299
|
+
for (const value of record.files) {
|
|
300
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
301
|
+
return "record contains a non-object file";
|
|
302
|
+
const file = value;
|
|
303
|
+
if (!exactKeys(file, ["path", "file_digest", "abstract", "overview"]))
|
|
304
|
+
return "record file has unknown or missing fields";
|
|
305
|
+
if (typeof file.path !== "string" || !safeCachedPath(file.path) || seen.has(file.path))
|
|
306
|
+
return "record contains an unsafe or duplicate path";
|
|
307
|
+
if (typeof file.file_digest !== "string" || !CACHE_DIGEST.test(file.file_digest))
|
|
308
|
+
return "record contains an invalid file digest";
|
|
309
|
+
const source = installedFiles.get(file.path);
|
|
310
|
+
if (!source || source.file_digest !== file.file_digest)
|
|
311
|
+
return "record is not bound to installed pack content";
|
|
312
|
+
if (typeof file.abstract !== "string" || Buffer.byteLength(file.abstract, "utf8") > ABSTRACT_MAX_BYTES)
|
|
313
|
+
return "record abstract is invalid or oversized";
|
|
314
|
+
if (typeof file.overview !== "string" || Buffer.byteLength(file.overview, "utf8") > OVERVIEW_MAX_BYTES)
|
|
315
|
+
return "record overview is invalid or oversized";
|
|
316
|
+
const expectedAbstract = fileAbstract(source);
|
|
317
|
+
if (file.abstract !== expectedAbstract || file.overview !== fileOverview(source, expectedAbstract))
|
|
318
|
+
return "record summaries do not match immutable pack content";
|
|
319
|
+
seen.add(file.path);
|
|
320
|
+
visibleFiles.push({ pack_digest: directoryDigest, path: file.path, file_digest: file.file_digest });
|
|
321
|
+
}
|
|
322
|
+
if (visibleSetDigest(visibleFiles) !== fileDigest)
|
|
323
|
+
return "record visibility digest does not match its file set";
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Validate the mutable, derived progressive-context cache. Cached records are
|
|
328
|
+
* addressed by both immutable pack digest and the exact visible file set, so a
|
|
329
|
+
* revoked skill can never be loaded through a document-only cache key.
|
|
330
|
+
*
|
|
331
|
+
* Repair is intentionally limited to deleting invalid derived entries. The
|
|
332
|
+
* next hierarchical read deterministically rebuilds them from immutable pack
|
|
333
|
+
* objects and the current project pointer.
|
|
334
|
+
*/
|
|
335
|
+
export async function doctorProgressiveContextCache(cacheBaseDirectory, packs, options = {}) {
|
|
336
|
+
const issues = [];
|
|
337
|
+
const temporaryRemnants = [];
|
|
338
|
+
const repaired = [];
|
|
339
|
+
const installed = new Map(packs.map((pack) => [
|
|
340
|
+
pack.pack_digest,
|
|
341
|
+
new Map(pack.files.map((file) => [file.path, file])),
|
|
342
|
+
]));
|
|
343
|
+
const removeDerived = async (path, label) => {
|
|
344
|
+
if (!options.repair)
|
|
345
|
+
return;
|
|
346
|
+
await rm(path, { recursive: true, force: true });
|
|
347
|
+
repaired.push(label);
|
|
348
|
+
};
|
|
349
|
+
let rootInfo;
|
|
350
|
+
try {
|
|
351
|
+
rootInfo = await lstat(cacheBaseDirectory);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
if (error.code === "ENOENT") {
|
|
355
|
+
return { ok: true, issues, temporary_remnants: temporaryRemnants, repaired };
|
|
356
|
+
}
|
|
357
|
+
issues.push(`Unable to inspect progressive context cache: ${error instanceof Error ? error.message : String(error)}`);
|
|
358
|
+
return { ok: false, issues, temporary_remnants: temporaryRemnants, repaired };
|
|
359
|
+
}
|
|
360
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
|
|
361
|
+
issues.push("Progressive context cache root is not a regular directory.");
|
|
362
|
+
await removeDerived(cacheBaseDirectory, "cache/context");
|
|
363
|
+
if (options.repair)
|
|
364
|
+
issues.length = 0;
|
|
365
|
+
return { ok: issues.length === 0, issues, temporary_remnants: temporaryRemnants, repaired };
|
|
366
|
+
}
|
|
367
|
+
for (const packEntry of (await readdir(cacheBaseDirectory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
|
|
368
|
+
const packPath = join(cacheBaseDirectory, packEntry.name);
|
|
369
|
+
const packInfo = await lstat(packPath);
|
|
370
|
+
if (packEntry.name.endsWith(".tmp")) {
|
|
371
|
+
temporaryRemnants.push(`cache/context/${packEntry.name}`);
|
|
372
|
+
await removeDerived(packPath, `cache/context/${packEntry.name}`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const installedFiles = installed.get(packEntry.name);
|
|
376
|
+
if (!CACHE_DIGEST.test(packEntry.name) || !packInfo.isDirectory() || packInfo.isSymbolicLink() || !installedFiles) {
|
|
377
|
+
issues.push(`Invalid progressive context cache pack entry: ${packEntry.name}`);
|
|
378
|
+
await removeDerived(packPath, `cache/context/${packEntry.name}`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
for (const cacheEntry of (await readdir(packPath, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
|
|
382
|
+
const cachePath = join(packPath, cacheEntry.name);
|
|
383
|
+
const relative = `cache/context/${packEntry.name}/${cacheEntry.name}`;
|
|
384
|
+
const cacheInfo = await lstat(cachePath);
|
|
385
|
+
if (cacheEntry.name.endsWith(".tmp")) {
|
|
386
|
+
temporaryRemnants.push(relative);
|
|
387
|
+
await removeDerived(cachePath, relative);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const match = /^([a-f0-9]{64})\.json$/.exec(cacheEntry.name);
|
|
391
|
+
if (!match || !cacheInfo.isFile() || cacheInfo.isSymbolicLink() || cacheInfo.size > CACHE_RECORD_MAX_BYTES) {
|
|
392
|
+
issues.push(`Invalid progressive context cache entry: ${relative}`);
|
|
393
|
+
await removeDerived(cachePath, relative);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const value = JSON.parse(await readFile(cachePath, "utf8"));
|
|
398
|
+
const invalid = validateCacheRecord(value, packEntry.name, match[1], installedFiles);
|
|
399
|
+
if (invalid)
|
|
400
|
+
throw new Error(invalid);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
issues.push(`Invalid progressive context cache record: ${relative}: ${error instanceof Error ? error.message : String(error)}`);
|
|
404
|
+
await removeDerived(cachePath, relative);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (options.repair) {
|
|
409
|
+
issues.length = 0;
|
|
410
|
+
temporaryRemnants.length = 0;
|
|
411
|
+
}
|
|
412
|
+
return { ok: issues.length === 0 && temporaryRemnants.length === 0, issues, temporary_remnants: temporaryRemnants, repaired };
|
|
413
|
+
}
|
|
@@ -7,6 +7,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText } from "./embeddings.js";
|
|
10
|
+
import { doctorProgressiveContextCache, loadProgressiveContext, publicTree, selectHierarchicalFiles, } from "./context-pack-retrieval.js";
|
|
10
11
|
import { indexOkfBundle, validateOkfBundle } from "./okf.js";
|
|
11
12
|
import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
@@ -547,7 +548,15 @@ async function acquirePointerLock(options) {
|
|
|
547
548
|
catch (error) {
|
|
548
549
|
if (error.code !== "EEXIST")
|
|
549
550
|
throw error;
|
|
550
|
-
|
|
551
|
+
let info;
|
|
552
|
+
try {
|
|
553
|
+
info = lstatSync(lock);
|
|
554
|
+
}
|
|
555
|
+
catch (inspectionError) {
|
|
556
|
+
if (inspectionError.code === "ENOENT")
|
|
557
|
+
continue;
|
|
558
|
+
throw inspectionError;
|
|
559
|
+
}
|
|
551
560
|
if (!info.isDirectory() || info.isSymbolicLink())
|
|
552
561
|
throw new Error("Context pack project pointer lock is unsafe.");
|
|
553
562
|
await delay(10);
|
|
@@ -782,7 +791,7 @@ export async function listVisibleContext(options) {
|
|
|
782
791
|
}
|
|
783
792
|
return { schema_version: 1, packs: [...grouped.values()] };
|
|
784
793
|
}
|
|
785
|
-
|
|
794
|
+
async function flatSearchVisibleContext(query, options) {
|
|
786
795
|
const normalized = query.trim().toLocaleLowerCase("en");
|
|
787
796
|
if (!normalized)
|
|
788
797
|
throw new Error("Context pack search query must not be empty.");
|
|
@@ -829,6 +838,137 @@ export async function searchVisibleContext(query, options) {
|
|
|
829
838
|
results.sort((a, b) => b.score - a.score || a.pack_digest.localeCompare(b.pack_digest, "en") || a.path.localeCompare(b.path, "en") || a.offset - b.offset);
|
|
830
839
|
return { schema_version: 1, query, search_mode: "substring", results: results.slice(0, options.limit ?? 20) };
|
|
831
840
|
}
|
|
841
|
+
function attachContextResultRefs(payload) {
|
|
842
|
+
const results = payload.results.map((result) => {
|
|
843
|
+
if (typeof result.file_digest !== "string")
|
|
844
|
+
throw new Error("Context search result is missing file provenance.");
|
|
845
|
+
return {
|
|
846
|
+
...result,
|
|
847
|
+
chunk_digest: sha256("cairn-context-chunk-v1\0", result.pack_digest, "\0", result.path, "\0", result.file_digest, "\0", String(result.offset), "\0", result.text),
|
|
848
|
+
};
|
|
849
|
+
});
|
|
850
|
+
const resultDigest = sha256("cairn-context-results-v1\n", results.map(({ chunk_digest }) => chunk_digest).join("\n"));
|
|
851
|
+
return { ...payload, result_digest: resultDigest, results };
|
|
852
|
+
}
|
|
853
|
+
async function progressiveVisibleContext(options) {
|
|
854
|
+
const visible = await visiblePackFiles(options);
|
|
855
|
+
const inputs = await Promise.all(visible.map(async ({ pack, file }) => ({
|
|
856
|
+
pack_id: pack.manifest.id,
|
|
857
|
+
version: pack.manifest.version,
|
|
858
|
+
pack_digest: pack.digest,
|
|
859
|
+
pack_title: pack.manifest.title,
|
|
860
|
+
pack_description: pack.manifest.description,
|
|
861
|
+
path: file.path,
|
|
862
|
+
kind: file.kind,
|
|
863
|
+
title: file.title,
|
|
864
|
+
description: file.description,
|
|
865
|
+
keywords: file.keywords,
|
|
866
|
+
file_digest: file.sha256,
|
|
867
|
+
text: await readFile(join(pack.root, file.path), "utf8"),
|
|
868
|
+
})));
|
|
869
|
+
return { packs: await loadProgressiveContext(inputs, join(packBaseDir(), "cache", "context")), visible };
|
|
870
|
+
}
|
|
871
|
+
function packMatches(selector, pack) {
|
|
872
|
+
return pack.pack_digest === selector || pack.pack_digest.startsWith(selector) || pack.pack_id === selector || `${pack.pack_id}@${pack.version}` === selector;
|
|
873
|
+
}
|
|
874
|
+
export async function treeVisibleContext(options) {
|
|
875
|
+
const detail = options.detail ?? "abstract";
|
|
876
|
+
const { packs } = await progressiveVisibleContext(options);
|
|
877
|
+
const selected = options.pack ? packs.filter((pack) => packMatches(options.pack, pack)) : packs;
|
|
878
|
+
if (options.pack && selected.length !== 1)
|
|
879
|
+
throw new Error(selected.length ? "Context pack selector is ambiguous." : "Context pack is not enabled.");
|
|
880
|
+
return { schema_version: 1, detail, packs: selected.map((pack) => publicTree(pack, detail)) };
|
|
881
|
+
}
|
|
882
|
+
function progressiveFileKey(file) {
|
|
883
|
+
return `${file.pack_digest}:${file.path}`;
|
|
884
|
+
}
|
|
885
|
+
export async function searchVisibleContext(query, options) {
|
|
886
|
+
const strategy = options.strategy ?? "flat";
|
|
887
|
+
const detail = options.detail ?? "content";
|
|
888
|
+
if (strategy === "flat" && detail === "content" && !options.explain) {
|
|
889
|
+
const payload = await flatSearchVisibleContext(query, options);
|
|
890
|
+
return options.includeRefs ? attachContextResultRefs(payload) : payload;
|
|
891
|
+
}
|
|
892
|
+
const normalized = query.trim().toLocaleLowerCase("en");
|
|
893
|
+
if (!normalized)
|
|
894
|
+
throw new Error("Context pack search query must not be empty.");
|
|
895
|
+
const { packs, visible } = await progressiveVisibleContext(options);
|
|
896
|
+
const progressiveFiles = packs.flatMap((pack) => pack.files);
|
|
897
|
+
if (strategy === "flat") {
|
|
898
|
+
// Summary detail is one result per file. Retrieve all matching chunks
|
|
899
|
+
// before deduplication so a chatty first file cannot consume the public
|
|
900
|
+
// result limit and hide later matching files.
|
|
901
|
+
const flat = await flatSearchVisibleContext(query, { ...options, limit: Number.MAX_SAFE_INTEGER });
|
|
902
|
+
const summaries = new Map(progressiveFiles.map((file) => [progressiveFileKey(file), file]));
|
|
903
|
+
const seen = new Set();
|
|
904
|
+
const allResults = flat.results.flatMap((result) => {
|
|
905
|
+
const key = progressiveFileKey(result);
|
|
906
|
+
if (detail !== "content" && seen.has(key))
|
|
907
|
+
return [];
|
|
908
|
+
seen.add(key);
|
|
909
|
+
const summary = summaries.get(key);
|
|
910
|
+
return [{ ...result, offset: detail === "content" ? result.offset : 0, text: detail === "content" ? result.text : summary?.[detail] ?? result.text }];
|
|
911
|
+
});
|
|
912
|
+
const results = allResults.slice(0, options.limit ?? 20);
|
|
913
|
+
const trace = options.explain ? {
|
|
914
|
+
exact_leaf_bypass: false,
|
|
915
|
+
considered_files: progressiveFiles.length,
|
|
916
|
+
selected_files: new Set(results.map(progressiveFileKey)).size,
|
|
917
|
+
events: results.slice(0, 64).map((result) => ({ phase: "file", pack_digest: result.pack_digest, path: result.path, score: result.score, reason: "flat-result" })),
|
|
918
|
+
truncated: allResults.length > results.length || results.length > 64,
|
|
919
|
+
} : undefined;
|
|
920
|
+
const payload = { ...flat, strategy, detail, results, ...(trace ? { trace } : {}) };
|
|
921
|
+
return options.includeRefs ? attachContextResultRefs(payload) : payload;
|
|
922
|
+
}
|
|
923
|
+
const selection = selectHierarchicalFiles(query, packs);
|
|
924
|
+
const visibleByKey = new Map(visible.map((entry) => [`${entry.pack.digest}:${entry.file.path}`, entry]));
|
|
925
|
+
const selectedScores = new Map(selection.events.filter((event) => event.phase === "file" && event.path && event.pack_digest)
|
|
926
|
+
.map((event) => [`${event.pack_digest}:${event.path}`, event.score ?? 0]));
|
|
927
|
+
const results = [];
|
|
928
|
+
for (const file of selection.files) {
|
|
929
|
+
const source = visibleByKey.get(progressiveFileKey(file));
|
|
930
|
+
if (!source)
|
|
931
|
+
continue;
|
|
932
|
+
const base = { ...provenance(source.pack, source.file), ...await okfProvenance(source.pack, source.file) };
|
|
933
|
+
const summaryScore = selectedScores.get(progressiveFileKey(file)) ?? 0;
|
|
934
|
+
if (detail !== "content") {
|
|
935
|
+
results.push({ ...base, offset: 0, text: file[detail], score: summaryScore });
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
const fileChunks = chunks(file.text);
|
|
939
|
+
let matched = false;
|
|
940
|
+
for (const chunk of fileChunks) {
|
|
941
|
+
const searchable = `${file.title}\n${file.description}\n${file.keywords.join(" ")}\n${chunk.text}`.toLocaleLowerCase("en");
|
|
942
|
+
const occurrences = searchable.split(normalized).length - 1;
|
|
943
|
+
if (!occurrences && !selection.exactLeafBypass)
|
|
944
|
+
continue;
|
|
945
|
+
matched = true;
|
|
946
|
+
results.push({ ...base, offset: chunk.offset, text: chunk.text, score: selection.exactLeafBypass ? 100 : occurrences });
|
|
947
|
+
}
|
|
948
|
+
if (!matched && fileChunks[0])
|
|
949
|
+
results.push({ ...base, offset: fileChunks[0].offset, text: fileChunks[0].text, score: summaryScore });
|
|
950
|
+
}
|
|
951
|
+
results.sort((left, right) => right.score - left.score || left.pack_digest.localeCompare(right.pack_digest, "en") || left.path.localeCompare(right.path, "en") || left.offset - right.offset);
|
|
952
|
+
const limited = results.slice(0, options.limit ?? 20);
|
|
953
|
+
const trace = options.explain ? {
|
|
954
|
+
exact_leaf_bypass: selection.exactLeafBypass,
|
|
955
|
+
considered_packs: packs.length,
|
|
956
|
+
considered_files: progressiveFiles.length,
|
|
957
|
+
selected_files: selection.files.length,
|
|
958
|
+
events: selection.events,
|
|
959
|
+
truncated: selection.events.length >= 64 || results.length > limited.length,
|
|
960
|
+
} : undefined;
|
|
961
|
+
const payload = {
|
|
962
|
+
schema_version: 1,
|
|
963
|
+
query,
|
|
964
|
+
search_mode: "substring",
|
|
965
|
+
strategy,
|
|
966
|
+
detail,
|
|
967
|
+
results: limited,
|
|
968
|
+
...(trace ? { trace } : {}),
|
|
969
|
+
};
|
|
970
|
+
return options.includeRefs ? attachContextResultRefs(payload) : payload;
|
|
971
|
+
}
|
|
832
972
|
export async function readVisibleContext(packSelector, path, options) {
|
|
833
973
|
const normalized = normalizePackPath(path);
|
|
834
974
|
const visible = await visiblePackFiles(options);
|
|
@@ -873,7 +1013,8 @@ export async function relatedVisibleContext(packSelector, path, options) {
|
|
|
873
1013
|
}
|
|
874
1014
|
return { schema_version: 1, ...provenance(pack, file), ...await okfProvenance(pack, file), direction, results, diagnostics: index.diagnostics.filter(({ path: diagnosticPath }) => diagnosticPath === file.path) };
|
|
875
1015
|
}
|
|
876
|
-
export async function doctorContextPacks() {
|
|
1016
|
+
export async function doctorContextPacks(options = {}) {
|
|
1017
|
+
const progressiveCachePacks = [];
|
|
877
1018
|
const issues = [];
|
|
878
1019
|
const remnants = [];
|
|
879
1020
|
let objects = 0;
|
|
@@ -883,6 +1024,23 @@ export async function doctorContextPacks() {
|
|
|
883
1024
|
objects = installed.length;
|
|
884
1025
|
for (const item of installed) {
|
|
885
1026
|
const pack = await validateContextPack(objectRoot(item.digest));
|
|
1027
|
+
progressiveCachePacks.push({
|
|
1028
|
+
pack_digest: pack.digest,
|
|
1029
|
+
files: await Promise.all(pack.manifest.files.map(async (file) => ({
|
|
1030
|
+
pack_id: pack.manifest.id,
|
|
1031
|
+
version: pack.manifest.version,
|
|
1032
|
+
pack_digest: pack.digest,
|
|
1033
|
+
pack_title: pack.manifest.title,
|
|
1034
|
+
pack_description: pack.manifest.description,
|
|
1035
|
+
path: file.path,
|
|
1036
|
+
kind: file.kind,
|
|
1037
|
+
file_digest: file.sha256,
|
|
1038
|
+
title: file.title,
|
|
1039
|
+
description: file.description,
|
|
1040
|
+
keywords: file.keywords,
|
|
1041
|
+
text: await readFile(join(pack.root, file.path), "utf8"),
|
|
1042
|
+
}))),
|
|
1043
|
+
});
|
|
886
1044
|
if (pack.manifest.source_format?.name === "okf") {
|
|
887
1045
|
const graph = await indexOkfBundle(pack.root, pack.manifest.files.map(({ path }) => path), pack.manifest.source_format.version);
|
|
888
1046
|
expectedGraphs.set(pack.digest, JSON.stringify(graph));
|
|
@@ -984,6 +1142,16 @@ export async function doctorContextPacks() {
|
|
|
984
1142
|
issues.push(`Stale context pack skill approval: ${path} -> ${approval.path}`);
|
|
985
1143
|
}
|
|
986
1144
|
}
|
|
1145
|
+
const progressiveCache = await doctorProgressiveContextCache(join(packBaseDir(), "cache", "context"), progressiveCachePacks, { repair: options.repair });
|
|
1146
|
+
issues.push(...progressiveCache.issues);
|
|
1147
|
+
remnants.push(...progressiveCache.temporary_remnants);
|
|
1148
|
+
if (options.repair) {
|
|
1149
|
+
const contextCacheDirectory = join(packBaseDir(), "cache", "context");
|
|
1150
|
+
for (let index = remnants.length - 1; index >= 0; index -= 1) {
|
|
1151
|
+
if (remnants[index] === contextCacheDirectory || remnants[index].startsWith(`${contextCacheDirectory}${sep}`))
|
|
1152
|
+
remnants.splice(index, 1);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
987
1155
|
return { ok: issues.length === 0 && remnants.length === 0, objects, projects: pointers.length, issues, temporary_remnants: remnants };
|
|
988
1156
|
}
|
|
989
1157
|
export async function inspectContextPackUpdate(selector, options) {
|