@cairnkeep/cli 2.13.0 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +7 -4
  3. package/docs/context-packs.md +13 -3
  4. package/docs/harness-compatibility.md +1 -1
  5. package/docs/learning/CURRICULUM-MAP.md +5 -4
  6. package/docs/learning/FEATURE-GUIDE.md +6 -2
  7. package/docs/learning/README.md +2 -1
  8. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  9. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  10. package/docs/learning/lessons/L02-installation.md +2 -2
  11. package/docs/learning/lessons/L03-first-project.md +1 -1
  12. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  13. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  14. package/docs/learning/lessons/L21-context-packs.md +5 -1
  15. package/docs/learning/lessons/L22-native-windows.md +2 -2
  16. package/docs/learning/lessons/L23-guided-setup.md +2 -2
  17. package/docs/learning/lessons/L24-okf-exchange.md +111 -0
  18. package/docs/learning/tracks/operator.md +4 -1
  19. package/docs/learning/video-scripts/V24-okf-exchange.md +31 -0
  20. package/docs/open-knowledge-format.md +100 -0
  21. package/docs/operating.md +14 -0
  22. package/docs/privacy-and-data-flow.md +2 -0
  23. package/docs/releasing.md +6 -2
  24. package/docs/storage.md +6 -0
  25. package/examples/eval/bundled-fake.json +1 -1
  26. package/mcp-memory-server/dist/context-pack-cli.js +54 -1
  27. package/mcp-memory-server/dist/context-pack.js +243 -47
  28. package/mcp-memory-server/dist/index.js +13 -1
  29. package/mcp-memory-server/dist/mcp-tool-catalog.js +1 -0
  30. package/mcp-memory-server/dist/note-store.js +44 -2
  31. package/mcp-memory-server/dist/okf.js +432 -0
  32. package/mcp-memory-server/package.json +3 -1
  33. package/package.json +6 -2
  34. package/schemas/context-pack.schema.json +9 -0
  35. package/scripts/completion.sh +3 -3
  36. package/scripts/test-learning-docs.sh +2 -2
  37. package/scripts/test-release-sbom.sh +91 -0
package/docs/releasing.md CHANGED
@@ -12,8 +12,12 @@ from a workstation.
12
12
  4. Publish a GitHub Release whose tag is exactly `v<package.json version>`.
13
13
  5. The `Publish release` workflow checks out that immutable tag, repeats the
14
14
  tests, and publishes `@cairnkeep/cli` with npm provenance.
15
- 6. The workflow attaches the npm tarball, a CycloneDX SBOM, and SHA-256 checksums
16
- to the GitHub Release.
15
+ 6. The workflow uses the exact locked `@cyclonedx/cyclonedx-npm` development
16
+ dependency to generate a reproducible CycloneDX 1.6 SBOM from
17
+ `package-lock.json`. Generation includes schema validation and runs before
18
+ npm publication, so an invalid document blocks both publication and
19
+ attachment. The workflow attaches that SBOM, the npm tarball, and SHA-256
20
+ checksums to the GitHub Release.
17
21
  7. The workflow publishes versioned `linux/amd64` and `linux/arm64`
18
22
  memory-server and workspace images to GHCR. It also updates `latest` for a
19
23
  stable release or `next` for a prerelease.
package/docs/storage.md CHANGED
@@ -548,3 +548,9 @@ and switch only the named project after exact-digest confirmation.
548
548
  It never chooses a pack version or grants a skill approval. `cairn pack remove`
549
549
  refuses any referenced digest. Ordinary uninstall retains this directory even
550
550
  when memory is removed; `--purge-packs` is the separate backup-first consent.
551
+
552
+ Imported OKF files live inside the same immutable object. The deterministic
553
+ link index is derived data under `cache/graphs/<pack-digest>.json`; corruption
554
+ or deletion does not affect identity and causes a rebuild from the object. OKF
555
+ exports are not stored automatically: `export-okf --apply` creates a new
556
+ operator-selected directory only after its preview digest is confirmed.
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.13.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.14.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -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
- export async function installContextPack(sourcePath, options = {}) {
327
- const materialized = await materializeSource(sourcePath, options.ref);
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
- const pack = await validateContextPack(materialized.root);
330
- const destination = objectRoot(pack.digest);
331
- if (existsSync(destination)) {
332
- const existing = await validateContextPack(destination);
333
- if (existing.digest !== pack.digest)
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 cp(pack.root, temporary, { recursive: true, errorOnExist: true, force: false });
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
- finally {
365
- await makeWritable(temporary).catch(() => undefined);
366
- await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
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
- await atomicJson(join(packBaseDir(), "sources", `${pack.digest}.json`), { schema_version: 1, source: materialized.source });
369
- return { pack: await validateContextPack(destination), source: materialized.source, existing: !published };
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
- objects = (await listInstalledContextPacks()).length;
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
- const installed = await installContextPack(check.source.kind === "local" ? check.source.path : check.source.url, check.source.kind === "git" ? { ref: check.source.ref } : {});
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)) {