@cairnkeep/cli 2.15.4 → 2.17.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 +44 -0
- package/CONTRIBUTING.md +12 -0
- package/README.md +85 -590
- package/docs/README.md +87 -0
- package/docs/agents.md +193 -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 +23 -0
- package/docs/learning/CURRICULUM-MAP.md +3 -1
- package/docs/learning/FEATURE-GUIDE.md +23 -4
- package/docs/learning/README.md +4 -0
- package/docs/learning/lessons/L00-why-cairnkeep.md +3 -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 +9 -10
- 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 +18 -5
- 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/native-windows.md +5 -0
- package/docs/operating.md +65 -3
- package/docs/privacy-and-data-flow.md +22 -0
- package/docs/quickstart.md +12 -4
- package/docs/releasing.md +27 -19
- 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 +1 -1
- 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 +3 -0
- package/scripts/completion.sh +17 -6
- package/scripts/doctor.sh +4 -2
- package/scripts/setup.mjs +92 -13
- package/scripts/terminal-prompts.mjs +172 -0
- package/scripts/test-completion.sh +8 -1
- package/scripts/test-docs-navigation.sh +63 -0
- package/scripts/test-doctor.sh +21 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-release-sbom.sh +44 -16
- package/scripts/test-setup-preflight.mjs +130 -0
- package/scripts/test-windows-native.mjs +30 -0
- package/scripts/verify-docs-parity.sh +28 -28
- package/scripts/windows-platform.mjs +8 -5
package/docs/quickstart.md
CHANGED
|
@@ -7,18 +7,19 @@ the default store stays on this computer.
|
|
|
7
7
|
## Codex CLI
|
|
8
8
|
|
|
9
9
|
Install Cairnkeep, then let guided setup initialize an empty project and write
|
|
10
|
-
Codex's project-scoped MCP entry
|
|
10
|
+
Codex's project-scoped MCP entry. Select **Initialize a Git repository**,
|
|
11
|
+
tick **Codex CLI** with Space, keep local memory, review the plan, and apply it:
|
|
11
12
|
|
|
12
13
|
```bash
|
|
13
14
|
npm install --global @cairnkeep/cli
|
|
14
|
-
cairn setup /path/to/project
|
|
15
|
+
cairn setup /path/to/project
|
|
15
16
|
cd /path/to/project
|
|
16
17
|
cairn doctor
|
|
17
18
|
./.ai/start-codex.sh
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
native Windows, launch with `.\.ai\start-codex.cmd`.
|
|
21
|
+
The wizard recommends the existing repository mode when the target is already
|
|
22
|
+
a Git work tree. On native Windows, launch with `.\.ai\start-codex.cmd`.
|
|
22
23
|
|
|
23
24
|
Before the first session, review `.codex/config.toml` and accept Codex's project
|
|
24
25
|
trust prompt. The generated entry runs `cairn memory-server` locally. Setup
|
|
@@ -53,6 +54,13 @@ cairn doctor
|
|
|
53
54
|
Setup reports machine-level commands but never executes them. This keeps
|
|
54
55
|
project scaffolding separate from harness-wide changes.
|
|
55
56
|
|
|
57
|
+
For scripts, CI, or reproducible fleet setup, skip the selector and provide all
|
|
58
|
+
choices explicitly:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cairn setup /path/to/project --git init --harness codex --memory local --yes
|
|
62
|
+
```
|
|
63
|
+
|
|
56
64
|
## First memory cycle
|
|
57
65
|
|
|
58
66
|
In a harness with Cairnkeep commands, remember one durable fact and recall it:
|
package/docs/releasing.md
CHANGED
|
@@ -10,37 +10,45 @@ from a workstation.
|
|
|
10
10
|
`npm --prefix mcp-memory-server test`.
|
|
11
11
|
3. Merge only after every required CI check passes.
|
|
12
12
|
4. Publish a GitHub Release whose tag is exactly `v<package.json version>`.
|
|
13
|
-
5.
|
|
14
|
-
|
|
15
|
-
6
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
7.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
8.
|
|
13
|
+
5. After every platform and runtime job passes, CI builds one release candidate
|
|
14
|
+
keyed by the exact Git tree. It contains the npm tarball, reproducible
|
|
15
|
+
CycloneDX 1.6 SBOM, checksums, and a tree/version manifest. CI retains it for
|
|
16
|
+
30 days.
|
|
17
|
+
6. The `Publish release` workflow checks out the immutable tag, resolves its
|
|
18
|
+
tree, and accepts a candidate only from a successful `CI` run for that exact
|
|
19
|
+
tree. It verifies the manifest and checksums instead of rebuilding or
|
|
20
|
+
repeating the complete test suite.
|
|
21
|
+
7. After candidate verification, npm publication and the memory-server and
|
|
22
|
+
workspace container publications run in parallel. npm retains provenance;
|
|
23
|
+
the containers retain their per-image SBOM and provenance attestations.
|
|
24
|
+
8. The workflow attaches the verified SBOM, npm tarball, and SHA-256 checksums
|
|
25
|
+
to the GitHub Release. It publishes versioned `linux/amd64` and
|
|
26
|
+
`linux/arm64` images to GHCR and updates `latest` for a stable release or
|
|
27
|
+
`next` for a prerelease.
|
|
28
|
+
9. Verify both image digests, SBOM attestations, provenance attestations, and an
|
|
25
29
|
anonymous pull before announcing the release.
|
|
26
30
|
|
|
27
31
|
Stable versions must use a stable GitHub Release and publish to npm's `latest`
|
|
28
32
|
tag. SemVer prerelease versions must use a GitHub prerelease and publish to
|
|
29
33
|
`next`. A mismatch fails before publication.
|
|
30
34
|
|
|
31
|
-
The workflow is safe to rerun: it
|
|
32
|
-
image when that exact version is already
|
|
33
|
-
|
|
35
|
+
The workflow is safe to rerun: it re-verifies the same CI candidate, skips
|
|
36
|
+
`npm publish` and each versioned OCI image when that exact version is already
|
|
37
|
+
present, retries attestations against the resolved image digests, and moves
|
|
34
38
|
only the `latest` or `next` channel tag. A versioned image tag is never rebuilt
|
|
35
|
-
or moved.
|
|
39
|
+
or moved. If the tree-addressed candidate has expired, rerun CI for the exact
|
|
40
|
+
release commit before retrying publication; the workflow never substitutes a
|
|
41
|
+
different version or tree.
|
|
36
42
|
|
|
37
43
|
## Repository configuration
|
|
38
44
|
|
|
39
45
|
The repository must provide an `NPM_TOKEN` Actions secret authorized to publish
|
|
40
46
|
`@cairnkeep/cli`. Keep that credential out of local files and rotate it according
|
|
41
|
-
to the npm account's security policy. The workflow grants `
|
|
42
|
-
`
|
|
43
|
-
|
|
47
|
+
to the npm account's security policy. The workflow grants `actions: read`,
|
|
48
|
+
`contents: write`, `packages: write`, `attestations: write`, and
|
|
49
|
+
`id-token: write`. Read access resolves the successful tree-addressed CI
|
|
50
|
+
artifact; the last two permissions are required for registry-backed build
|
|
51
|
+
provenance.
|
|
44
52
|
|
|
45
53
|
GitHub creates each new GHCR package as private. After the first container
|
|
46
54
|
release, a package administrator must change both `cairnkeep` and
|
package/docs/storage.md
CHANGED
|
@@ -576,3 +576,24 @@ link index is derived data under `cache/graphs/<pack-digest>.json`; corruption
|
|
|
576
576
|
or deletion does not affect identity and causes a rebuild from the object. OKF
|
|
577
577
|
exports are not stored automatically: `export-okf --apply` creates a new
|
|
578
578
|
operator-selected directory only after its preview digest is confirmed.
|
|
579
|
+
|
|
580
|
+
## Context-intelligence derived and review state
|
|
581
|
+
|
|
582
|
+
Progressive context-pack abstracts and overviews live below
|
|
583
|
+
`${CAIRN_PACK_BASE_DIR:-~/.cairnkeep/packs}/cache/context/<pack-digest>/` as
|
|
584
|
+
digest-named JSON. This cache is derived, permission-restricted, outside
|
|
585
|
+
immutable pack objects, and safe to discard or deterministically rebuild.
|
|
586
|
+
Validation rejects symlinks, invalid names, oversized records, digest mismatches,
|
|
587
|
+
and temporary remnants. Repair removes invalid derived entries only; it never
|
|
588
|
+
changes a project pointer or skill approval.
|
|
589
|
+
|
|
590
|
+
Review-gated memory proposals live under
|
|
591
|
+
`<project>/.agentfs/memory-proposals/<proposal-digest>.json`. The directory is mode
|
|
592
|
+
`0700` and files are mode `0600` on POSIX. Proposals are immutable and include
|
|
593
|
+
the source trajectory digest and base reviewed-memory hashes used to reject stale
|
|
594
|
+
application. Applying a proposal writes through the existing reviewed-memory
|
|
595
|
+
store; it does not turn the proposal directory into a second memory authority.
|
|
596
|
+
|
|
597
|
+
Context-usage receipts are strict links in the existing work-evidence store.
|
|
598
|
+
They inherit work-evidence inspection, retention, and doctor behavior; there is
|
|
599
|
+
no separate prompt or retrieved-content log.
|
package/docs/work-evidence.md
CHANGED
|
@@ -52,6 +52,20 @@ bodies are not included. The patch is redacted, bounded by the lower of the
|
|
|
52
52
|
work-evidence and artifact limits, and stored in the existing artifact store.
|
|
53
53
|
Cairnkeep provides no patch apply, restore or replay command.
|
|
54
54
|
|
|
55
|
+
## Context-usage receipts
|
|
56
|
+
|
|
57
|
+
With context packs enabled, `context_pack_search` can return stable references
|
|
58
|
+
when called with `include_refs: true`. With `CAIRN_WORK_EVIDENCE=1` active, set
|
|
59
|
+
`CAIRN_CONTEXT_USAGE=1` as the second gate to expose the
|
|
60
|
+
local-only `context_usage_record` mutation. It links `task_digest`,
|
|
61
|
+
`result_digest`, and an outcome (`used`, `unused`, or `unknown`) to the active
|
|
62
|
+
work-evidence record, or to an explicit `evidence_id`.
|
|
63
|
+
|
|
64
|
+
Receipts intentionally exclude the query, prompt, retrieved text, and model
|
|
65
|
+
response. Their ID is deterministic, repeat recording is idempotent, conflicting
|
|
66
|
+
outcomes fail, and the normal work-evidence doctor and retention rules apply.
|
|
67
|
+
Because recording changes evidence, the read-only MCP profile excludes the tool.
|
|
68
|
+
|
|
55
69
|
## Inspect and maintain
|
|
56
70
|
|
|
57
71
|
```sh
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"identifier":"cairn-offline-fake-v1","package_version":"2.
|
|
1
|
+
{"identifier":"cairn-offline-fake-v1","package_version":"2.17.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
|
|
@@ -21,6 +21,7 @@ function usage() {
|
|
|
21
21
|
cairn pack skills [--project PATH | --project-id ID] [--json]
|
|
22
22
|
cairn pack approve-skill SELECTOR PATH --confirm FILE_DIGEST [--project PATH | --project-id ID]
|
|
23
23
|
cairn pack revoke-skill SELECTOR PATH [--project PATH | --project-id ID]
|
|
24
|
+
cairn pack doctor [--repair] [--json]
|
|
24
25
|
`);
|
|
25
26
|
process.exit(2);
|
|
26
27
|
}
|
|
@@ -205,9 +206,10 @@ async function main() {
|
|
|
205
206
|
return;
|
|
206
207
|
}
|
|
207
208
|
if (command === "doctor") {
|
|
209
|
+
const repair = flag(args, "--repair");
|
|
208
210
|
if (args.length)
|
|
209
211
|
usage();
|
|
210
|
-
const result = await doctorContextPacks();
|
|
212
|
+
const result = await doctorContextPacks({ repair });
|
|
211
213
|
output(result, json, result.ok ? `Context packs healthy (${result.objects} objects, ${result.projects} projects).` : `Context pack problems: ${[...result.issues, ...result.temporary_remnants].join("; ")}`);
|
|
212
214
|
if (!result.ok)
|
|
213
215
|
process.exitCode = 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
|
+
}
|