@cairnkeep/cli 2.13.1 → 2.14.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 +16 -1
- package/README.md +7 -4
- package/docs/context-packs.md +13 -3
- package/docs/harness-compatibility.md +1 -1
- package/docs/learning/CURRICULUM-MAP.md +5 -4
- package/docs/learning/FEATURE-GUIDE.md +6 -2
- package/docs/learning/README.md +2 -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/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 +5 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +2 -2
- package/docs/learning/lessons/L24-okf-exchange.md +111 -0
- package/docs/learning/tracks/operator.md +4 -1
- package/docs/learning/video-scripts/V24-okf-exchange.md +31 -0
- package/docs/open-knowledge-format.md +100 -0
- package/docs/operating.md +14 -0
- package/docs/privacy-and-data-flow.md +2 -0
- package/docs/storage.md +6 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/context-pack-cli.js +54 -1
- package/mcp-memory-server/dist/context-pack.js +243 -47
- package/mcp-memory-server/dist/index.js +13 -1
- package/mcp-memory-server/dist/mcp-tool-catalog.js +1 -0
- package/mcp-memory-server/dist/note-store.js +44 -2
- package/mcp-memory-server/dist/okf.js +432 -0
- package/mcp-memory-server/package.json +3 -1
- package/package.json +2 -1
- package/schemas/context-pack.schema.json +9 -0
- package/scripts/completion.sh +3 -3
- package/scripts/test-learning-docs.sh +2 -2
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { rm } from "node:fs/promises";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { applyContextPackUpdate, approvePackSkill, contextPackBaseDirectory, disableContextPack, doctorContextPacks, enableContextPack, initializeContextPack, inspectContextPackUpdate, installContextPack, listInstalledContextPacks, listPackSkills, lockContextPack, readProjectPointer, removeContextPack, resolveInstalledPack, revokePackSkill, validateContextPack, } from "./context-pack.js";
|
|
4
|
+
import { applyContextPackUpdate, approvePackSkill, contextPackBaseDirectory, disableContextPack, doctorContextPacks, enableContextPack, initializeContextPack, inspectContextPackUpdate, installContextPack, installOkfContextPack, listInstalledContextPacks, listPackSkills, lockContextPack, readProjectPointer, removeContextPack, resolveInstalledPack, revokePackSkill, validateContextPack, } from "./context-pack.js";
|
|
5
|
+
import { applyOkfExport, planOkfExport, validateOkfBundle } from "./okf.js";
|
|
5
6
|
function usage() {
|
|
6
7
|
process.stderr.write(`Usage:
|
|
7
8
|
cairn pack init [DIRECTORY] [--id ID] [--version VERSION] [--title TITLE] [--description TEXT] [--license LICENSE]
|
|
8
9
|
cairn pack lock|validate [DIRECTORY]
|
|
9
10
|
cairn pack install SOURCE [--ref REF]
|
|
11
|
+
cairn pack import-okf SOURCE --id ID --version VERSION --license LICENSE [--title TITLE] [--description TEXT] [--ref REF]
|
|
12
|
+
cairn pack validate-okf [DIRECTORY]
|
|
13
|
+
cairn pack export-okf --project PATH --output DIRECTORY (--file PATH | --note ID)... --check
|
|
14
|
+
cairn pack export-okf --project PATH --output DIRECTORY (--file PATH | --note ID)... --apply --confirm DIGEST
|
|
10
15
|
cairn pack list [--json]
|
|
11
16
|
cairn pack show SELECTOR [--json]
|
|
12
17
|
cairn pack remove SELECTOR
|
|
@@ -36,6 +41,12 @@ function flag(args, name) {
|
|
|
36
41
|
args.splice(index, 1);
|
|
37
42
|
return true;
|
|
38
43
|
}
|
|
44
|
+
function takeAll(args, name) {
|
|
45
|
+
const values = [];
|
|
46
|
+
while (args.includes(name))
|
|
47
|
+
values.push(take(args, name));
|
|
48
|
+
return values;
|
|
49
|
+
}
|
|
39
50
|
function projectOptions(args) {
|
|
40
51
|
const projectRoot = take(args, "--project");
|
|
41
52
|
const projectId = take(args, "--project-id");
|
|
@@ -80,6 +91,48 @@ async function main() {
|
|
|
80
91
|
output({ id: result.pack.manifest.id, version: result.pack.manifest.version, digest: result.pack.digest, source: result.source, existing: result.existing }, json, `${result.existing ? "Already installed" : "Installed"} ${result.pack.manifest.id}@${result.pack.manifest.version} ${result.pack.digest}`);
|
|
81
92
|
return;
|
|
82
93
|
}
|
|
94
|
+
if (command === "import-okf") {
|
|
95
|
+
const source = args.shift();
|
|
96
|
+
if (!source)
|
|
97
|
+
usage();
|
|
98
|
+
const id = take(args, "--id");
|
|
99
|
+
const version = take(args, "--version");
|
|
100
|
+
const license = take(args, "--license");
|
|
101
|
+
const title = take(args, "--title");
|
|
102
|
+
const description = take(args, "--description");
|
|
103
|
+
const ref = take(args, "--ref");
|
|
104
|
+
if (!id || !version || !license || args.length)
|
|
105
|
+
usage();
|
|
106
|
+
const result = await installOkfContextPack(source, { id, version, license, title, description, ref });
|
|
107
|
+
const payload = { id: result.pack.manifest.id, version: result.pack.manifest.version, digest: result.pack.digest, source: result.source, existing: result.existing, diagnostics: result.diagnostics };
|
|
108
|
+
output(payload, json, `${result.existing ? "Already installed" : "Imported"} ${result.pack.manifest.id}@${result.pack.manifest.version} ${result.pack.digest} (${result.diagnostics.length} diagnostics)`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (command === "validate-okf") {
|
|
112
|
+
const directory = args.shift() ?? process.cwd();
|
|
113
|
+
if (args.length)
|
|
114
|
+
usage();
|
|
115
|
+
const result = await validateOkfBundle(directory);
|
|
116
|
+
output(result, json, `Valid Open Knowledge Format ${result.version} bundle: ${result.files.length} files, ${result.diagnostics.length} diagnostics.`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (command === "export-okf") {
|
|
120
|
+
const projectRoot = take(args, "--project") ?? process.cwd();
|
|
121
|
+
const outputDirectory = take(args, "--output");
|
|
122
|
+
const files = takeAll(args, "--file");
|
|
123
|
+
const noteIds = takeAll(args, "--note");
|
|
124
|
+
const check = flag(args, "--check");
|
|
125
|
+
const apply = flag(args, "--apply");
|
|
126
|
+
const confirm = take(args, "--confirm");
|
|
127
|
+
if (!outputDirectory || args.length || check === apply || (apply && !confirm) || (check && confirm))
|
|
128
|
+
usage();
|
|
129
|
+
const options = { projectRoot, outputDirectory, files, noteIds };
|
|
130
|
+
const result = check ? await planOkfExport(options) : await applyOkfExport(options, confirm);
|
|
131
|
+
output(result, json, check
|
|
132
|
+
? `Export preview ${result.confirmation_digest}: ${result.output_files.length} files, ${result.redaction_replacements} redactions. Re-run with --apply --confirm ${result.confirmation_digest}.`
|
|
133
|
+
: `Exported Open Knowledge Format bundle to ${resolve(outputDirectory)}.`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
83
136
|
if (command === "list") {
|
|
84
137
|
if (args.length)
|
|
85
138
|
usage();
|
|
@@ -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 { indexOkfBundle, validateOkfBundle } from "./okf.js";
|
|
10
11
|
import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
12
13
|
export const CONTEXT_PACK_MANIFEST = "context-pack.json";
|
|
@@ -33,6 +34,10 @@ export const contextPackManifestSchema = z.object({
|
|
|
33
34
|
title: z.string().min(1).max(256),
|
|
34
35
|
description: z.string().min(1).max(2048),
|
|
35
36
|
license: z.string().min(1).max(128),
|
|
37
|
+
source_format: z.object({
|
|
38
|
+
name: z.literal("okf"),
|
|
39
|
+
version: z.string().min(1).max(32),
|
|
40
|
+
}).strict().optional(),
|
|
36
41
|
files: z.array(contextPackFileSchema).max(CONTEXT_PACK_MAX_ENTRIES),
|
|
37
42
|
}).strict();
|
|
38
43
|
function sha256(...parts) {
|
|
@@ -323,56 +328,114 @@ async function materializeSource(source, ref) {
|
|
|
323
328
|
throw new Error(`Unable to materialize Git context pack: ${error instanceof Error ? error.message : String(error)}`);
|
|
324
329
|
}
|
|
325
330
|
}
|
|
326
|
-
|
|
327
|
-
const
|
|
331
|
+
async function publishContextPack(pack, source) {
|
|
332
|
+
const destination = objectRoot(pack.digest);
|
|
333
|
+
if (existsSync(destination)) {
|
|
334
|
+
const existing = await validateContextPack(destination);
|
|
335
|
+
if (existing.digest !== pack.digest)
|
|
336
|
+
throw new Error("Stored context pack object failed verification.");
|
|
337
|
+
await makeImmutable(destination);
|
|
338
|
+
await atomicJson(join(packBaseDir(), "sources", `${pack.digest}.json`), { schema_version: 1, source });
|
|
339
|
+
return { pack: existing, source, existing: true };
|
|
340
|
+
}
|
|
341
|
+
const objects = dirname(destination);
|
|
342
|
+
await mkdir(objects, { recursive: true, mode: 0o700 });
|
|
343
|
+
const temporary = join(objects, `.${pack.digest}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
344
|
+
let published = true;
|
|
328
345
|
try {
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
throw new Error("Stored context pack object failed verification.");
|
|
335
|
-
await makeImmutable(destination);
|
|
336
|
-
await atomicJson(join(packBaseDir(), "sources", `${pack.digest}.json`), { schema_version: 1, source: materialized.source });
|
|
337
|
-
return { pack: existing, source: materialized.source, existing: true };
|
|
338
|
-
}
|
|
339
|
-
const objects = dirname(destination);
|
|
340
|
-
await mkdir(objects, { recursive: true, mode: 0o700 });
|
|
341
|
-
const temporary = join(objects, `.${pack.digest}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
342
|
-
let published = true;
|
|
346
|
+
await cp(pack.root, temporary, { recursive: true, errorOnExist: true, force: false });
|
|
347
|
+
const copied = await validateContextPack(temporary);
|
|
348
|
+
if (copied.digest !== pack.digest)
|
|
349
|
+
throw new Error("Context pack changed during installation.");
|
|
350
|
+
await makeImmutable(temporary);
|
|
343
351
|
try {
|
|
344
|
-
await
|
|
345
|
-
const copied = await validateContextPack(temporary);
|
|
346
|
-
if (copied.digest !== pack.digest)
|
|
347
|
-
throw new Error("Context pack changed during installation.");
|
|
348
|
-
await makeImmutable(temporary);
|
|
349
|
-
try {
|
|
350
|
-
await rename(temporary, destination);
|
|
351
|
-
}
|
|
352
|
-
catch (error) {
|
|
353
|
-
const code = error.code ?? "";
|
|
354
|
-
const converged = existsSync(destination)
|
|
355
|
-
&& (["EEXIST", "ENOTEMPTY"].includes(code) || (process.platform === "win32" && code === "EPERM"));
|
|
356
|
-
if (!converged)
|
|
357
|
-
throw error;
|
|
358
|
-
const concurrent = await validateContextPack(destination);
|
|
359
|
-
if (concurrent.digest !== pack.digest)
|
|
360
|
-
throw new Error("Concurrent context pack installation produced a conflicting object.");
|
|
361
|
-
published = false;
|
|
362
|
-
}
|
|
352
|
+
await rename(temporary, destination);
|
|
363
353
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
354
|
+
catch (error) {
|
|
355
|
+
const code = error.code ?? "";
|
|
356
|
+
const converged = existsSync(destination)
|
|
357
|
+
&& (["EEXIST", "ENOTEMPTY"].includes(code) || (process.platform === "win32" && code === "EPERM"));
|
|
358
|
+
if (!converged)
|
|
359
|
+
throw error;
|
|
360
|
+
const concurrent = await validateContextPack(destination);
|
|
361
|
+
if (concurrent.digest !== pack.digest)
|
|
362
|
+
throw new Error("Concurrent context pack installation produced a conflicting object.");
|
|
363
|
+
published = false;
|
|
367
364
|
}
|
|
368
|
-
|
|
369
|
-
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
await makeWritable(temporary).catch(() => undefined);
|
|
368
|
+
await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|
|
369
|
+
}
|
|
370
|
+
await atomicJson(join(packBaseDir(), "sources", `${pack.digest}.json`), { schema_version: 1, source });
|
|
371
|
+
return { pack: await validateContextPack(destination), source, existing: !published };
|
|
372
|
+
}
|
|
373
|
+
export async function installContextPack(sourcePath, options = {}) {
|
|
374
|
+
const materialized = await materializeSource(sourcePath, options.ref);
|
|
375
|
+
try {
|
|
376
|
+
return await publishContextPack(await validateContextPack(materialized.root), materialized.source);
|
|
370
377
|
}
|
|
371
378
|
finally {
|
|
372
379
|
if (materialized.cleanup)
|
|
373
380
|
await rm(materialized.cleanup, { recursive: true, force: true });
|
|
374
381
|
}
|
|
375
382
|
}
|
|
383
|
+
async function prepareOkfContextPack(sourceRoot, options) {
|
|
384
|
+
const stagingRoot = await mkdtemp(join(tmpdir(), "cairn-okf-pack-"));
|
|
385
|
+
const staging = join(stagingRoot, "pack");
|
|
386
|
+
try {
|
|
387
|
+
const okf = await validateOkfBundle(sourceRoot);
|
|
388
|
+
await mkdir(staging, { recursive: true, mode: 0o700 });
|
|
389
|
+
for (const file of okf.files) {
|
|
390
|
+
const source = resolve(okf.root, ...file.path.split("/"));
|
|
391
|
+
const target = resolve(staging, ...file.path.split("/"));
|
|
392
|
+
if (!contained(okf.root, source) || !contained(staging, target))
|
|
393
|
+
throw new Error(`Unsafe OKF path: ${file.path}`);
|
|
394
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
395
|
+
await cp(source, target, { errorOnExist: true, force: false });
|
|
396
|
+
}
|
|
397
|
+
const title = options.title?.trim() || okf.files.find(({ path }) => path === "index.md")?.title || options.id;
|
|
398
|
+
const description = options.description?.trim() || `Imported Open Knowledge Format ${okf.version} bundle.`;
|
|
399
|
+
const manifest = contextPackManifestSchema.parse({
|
|
400
|
+
schema_version: 1,
|
|
401
|
+
id: options.id,
|
|
402
|
+
version: options.version,
|
|
403
|
+
title,
|
|
404
|
+
description,
|
|
405
|
+
license: options.license,
|
|
406
|
+
source_format: { name: "okf", version: okf.version },
|
|
407
|
+
files: okf.files.map((file) => ({
|
|
408
|
+
path: file.path,
|
|
409
|
+
kind: "document",
|
|
410
|
+
title: file.title,
|
|
411
|
+
description: file.description,
|
|
412
|
+
keywords: file.keywords,
|
|
413
|
+
sha256: file.sha256,
|
|
414
|
+
})),
|
|
415
|
+
});
|
|
416
|
+
await writeFile(join(staging, CONTEXT_PACK_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
417
|
+
return { pack: await validateContextPack(staging), staging_root: stagingRoot, diagnostics: okf.diagnostics };
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
export async function installOkfContextPack(sourcePath, options) {
|
|
425
|
+
const materialized = await materializeSource(sourcePath, options.ref);
|
|
426
|
+
let prepared;
|
|
427
|
+
try {
|
|
428
|
+
prepared = await prepareOkfContextPack(materialized.root, options);
|
|
429
|
+
const result = await publishContextPack(prepared.pack, materialized.source);
|
|
430
|
+
return { ...result, diagnostics: prepared.diagnostics };
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
if (prepared)
|
|
434
|
+
await rm(prepared.staging_root, { recursive: true, force: true });
|
|
435
|
+
if (materialized.cleanup)
|
|
436
|
+
await rm(materialized.cleanup, { recursive: true, force: true });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
376
439
|
export async function listInstalledContextPacks() {
|
|
377
440
|
const objects = join(packBaseDir(), "objects");
|
|
378
441
|
if (!existsSync(objects))
|
|
@@ -561,6 +624,8 @@ export async function removeContextPack(selector) {
|
|
|
561
624
|
await makeWritable(root);
|
|
562
625
|
await rm(root, { recursive: true, force: true });
|
|
563
626
|
await rm(join(packBaseDir(), "sources", `${pack.digest}.json`), { force: true });
|
|
627
|
+
await rm(join(packBaseDir(), "cache", "graphs", `${pack.digest}.json`), { force: true });
|
|
628
|
+
okfIndexPromises.delete(pack.digest);
|
|
564
629
|
return pack.digest;
|
|
565
630
|
}
|
|
566
631
|
export async function listPackSkills(options) {
|
|
@@ -618,6 +683,40 @@ export async function visiblePackFiles(options) {
|
|
|
618
683
|
function provenance(pack, file) {
|
|
619
684
|
return { pack_id: pack.manifest.id, version: pack.manifest.version, pack_digest: pack.digest, path: file.path, kind: file.kind, file_digest: file.sha256 };
|
|
620
685
|
}
|
|
686
|
+
const okfIndexPromises = new Map();
|
|
687
|
+
async function okfIndex(pack) {
|
|
688
|
+
const sourceFormat = pack.manifest.source_format;
|
|
689
|
+
if (sourceFormat?.name !== "okf")
|
|
690
|
+
return null;
|
|
691
|
+
let pending = okfIndexPromises.get(pack.digest);
|
|
692
|
+
if (!pending) {
|
|
693
|
+
pending = (async () => {
|
|
694
|
+
const cachePath = join(packBaseDir(), "cache", "graphs", `${pack.digest}.json`);
|
|
695
|
+
const built = await indexOkfBundle(pack.root, pack.manifest.files.map(({ path }) => path), sourceFormat.version);
|
|
696
|
+
await atomicJson(cachePath, built).catch(() => undefined);
|
|
697
|
+
return built;
|
|
698
|
+
})();
|
|
699
|
+
okfIndexPromises.set(pack.digest, pending);
|
|
700
|
+
}
|
|
701
|
+
return pending;
|
|
702
|
+
}
|
|
703
|
+
async function okfProvenance(pack, file) {
|
|
704
|
+
const index = await okfIndex(pack);
|
|
705
|
+
if (!index)
|
|
706
|
+
return {};
|
|
707
|
+
const indexed = index.files.find(({ path }) => path === file.path);
|
|
708
|
+
if (!indexed)
|
|
709
|
+
return {};
|
|
710
|
+
return {
|
|
711
|
+
okf: {
|
|
712
|
+
version: index.version,
|
|
713
|
+
role: indexed.role,
|
|
714
|
+
...(indexed.concept ?? {}),
|
|
715
|
+
links: { outbound: indexed.outbound, broken: indexed.broken_links },
|
|
716
|
+
diagnostics: index.diagnostics.filter(({ path }) => path === file.path),
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
}
|
|
621
720
|
function utf8Slice(bytes, start, maximum) {
|
|
622
721
|
const requestedStart = Math.min(start, bytes.length);
|
|
623
722
|
let safeStart = requestedStart;
|
|
@@ -678,7 +777,7 @@ export async function listVisibleContext(options) {
|
|
|
678
777
|
const grouped = new Map();
|
|
679
778
|
for (const { pack, file } of files) {
|
|
680
779
|
const row = grouped.get(pack.digest) ?? { pack_id: pack.manifest.id, version: pack.manifest.version, pack_digest: pack.digest, title: pack.manifest.title, files: [] };
|
|
681
|
-
row.files.push({ ...provenance(pack, file), title: file.title, description: file.description, keywords: file.keywords });
|
|
780
|
+
row.files.push({ ...provenance(pack, file), ...await okfProvenance(pack, file), title: file.title, description: file.description, keywords: file.keywords });
|
|
682
781
|
grouped.set(pack.digest, row);
|
|
683
782
|
}
|
|
684
783
|
return { schema_version: 1, packs: [...grouped.values()] };
|
|
@@ -690,8 +789,9 @@ export async function searchVisibleContext(query, options) {
|
|
|
690
789
|
const candidates = [];
|
|
691
790
|
for (const { pack, file } of await visiblePackFiles(options)) {
|
|
692
791
|
const text = await readFile(join(pack.root, file.path), "utf8");
|
|
792
|
+
const okf = await okfProvenance(pack, file);
|
|
693
793
|
for (const chunk of chunks(text)) {
|
|
694
|
-
candidates.push({ pack, file, offset: chunk.offset, text: chunk.text, searchable: `${file.title}\n${file.description}\n${file.keywords.join(" ")}\n${chunk.text}
|
|
794
|
+
candidates.push({ pack, file, offset: chunk.offset, text: chunk.text, searchable: `${file.title}\n${file.description}\n${file.keywords.join(" ")}\n${chunk.text}`, okf });
|
|
695
795
|
}
|
|
696
796
|
}
|
|
697
797
|
const config = getEmbeddingConfig();
|
|
@@ -713,7 +813,7 @@ export async function searchVisibleContext(query, options) {
|
|
|
713
813
|
});
|
|
714
814
|
cache.save();
|
|
715
815
|
const results = candidates.map((candidate, index) => ({
|
|
716
|
-
...provenance(candidate.pack, candidate.file), offset: candidate.offset, text: candidate.text,
|
|
816
|
+
...provenance(candidate.pack, candidate.file), ...candidate.okf, offset: candidate.offset, text: candidate.text,
|
|
717
817
|
score: cosineSimilarity(embedded[0], vectors[index] ?? []),
|
|
718
818
|
})).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);
|
|
719
819
|
return { schema_version: 1, query, search_mode: "embedding", results: results.slice(0, options.limit ?? 20) };
|
|
@@ -724,7 +824,7 @@ export async function searchVisibleContext(query, options) {
|
|
|
724
824
|
}
|
|
725
825
|
const results = candidates.flatMap((candidate) => {
|
|
726
826
|
const occurrences = candidate.searchable.toLocaleLowerCase("en").split(normalized).length - 1;
|
|
727
|
-
return occurrences ? [{ ...provenance(candidate.pack, candidate.file), offset: candidate.offset, text: candidate.text, score: occurrences }] : [];
|
|
827
|
+
return occurrences ? [{ ...provenance(candidate.pack, candidate.file), ...candidate.okf, offset: candidate.offset, text: candidate.text, score: occurrences }] : [];
|
|
728
828
|
});
|
|
729
829
|
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);
|
|
730
830
|
return { schema_version: 1, query, search_mode: "substring", results: results.slice(0, options.limit ?? 20) };
|
|
@@ -742,14 +842,52 @@ export async function readVisibleContext(packSelector, path, options) {
|
|
|
742
842
|
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 8192)
|
|
743
843
|
throw new Error("Invalid context pack read range.");
|
|
744
844
|
const slice = utf8Slice(bytes, offset, maxBytes);
|
|
745
|
-
return { schema_version: 1, ...provenance(pack, file), offset, text: slice.text, next_offset: offset + slice.bytes < bytes.length ? offset + slice.bytes : null };
|
|
845
|
+
return { schema_version: 1, ...provenance(pack, file), ...await okfProvenance(pack, file), offset, text: slice.text, next_offset: offset + slice.bytes < bytes.length ? offset + slice.bytes : null };
|
|
846
|
+
}
|
|
847
|
+
export async function relatedVisibleContext(packSelector, path, options) {
|
|
848
|
+
const normalized = normalizePackPath(path);
|
|
849
|
+
const visible = await visiblePackFiles(options);
|
|
850
|
+
const matches = visible.filter(({ pack, file }) => (pack.digest === packSelector || pack.digest.startsWith(packSelector) || pack.manifest.id === packSelector) && file.path === normalized);
|
|
851
|
+
if (matches.length !== 1)
|
|
852
|
+
throw new Error(matches.length ? "Context pack selector is ambiguous." : "Context pack file is not enabled or approved.");
|
|
853
|
+
const { pack, file } = matches[0];
|
|
854
|
+
const index = await okfIndex(pack);
|
|
855
|
+
if (!index)
|
|
856
|
+
throw new Error("Related-document traversal is available only for imported Open Knowledge Format packs.");
|
|
857
|
+
const source = index.files.find(({ path: candidate }) => candidate === file.path);
|
|
858
|
+
if (!source)
|
|
859
|
+
throw new Error("Context pack graph does not contain the requested file.");
|
|
860
|
+
const direction = options.direction ?? "both";
|
|
861
|
+
const visiblePaths = new Set(visible.filter(({ pack: candidate }) => candidate.digest === pack.digest).map(({ file: candidate }) => candidate.path));
|
|
862
|
+
const outbound = new Set(direction === "inbound" ? [] : source.outbound);
|
|
863
|
+
const inbound = new Set(direction === "outbound" ? [] : index.files.filter(({ outbound: links }) => links.includes(file.path)).map(({ path: candidate }) => candidate));
|
|
864
|
+
const paths = [...new Set([...outbound, ...inbound])].filter((candidate) => visiblePaths.has(candidate)).sort((a, b) => a.localeCompare(b, "en"));
|
|
865
|
+
const limit = options.limit ?? 20;
|
|
866
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
867
|
+
throw new Error("Invalid related-document limit.");
|
|
868
|
+
const results = [];
|
|
869
|
+
for (const candidatePath of paths.slice(0, limit)) {
|
|
870
|
+
const candidateFile = pack.manifest.files.find(({ path: declared }) => declared === candidatePath);
|
|
871
|
+
const relation = outbound.has(candidatePath) && inbound.has(candidatePath) ? "both" : outbound.has(candidatePath) ? "outbound" : "inbound";
|
|
872
|
+
results.push({ ...provenance(pack, candidateFile), ...await okfProvenance(pack, candidateFile), relation, title: candidateFile.title, description: candidateFile.description, keywords: candidateFile.keywords });
|
|
873
|
+
}
|
|
874
|
+
return { schema_version: 1, ...provenance(pack, file), ...await okfProvenance(pack, file), direction, results, diagnostics: index.diagnostics.filter(({ path: diagnosticPath }) => diagnosticPath === file.path) };
|
|
746
875
|
}
|
|
747
876
|
export async function doctorContextPacks() {
|
|
748
877
|
const issues = [];
|
|
749
878
|
const remnants = [];
|
|
750
879
|
let objects = 0;
|
|
880
|
+
const expectedGraphs = new Map();
|
|
751
881
|
try {
|
|
752
|
-
|
|
882
|
+
const installed = await listInstalledContextPacks();
|
|
883
|
+
objects = installed.length;
|
|
884
|
+
for (const item of installed) {
|
|
885
|
+
const pack = await validateContextPack(objectRoot(item.digest));
|
|
886
|
+
if (pack.manifest.source_format?.name === "okf") {
|
|
887
|
+
const graph = await indexOkfBundle(pack.root, pack.manifest.files.map(({ path }) => path), pack.manifest.source_format.version);
|
|
888
|
+
expectedGraphs.set(pack.digest, JSON.stringify(graph));
|
|
889
|
+
}
|
|
890
|
+
}
|
|
753
891
|
}
|
|
754
892
|
catch (error) {
|
|
755
893
|
issues.push(error instanceof Error ? error.message : String(error));
|
|
@@ -790,6 +928,36 @@ export async function doctorContextPacks() {
|
|
|
790
928
|
}
|
|
791
929
|
}
|
|
792
930
|
}
|
|
931
|
+
const graphDirectory = join(packBaseDir(), "cache", "graphs");
|
|
932
|
+
if (existsSync(graphDirectory)) {
|
|
933
|
+
const directoryInfo = lstatSync(graphDirectory);
|
|
934
|
+
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) {
|
|
935
|
+
issues.push("Context pack graph cache directory is unsafe.");
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
for (const entry of await readdir(graphDirectory, { withFileTypes: true })) {
|
|
939
|
+
const match = /^([a-f0-9]{64})\.json$/.exec(entry.name);
|
|
940
|
+
const path = join(graphDirectory, entry.name);
|
|
941
|
+
if (!entry.isFile() || entry.isSymbolicLink() || !match || lstatSync(path).size > CONTEXT_PACK_MAX_FILE_BYTES || !privatePathIsSafe(path)) {
|
|
942
|
+
issues.push(`Invalid context pack graph cache entry: ${entry.name}`);
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
if (!existsSync(objectRoot(match[1])))
|
|
946
|
+
issues.push(`Orphaned context pack graph cache: ${entry.name}`);
|
|
947
|
+
try {
|
|
948
|
+
const cached = JSON.parse(await readFile(path, "utf8"));
|
|
949
|
+
if (cached.schema_version !== 1 || !Array.isArray(cached.files) || !Array.isArray(cached.diagnostics))
|
|
950
|
+
throw new Error("invalid graph cache schema");
|
|
951
|
+
const expected = expectedGraphs.get(match[1]);
|
|
952
|
+
if (expected && JSON.stringify(cached) !== expected)
|
|
953
|
+
throw new Error("graph cache does not match the immutable object");
|
|
954
|
+
}
|
|
955
|
+
catch (error) {
|
|
956
|
+
issues.push(`Invalid context pack graph cache: ${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
793
961
|
const pointers = await allPointers().catch((error) => { issues.push(error instanceof Error ? error.message : String(error)); return []; });
|
|
794
962
|
for (const { path, pointer } of pointers) {
|
|
795
963
|
const packs = new Map();
|
|
@@ -825,12 +993,26 @@ export async function inspectContextPackUpdate(selector, options) {
|
|
|
825
993
|
throw new Error("Context pack is not enabled for this project.");
|
|
826
994
|
const sourceRecord = JSON.parse(await readFile(join(packBaseDir(), "sources", `${enabled.digest}.json`), "utf8"));
|
|
827
995
|
const source = sourceRecord.source;
|
|
996
|
+
const current = await validateContextPack(objectRoot(enabled.digest));
|
|
828
997
|
const materialized = await materializeSource(source.kind === "local" ? source.path : source.url, source.kind === "git" ? source.ref : undefined);
|
|
998
|
+
let prepared;
|
|
829
999
|
try {
|
|
1000
|
+
if (current.manifest.source_format?.name === "okf") {
|
|
1001
|
+
prepared = await prepareOkfContextPack(materialized.root, {
|
|
1002
|
+
id: current.manifest.id,
|
|
1003
|
+
version: current.manifest.version,
|
|
1004
|
+
license: current.manifest.license,
|
|
1005
|
+
title: current.manifest.title,
|
|
1006
|
+
description: current.manifest.description,
|
|
1007
|
+
});
|
|
1008
|
+
return { current_digest: enabled.digest, candidate_digest: prepared.pack.digest, changed: prepared.pack.digest !== enabled.digest, source: materialized.source, source_format: "okf" };
|
|
1009
|
+
}
|
|
830
1010
|
const candidate = await validateContextPack(materialized.root);
|
|
831
1011
|
return { current_digest: enabled.digest, candidate_digest: candidate.digest, changed: candidate.digest !== enabled.digest, source: materialized.source };
|
|
832
1012
|
}
|
|
833
1013
|
finally {
|
|
1014
|
+
if (prepared)
|
|
1015
|
+
await rm(prepared.staging_root, { recursive: true, force: true });
|
|
834
1016
|
if (materialized.cleanup)
|
|
835
1017
|
await rm(materialized.cleanup, { recursive: true, force: true });
|
|
836
1018
|
}
|
|
@@ -839,7 +1021,21 @@ export async function applyContextPackUpdate(selector, confirm, options) {
|
|
|
839
1021
|
const check = await inspectContextPackUpdate(selector, options);
|
|
840
1022
|
if (confirm !== check.candidate_digest)
|
|
841
1023
|
throw new Error("Update confirmation digest does not match the inspected context pack.");
|
|
842
|
-
|
|
1024
|
+
let installed;
|
|
1025
|
+
if (check.source_format === "okf") {
|
|
1026
|
+
const current = await validateContextPack(objectRoot(check.current_digest));
|
|
1027
|
+
installed = await installOkfContextPack(check.source.kind === "local" ? check.source.path : check.source.url, {
|
|
1028
|
+
id: current.manifest.id,
|
|
1029
|
+
version: current.manifest.version,
|
|
1030
|
+
license: current.manifest.license,
|
|
1031
|
+
title: current.manifest.title,
|
|
1032
|
+
description: current.manifest.description,
|
|
1033
|
+
...(check.source.kind === "git" ? { ref: check.source.ref } : {}),
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
installed = await installContextPack(check.source.kind === "local" ? check.source.path : check.source.url, check.source.kind === "git" ? { ref: check.source.ref } : {});
|
|
1038
|
+
}
|
|
843
1039
|
if (installed.pack.digest !== confirm)
|
|
844
1040
|
throw new Error("Context pack changed after update inspection.");
|
|
845
1041
|
const pointer = await enableContextPack(installed.pack.digest, options);
|
|
@@ -17,7 +17,7 @@ import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashT
|
|
|
17
17
|
import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
|
|
18
18
|
import { canonicalTagsSchema, isTypedMemoryNodesEnabled, memoryImportEnvelopeSchema, noteNodeSchema, nodeTypeSchema, } from "./node-schema.js";
|
|
19
19
|
import { attachNodeMetadata, applyReviewedTypedNode, commitMemoryImport, createTypedNode, deleteTypedNode, getTypedNode, invalidateReviewedTypedNode, listTypedHistory, planMemoryImport, supersedeTypedNode, } from "./node-store.js";
|
|
20
|
-
import { contextPackHttpEnabled, contextPacksEnabled, listVisibleContext, readVisibleContext, searchVisibleContext } from "./context-pack.js";
|
|
20
|
+
import { contextPackHttpEnabled, contextPacksEnabled, listVisibleContext, readVisibleContext, relatedVisibleContext, searchVisibleContext } from "./context-pack.js";
|
|
21
21
|
import { metadataForTool } from "./mcp-tool-catalog.js";
|
|
22
22
|
import { profileAllowsTool, resolveMcpToolProfile } from "./mcp-tool-profile.js";
|
|
23
23
|
import { isWorkEvidenceEnabled } from "./work-evidence-schema.js";
|
|
@@ -1934,6 +1934,18 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProf
|
|
|
1934
1934
|
const payload = await readVisibleContext(pack, path, { ...contextPackProject, offset, maxBytes: max_bytes });
|
|
1935
1935
|
return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
|
|
1936
1936
|
});
|
|
1937
|
+
registerTool("context_pack_related", {
|
|
1938
|
+
description: "Traverse deterministic links between documents in an enabled imported Open Knowledge Format pack.",
|
|
1939
|
+
inputSchema: z.object({
|
|
1940
|
+
pack: z.string().min(1).max(128),
|
|
1941
|
+
path: z.string().min(1).max(512),
|
|
1942
|
+
direction: z.enum(["outbound", "inbound", "both"]).optional(),
|
|
1943
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
1944
|
+
}).strict(),
|
|
1945
|
+
}, async ({ pack, path, direction, limit }) => {
|
|
1946
|
+
const payload = await relatedVisibleContext(pack, path, { ...contextPackProject, direction, limit });
|
|
1947
|
+
return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
|
|
1948
|
+
});
|
|
1937
1949
|
}
|
|
1938
1950
|
if (workEvidenceToolsEnabled) {
|
|
1939
1951
|
registerTool("work_evidence_list", {
|
|
@@ -35,6 +35,7 @@ export const MCP_TOOL_CATALOG = {
|
|
|
35
35
|
context_pack_list: observation("List context packs"),
|
|
36
36
|
context_pack_search: observation("Search context packs"),
|
|
37
37
|
context_pack_read: observation("Read context pack file"),
|
|
38
|
+
context_pack_related: observation("Traverse context pack links"),
|
|
38
39
|
work_evidence_list: observation("List work evidence"),
|
|
39
40
|
work_evidence_read: observation("Read work evidence"),
|
|
40
41
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { buildFailureSignature } from "./failure-signature.js";
|
|
6
6
|
import { NOTE_SCHEMA_VERSION, noteNodeSchema, } from "./note-schema.js";
|
|
7
7
|
import { memoryImportEnvelopeSchema, nodePathSchema, } from "./node-schema.js";
|
|
@@ -229,6 +229,48 @@ function resultFor(notesRoot, entry) {
|
|
|
229
229
|
...(note.canonical_id ? { canonical_id: note.canonical_id } : {}),
|
|
230
230
|
};
|
|
231
231
|
}
|
|
232
|
+
export function readSharedNoteForExport(noteId) {
|
|
233
|
+
if (!noteId || noteId.length > 256)
|
|
234
|
+
throw new Error("Invalid shared note ID.");
|
|
235
|
+
const notesRoot = join(baseDirectory(), "notes");
|
|
236
|
+
const entry = loadManifest(notesRoot).notes[noteId];
|
|
237
|
+
if (!entry)
|
|
238
|
+
throw new Error(`Shared note "${noteId}" was not found.`);
|
|
239
|
+
if (entry.record.node_type !== "shared")
|
|
240
|
+
throw new Error(`Note "${noteId}" is not a promoted shared note.`);
|
|
241
|
+
const notesInfo = lstatSync(notesRoot);
|
|
242
|
+
if (!notesInfo.isDirectory() || notesInfo.isSymbolicLink())
|
|
243
|
+
throw new Error("Shared note store is unsafe.");
|
|
244
|
+
const realNotesRoot = realpathSync(notesRoot);
|
|
245
|
+
const path = resolve(notesRoot, entry.path);
|
|
246
|
+
const realPath = realpathSync(path);
|
|
247
|
+
const rel = relative(realNotesRoot, realPath);
|
|
248
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
249
|
+
throw new Error(`Shared note "${noteId}" has an unsafe path.`);
|
|
250
|
+
}
|
|
251
|
+
const info = lstatSync(path);
|
|
252
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024)
|
|
253
|
+
throw new Error(`Shared note "${noteId}" is unsafe or too large.`);
|
|
254
|
+
const bytes = readFileSync(realPath);
|
|
255
|
+
let content;
|
|
256
|
+
try {
|
|
257
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
throw new Error(`Shared note "${noteId}" is not UTF-8.`);
|
|
261
|
+
}
|
|
262
|
+
if (Buffer.from(content, "utf8").compare(bytes) !== 0)
|
|
263
|
+
throw new Error(`Shared note "${noteId}" is not canonical UTF-8.`);
|
|
264
|
+
return {
|
|
265
|
+
id: entry.record.id,
|
|
266
|
+
title: entry.record.title,
|
|
267
|
+
description: entry.record.description,
|
|
268
|
+
keywords: entry.record.keywords,
|
|
269
|
+
tags: entry.record.tags,
|
|
270
|
+
updated_at: entry.record.updated_at,
|
|
271
|
+
content,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
232
274
|
function rebuildLookups(manifest) {
|
|
233
275
|
const lookups = {};
|
|
234
276
|
for (const [id, entry] of Object.entries(manifest.notes)) {
|