@cairnkeep/cli 2.8.0 → 2.11.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 +68 -1
- package/README.md +82 -27
- package/bin/cairn +6 -153
- package/bin/cairn-container +8 -0
- package/docs/building-an-overlay.md +41 -4
- package/docs/containers.md +10 -0
- package/docs/context-packs.md +94 -0
- package/docs/harness-compatibility.md +37 -3
- package/docs/learning/CURRICULUM-MAP.md +16 -5
- package/docs/learning/FEATURE-GUIDE.md +15 -4
- package/docs/learning/README.md +8 -2
- 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 +11 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
- package/docs/learning/lessons/L21-context-packs.md +79 -0
- package/docs/learning/lessons/L22-native-windows.md +83 -0
- package/docs/learning/lessons/L23-guided-setup.md +138 -0
- package/docs/learning/tracks/operator.md +10 -3
- package/docs/learning/tracks/quickstart.md +7 -3
- package/docs/mcp-tool-profiles.md +43 -0
- package/docs/native-windows.md +122 -0
- package/docs/operating.md +157 -18
- package/docs/privacy-and-data-flow.md +51 -1
- package/docs/storage.md +29 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/artifact-store-windows-worker.js +74 -0
- package/mcp-memory-server/dist/artifact-store.js +196 -55
- package/mcp-memory-server/dist/capability-cli.js +2 -1
- package/mcp-memory-server/dist/capability-config.js +4 -3
- package/mcp-memory-server/dist/capability-harness.js +34 -7
- package/mcp-memory-server/dist/context-pack-cli.js +185 -0
- package/mcp-memory-server/dist/context-pack.js +889 -0
- package/mcp-memory-server/dist/eval-cli.js +6 -5
- package/mcp-memory-server/dist/eval-plan.js +35 -13
- package/mcp-memory-server/dist/eval-process.js +11 -4
- package/mcp-memory-server/dist/eval-report.js +8 -5
- package/mcp-memory-server/dist/eval-runner.js +2 -0
- package/mcp-memory-server/dist/eval-schema.js +1 -0
- package/mcp-memory-server/dist/graph-cli.js +7 -1
- package/mcp-memory-server/dist/index.js +87 -37
- package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
- package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
- package/mcp-memory-server/dist/mcp-tool-profile.js +164 -0
- package/mcp-memory-server/dist/note-store.js +12 -3
- package/mcp-memory-server/dist/pi-mcp-bridge.js +185 -0
- package/mcp-memory-server/dist/platform-security.js +102 -0
- package/mcp-memory-server/dist/skill-store.js +27 -12
- package/mcp-memory-server/package.json +4 -1
- package/package.json +5 -4
- package/pi/extensions/cairnkeep-memory.ts +61 -0
- package/schemas/cairnkeep-setup-policy.schema.json +55 -0
- package/schemas/cairnkeep-setup.schema.json +36 -0
- package/schemas/context-pack.schema.json +33 -0
- package/schemas/eval-report.schema.json +1 -0
- package/scripts/cairn-cli.mjs +119 -0
- package/scripts/cairn-container-cli.mjs +121 -0
- package/scripts/cairn-hook.mjs +199 -0
- package/scripts/completion.sh +33 -5
- package/scripts/doctor.sh +96 -0
- package/scripts/fix-permissions.mjs +12 -0
- package/scripts/memory-wiki-audit.mjs +95 -0
- package/scripts/phase26-test-manifest.mjs +26 -0
- package/scripts/run-repository-tests.mjs +76 -0
- package/scripts/setup-core.mjs +310 -0
- package/scripts/setup-reconcile.mjs +331 -0
- package/scripts/setup.mjs +306 -0
- package/scripts/sync-pi-assets.sh +19 -4
- package/scripts/test-cli-dispatch.sh +6 -0
- package/scripts/test-completion.sh +44 -0
- package/scripts/test-doctor.sh +66 -0
- package/scripts/test-learning-docs.sh +53 -3
- package/scripts/test-package-install.sh +81 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-phase18-capability-lifecycle.sh +1 -1
- package/scripts/test-pi-lifecycle.mjs +190 -0
- package/scripts/test-setup-compatibility.mjs +186 -0
- package/scripts/test-setup-output.sh +131 -0
- package/scripts/test-setup-overlay.sh +113 -0
- package/scripts/test-setup-preflight.mjs +281 -0
- package/scripts/test-setup-reconcile.mjs +257 -0
- package/scripts/test-uninstall.sh +71 -0
- package/scripts/test-windows-native.mjs +136 -0
- package/scripts/uninstall.sh +95 -7
- package/scripts/verify-pi-mcp-bridge.mjs +588 -0
- package/scripts/windows-platform.mjs +725 -0
- package/templates/env.example.template +11 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { chmod, cp, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText } from "./embeddings.js";
|
|
10
|
+
import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
export const CONTEXT_PACK_MANIFEST = "context-pack.json";
|
|
13
|
+
export const CONTEXT_PACK_MAX_FILE_BYTES = 1024 * 1024;
|
|
14
|
+
export const CONTEXT_PACK_MAX_ENTRIES = 1024;
|
|
15
|
+
export const CONTEXT_PACK_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
17
|
+
const GIT_COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
18
|
+
const PACK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
19
|
+
const PROJECT_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
20
|
+
const SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
21
|
+
export const contextPackFileSchema = z.object({
|
|
22
|
+
path: z.string().min(1).max(512),
|
|
23
|
+
kind: z.enum(["document", "skill"]),
|
|
24
|
+
title: z.string().min(1).max(256),
|
|
25
|
+
description: z.string().max(2048),
|
|
26
|
+
keywords: z.array(z.string().min(1).max(128)).max(64),
|
|
27
|
+
sha256: z.string().regex(DIGEST),
|
|
28
|
+
}).strict();
|
|
29
|
+
export const contextPackManifestSchema = z.object({
|
|
30
|
+
schema_version: z.literal(1),
|
|
31
|
+
id: z.string().min(1).max(64).regex(PACK_ID),
|
|
32
|
+
version: z.string().min(1).max(128).regex(SEMVER),
|
|
33
|
+
title: z.string().min(1).max(256),
|
|
34
|
+
description: z.string().min(1).max(2048),
|
|
35
|
+
license: z.string().min(1).max(128),
|
|
36
|
+
files: z.array(contextPackFileSchema).max(CONTEXT_PACK_MAX_ENTRIES),
|
|
37
|
+
}).strict();
|
|
38
|
+
function sha256(...parts) {
|
|
39
|
+
const hash = createHash("sha256");
|
|
40
|
+
for (const part of parts)
|
|
41
|
+
hash.update(part);
|
|
42
|
+
return hash.digest("hex");
|
|
43
|
+
}
|
|
44
|
+
function packBaseDir() {
|
|
45
|
+
const raw = process.env.CAIRN_PACK_BASE_DIR?.trim() || join(homedir(), ".cairnkeep", "packs");
|
|
46
|
+
return resolve(raw === "~" ? homedir() : raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw);
|
|
47
|
+
}
|
|
48
|
+
function objectRoot(digest) {
|
|
49
|
+
if (!DIGEST.test(digest))
|
|
50
|
+
throw new Error("Invalid context pack digest.");
|
|
51
|
+
return join(packBaseDir(), "objects", digest);
|
|
52
|
+
}
|
|
53
|
+
function normalizePackPath(value) {
|
|
54
|
+
if (!value || value.includes("\\") || isAbsolute(value) || value.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
55
|
+
throw new Error(`Unsafe context pack path: ${value}`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function contained(root, candidate) {
|
|
60
|
+
const rel = relative(root, candidate);
|
|
61
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
62
|
+
}
|
|
63
|
+
function assertUtf8(bytes, path) {
|
|
64
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
65
|
+
if (Buffer.from(text, "utf8").compare(bytes) !== 0)
|
|
66
|
+
throw new Error(`Context pack file is not canonical UTF-8: ${path}`);
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
69
|
+
function readPackSource(digest) {
|
|
70
|
+
const directory = join(packBaseDir(), "sources");
|
|
71
|
+
const path = join(directory, `${digest}.json`);
|
|
72
|
+
if (!existsSync(path))
|
|
73
|
+
return undefined;
|
|
74
|
+
const directoryInfo = lstatSync(directory);
|
|
75
|
+
const info = lstatSync(path);
|
|
76
|
+
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()
|
|
77
|
+
|| info.size > 64 * 1024 || !privatePathIsSafe(path)) {
|
|
78
|
+
throw new Error(`Context pack source record is unsafe: ${digest}`);
|
|
79
|
+
}
|
|
80
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
81
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
82
|
+
throw new Error(`Context pack source record is invalid: ${digest}`);
|
|
83
|
+
const record = value;
|
|
84
|
+
if (Object.keys(record).some((key) => !["schema_version", "source"].includes(key)) || record.schema_version !== 1
|
|
85
|
+
|| !record.source || typeof record.source !== "object" || Array.isArray(record.source)) {
|
|
86
|
+
throw new Error(`Context pack source record is invalid: ${digest}`);
|
|
87
|
+
}
|
|
88
|
+
const source = record.source;
|
|
89
|
+
if (source.kind === "local") {
|
|
90
|
+
if (Object.keys(source).some((key) => !["kind", "path"].includes(key)) || typeof source.path !== "string"
|
|
91
|
+
|| !isAbsolute(source.path) || resolve(source.path) !== source.path)
|
|
92
|
+
throw new Error(`Context pack source record is invalid: ${digest}`);
|
|
93
|
+
return { kind: "local", path: source.path };
|
|
94
|
+
}
|
|
95
|
+
if (source.kind === "git") {
|
|
96
|
+
if (Object.keys(source).some((key) => !["kind", "url", "ref", "commit"].includes(key))
|
|
97
|
+
|| typeof source.url !== "string" || !source.url || typeof source.ref !== "string" || !source.ref
|
|
98
|
+
|| typeof source.commit !== "string" || !GIT_COMMIT.test(source.commit))
|
|
99
|
+
throw new Error(`Context pack source record is invalid: ${digest}`);
|
|
100
|
+
rejectCredentialUrl(source.url);
|
|
101
|
+
return { kind: "git", url: source.url, ref: source.ref, commit: source.commit };
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`Context pack source record is invalid: ${digest}`);
|
|
104
|
+
}
|
|
105
|
+
async function walkFiles(root, directory = root) {
|
|
106
|
+
const result = [];
|
|
107
|
+
const directoryInfo = lstatSync(directory);
|
|
108
|
+
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) {
|
|
109
|
+
throw new Error(`Context packs may not contain symlink directories: ${relative(root, directory) || "."}`);
|
|
110
|
+
}
|
|
111
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
|
|
112
|
+
const absolute = join(directory, entry.name);
|
|
113
|
+
const relativePath = relative(root, absolute).split(sep).join("/");
|
|
114
|
+
if (entry.isSymbolicLink())
|
|
115
|
+
throw new Error(`Context packs may not contain symlinks: ${relativePath}`);
|
|
116
|
+
if (entry.isDirectory())
|
|
117
|
+
result.push(...await walkFiles(root, absolute));
|
|
118
|
+
else if (entry.isFile())
|
|
119
|
+
result.push(relativePath);
|
|
120
|
+
else
|
|
121
|
+
throw new Error(`Context packs may contain regular files only: ${relativePath}`);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
export async function validateContextPack(directory) {
|
|
126
|
+
const requestedRoot = resolve(directory);
|
|
127
|
+
const requestedInfo = lstatSync(requestedRoot);
|
|
128
|
+
if (requestedInfo.isSymbolicLink())
|
|
129
|
+
throw new Error("Context pack source may not be a symlink.");
|
|
130
|
+
if (!requestedInfo.isDirectory())
|
|
131
|
+
throw new Error("Context pack source must be a real directory.");
|
|
132
|
+
const root = await realpath(requestedRoot);
|
|
133
|
+
const rootInfo = await stat(root);
|
|
134
|
+
if (!rootInfo.isDirectory() || lstatSync(root).isSymbolicLink())
|
|
135
|
+
throw new Error("Context pack source must be a real directory.");
|
|
136
|
+
const manifestPath = join(root, CONTEXT_PACK_MANIFEST);
|
|
137
|
+
const manifestInfo = lstatSync(manifestPath);
|
|
138
|
+
if (!manifestInfo.isFile() || manifestInfo.isSymbolicLink() || manifestInfo.size > CONTEXT_PACK_MAX_FILE_BYTES)
|
|
139
|
+
throw new Error("Context pack manifest is unsafe.");
|
|
140
|
+
const manifestBytes = await readFile(manifestPath);
|
|
141
|
+
assertUtf8(manifestBytes, CONTEXT_PACK_MANIFEST);
|
|
142
|
+
const manifest = contextPackManifestSchema.parse(JSON.parse(manifestBytes.toString("utf8")));
|
|
143
|
+
const declared = new Set();
|
|
144
|
+
let totalBytes = manifestBytes.byteLength;
|
|
145
|
+
const hash = createHash("sha256").update(manifestBytes).update("\0");
|
|
146
|
+
const content = new Map();
|
|
147
|
+
for (const file of manifest.files) {
|
|
148
|
+
const normalized = normalizePackPath(file.path);
|
|
149
|
+
if (normalized === CONTEXT_PACK_MANIFEST || declared.has(normalized))
|
|
150
|
+
throw new Error(`Duplicate or reserved context pack path: ${normalized}`);
|
|
151
|
+
declared.add(normalized);
|
|
152
|
+
const absolute = resolve(root, normalized);
|
|
153
|
+
if (!contained(root, absolute))
|
|
154
|
+
throw new Error(`Unsafe context pack path: ${normalized}`);
|
|
155
|
+
const info = lstatSync(absolute);
|
|
156
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > CONTEXT_PACK_MAX_FILE_BYTES)
|
|
157
|
+
throw new Error(`Context pack file is unsafe or too large: ${normalized}`);
|
|
158
|
+
const bytes = await readFile(absolute);
|
|
159
|
+
assertUtf8(bytes, normalized);
|
|
160
|
+
if (sha256(bytes) !== file.sha256)
|
|
161
|
+
throw new Error(`Context pack digest mismatch: ${normalized}`);
|
|
162
|
+
totalBytes += bytes.byteLength;
|
|
163
|
+
if (totalBytes > CONTEXT_PACK_MAX_TOTAL_BYTES)
|
|
164
|
+
throw new Error("Context pack exceeds the total size limit.");
|
|
165
|
+
content.set(normalized, bytes);
|
|
166
|
+
}
|
|
167
|
+
const actual = (await walkFiles(root)).filter((path) => path !== CONTEXT_PACK_MANIFEST);
|
|
168
|
+
const undeclared = actual.filter((path) => !declared.has(path));
|
|
169
|
+
const missing = [...declared].filter((path) => !actual.includes(path));
|
|
170
|
+
if (undeclared.length || missing.length)
|
|
171
|
+
throw new Error(`Context pack file declaration mismatch${undeclared.length ? `; undeclared: ${undeclared.join(", ")}` : ""}${missing.length ? `; missing: ${missing.join(", ")}` : ""}`);
|
|
172
|
+
for (const path of [...declared].sort((a, b) => a.localeCompare(b, "en"))) {
|
|
173
|
+
const bytes = content.get(path);
|
|
174
|
+
hash.update(path, "utf8").update("\0").update(bytes).update("\0");
|
|
175
|
+
}
|
|
176
|
+
return { root, manifest, manifest_bytes: manifestBytes, digest: hash.digest("hex"), total_bytes: totalBytes };
|
|
177
|
+
}
|
|
178
|
+
async function atomicJson(path, value) {
|
|
179
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
180
|
+
const directoryInfo = lstatSync(dirname(path));
|
|
181
|
+
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
|
|
182
|
+
throw new Error("Context pack state directory is unsafe.");
|
|
183
|
+
if (existsSync(path)) {
|
|
184
|
+
const existing = lstatSync(path);
|
|
185
|
+
if (!existing.isFile() || existing.isSymbolicLink())
|
|
186
|
+
throw new Error("Context pack state file is unsafe.");
|
|
187
|
+
}
|
|
188
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
189
|
+
let handle;
|
|
190
|
+
try {
|
|
191
|
+
handle = await open(temporary, "wx", 0o600);
|
|
192
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
193
|
+
await handle.sync();
|
|
194
|
+
await handle.close();
|
|
195
|
+
handle = undefined;
|
|
196
|
+
await chmod(temporary, 0o600);
|
|
197
|
+
hardenPrivatePath(temporary);
|
|
198
|
+
await atomicReplace(temporary, path);
|
|
199
|
+
hardenPrivatePath(path);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
if (handle)
|
|
203
|
+
await handle.close().catch(() => undefined);
|
|
204
|
+
await rm(temporary, { force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function makeImmutable(root) {
|
|
208
|
+
const directories = [];
|
|
209
|
+
const walk = async (directory) => {
|
|
210
|
+
directories.push(directory);
|
|
211
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
212
|
+
const path = join(directory, entry.name);
|
|
213
|
+
if (entry.isDirectory())
|
|
214
|
+
await walk(path);
|
|
215
|
+
else if (process.platform === "win32") {
|
|
216
|
+
hardenPrivatePath(path);
|
|
217
|
+
await execFileAsync("attrib.exe", ["+R", path]);
|
|
218
|
+
}
|
|
219
|
+
else
|
|
220
|
+
await chmod(path, 0o400);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
await walk(root);
|
|
224
|
+
for (const directory of directories.reverse()) {
|
|
225
|
+
if (process.platform === "win32")
|
|
226
|
+
hardenPrivatePath(directory);
|
|
227
|
+
else
|
|
228
|
+
await chmod(directory, 0o500);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function makeWritable(root) {
|
|
232
|
+
if (!existsSync(root))
|
|
233
|
+
return;
|
|
234
|
+
await chmod(root, 0o700);
|
|
235
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
236
|
+
const path = join(root, entry.name);
|
|
237
|
+
if (entry.isDirectory())
|
|
238
|
+
await makeWritable(path);
|
|
239
|
+
else if (process.platform === "win32")
|
|
240
|
+
await execFileAsync("attrib.exe", ["-R", path]);
|
|
241
|
+
else
|
|
242
|
+
await chmod(path, 0o600);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function rejectCredentialUrl(source) {
|
|
246
|
+
try {
|
|
247
|
+
const url = new URL(source);
|
|
248
|
+
if (url.username || url.password)
|
|
249
|
+
throw new Error("Git source URLs may not contain credentials.");
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (error instanceof Error && error.message.includes("credentials"))
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
if (/^[^/@:\s]+:[^@\s]+@/.test(source) || /[?&](?:token|password|access_token)=/i.test(source)) {
|
|
256
|
+
throw new Error("Git source URLs may not contain credentials.");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function execFileBuffer(program, args, options) {
|
|
260
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
261
|
+
execFile(program, args, { ...options, encoding: null, windowsHide: true }, (error, stdout) => {
|
|
262
|
+
if (error)
|
|
263
|
+
rejectPromise(error);
|
|
264
|
+
else
|
|
265
|
+
resolvePromise(Buffer.from(stdout));
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
async function materializeGitTree(repository, destination, commit) {
|
|
270
|
+
const listing = await execFileBuffer("git", ["-C", repository, "ls-tree", "-r", "-z", commit], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
|
|
271
|
+
const records = new TextDecoder("utf-8", { fatal: true }).decode(listing).split("\0").filter(Boolean);
|
|
272
|
+
if (records.length > CONTEXT_PACK_MAX_ENTRIES + 1)
|
|
273
|
+
throw new Error("Git context pack exceeds the entry limit.");
|
|
274
|
+
let totalBytes = 0;
|
|
275
|
+
for (const record of records) {
|
|
276
|
+
const match = /^(100644|100755|120000) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(record);
|
|
277
|
+
if (!match || match[1] === "120000")
|
|
278
|
+
throw new Error("Git context packs may contain only regular files.");
|
|
279
|
+
const path = normalizePackPath(match[3]);
|
|
280
|
+
const { stdout: rawSize } = await execFileAsync("git", ["-C", repository, "cat-file", "-s", match[2]], { encoding: "utf8", timeout: 30_000, maxBuffer: 1024 });
|
|
281
|
+
const size = Number(rawSize.trim());
|
|
282
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > CONTEXT_PACK_MAX_FILE_BYTES) {
|
|
283
|
+
throw new Error(`Git context pack file is unsafe or too large: ${path}`);
|
|
284
|
+
}
|
|
285
|
+
totalBytes += size;
|
|
286
|
+
if (totalBytes > CONTEXT_PACK_MAX_TOTAL_BYTES)
|
|
287
|
+
throw new Error("Git context pack exceeds the total size limit.");
|
|
288
|
+
const target = resolve(destination, ...path.split("/"));
|
|
289
|
+
if (!contained(destination, target))
|
|
290
|
+
throw new Error(`Unsafe Git context pack path: ${path}`);
|
|
291
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
292
|
+
const bytes = await execFileBuffer("git", ["-C", repository, "cat-file", "blob", match[2]], { timeout: 30_000, maxBuffer: CONTEXT_PACK_MAX_FILE_BYTES + 1 });
|
|
293
|
+
if (bytes.byteLength !== size)
|
|
294
|
+
throw new Error(`Git context pack blob changed while reading: ${path}`);
|
|
295
|
+
await writeFile(target, bytes, { mode: match[1] === "100755" ? 0o700 : 0o600 });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function materializeSource(source, ref) {
|
|
299
|
+
if (!ref) {
|
|
300
|
+
const requested = resolve(source);
|
|
301
|
+
const info = lstatSync(requested);
|
|
302
|
+
if (info.isSymbolicLink())
|
|
303
|
+
throw new Error("Context pack source may not be a symlink.");
|
|
304
|
+
const root = await realpath(requested);
|
|
305
|
+
return { root, source: { kind: "local", path: root } };
|
|
306
|
+
}
|
|
307
|
+
const gitSource = existsSync(resolve(source)) ? await realpath(resolve(source)) : source;
|
|
308
|
+
rejectCredentialUrl(gitSource);
|
|
309
|
+
const checkout = await mkdtemp(join(tmpdir(), "cairn-pack-git-"));
|
|
310
|
+
try {
|
|
311
|
+
await execFileAsync("git", ["clone", "--quiet", "--no-checkout", "--", gitSource, checkout], { timeout: 120_000, maxBuffer: 1024 * 1024 });
|
|
312
|
+
const { stdout } = await execFileAsync("git", ["-C", checkout, "rev-parse", "--verify", `${ref}^{commit}`], { timeout: 30_000, maxBuffer: 1024 * 1024 });
|
|
313
|
+
const commit = stdout.trim();
|
|
314
|
+
if (!GIT_COMMIT.test(commit))
|
|
315
|
+
throw new Error("Git did not resolve the requested ref to a full commit.");
|
|
316
|
+
const root = join(checkout, "pack");
|
|
317
|
+
await mkdir(root, { mode: 0o700 });
|
|
318
|
+
await materializeGitTree(checkout, root, commit);
|
|
319
|
+
return { root, cleanup: checkout, source: { kind: "git", url: gitSource, ref, commit } };
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
await rm(checkout, { recursive: true, force: true });
|
|
323
|
+
throw new Error(`Unable to materialize Git context pack: ${error instanceof Error ? error.message : String(error)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
export async function installContextPack(sourcePath, options = {}) {
|
|
327
|
+
const materialized = await materializeSource(sourcePath, options.ref);
|
|
328
|
+
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;
|
|
343
|
+
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
|
+
}
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
await makeWritable(temporary).catch(() => undefined);
|
|
366
|
+
await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|
|
367
|
+
}
|
|
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 };
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
if (materialized.cleanup)
|
|
373
|
+
await rm(materialized.cleanup, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
export async function listInstalledContextPacks() {
|
|
377
|
+
const objects = join(packBaseDir(), "objects");
|
|
378
|
+
if (!existsSync(objects))
|
|
379
|
+
return [];
|
|
380
|
+
const objectDirectoryInfo = lstatSync(objects);
|
|
381
|
+
if (!objectDirectoryInfo.isDirectory() || objectDirectoryInfo.isSymbolicLink())
|
|
382
|
+
throw new Error("Context pack object directory is unsafe.");
|
|
383
|
+
const result = [];
|
|
384
|
+
for (const entry of (await readdir(objects, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
|
|
385
|
+
if (!entry.isDirectory() || !DIGEST.test(entry.name))
|
|
386
|
+
continue;
|
|
387
|
+
const pack = await validateContextPack(join(objects, entry.name));
|
|
388
|
+
if (pack.digest !== entry.name)
|
|
389
|
+
throw new Error(`Stored context pack object has the wrong identity: ${entry.name}`);
|
|
390
|
+
const source = readPackSource(entry.name);
|
|
391
|
+
result.push({ id: pack.manifest.id, version: pack.manifest.version, digest: pack.digest, title: pack.manifest.title, ...(source ? { source } : {}) });
|
|
392
|
+
}
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
export async function resolveInstalledPack(selector) {
|
|
396
|
+
const installed = await listInstalledContextPacks();
|
|
397
|
+
const matches = installed.filter((pack) => pack.digest === selector || pack.digest.startsWith(selector) || pack.id === selector || `${pack.id}@${pack.version}` === selector);
|
|
398
|
+
if (matches.length === 0)
|
|
399
|
+
throw new Error("Context pack not found.");
|
|
400
|
+
if (matches.length > 1)
|
|
401
|
+
throw new Error("Context pack selector is ambiguous; use its full digest.");
|
|
402
|
+
return validateContextPack(objectRoot(matches[0].digest));
|
|
403
|
+
}
|
|
404
|
+
function projectIdentity(options) {
|
|
405
|
+
if (options.projectId) {
|
|
406
|
+
if (!PROJECT_ID.test(options.projectId))
|
|
407
|
+
throw new Error("Invalid remote project ID.");
|
|
408
|
+
const id = `remote:${options.projectId}`;
|
|
409
|
+
return { id, path: join(packBaseDir(), "projects", `remote-${options.projectId}.json`) };
|
|
410
|
+
}
|
|
411
|
+
const raw = resolve(options.projectRoot ?? process.cwd());
|
|
412
|
+
const canonical = existsSync(raw) ? realpathSync(raw) : raw;
|
|
413
|
+
const digest = sha256(canonical);
|
|
414
|
+
return { id: `local:${canonical}`, path: join(packBaseDir(), "projects", `local-${digest}.json`) };
|
|
415
|
+
}
|
|
416
|
+
function emptyPointer(id) {
|
|
417
|
+
return { schema_version: 1, project_id: id, enabled: [], skill_approvals: [] };
|
|
418
|
+
}
|
|
419
|
+
function parseProjectPointer(value, expectedId) {
|
|
420
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
421
|
+
throw new Error("Context pack project pointer is invalid.");
|
|
422
|
+
const row = value;
|
|
423
|
+
if (Object.keys(row).some((key) => !["schema_version", "project_id", "enabled", "skill_approvals"].includes(key))
|
|
424
|
+
|| row.schema_version !== 1 || row.project_id !== expectedId || !Array.isArray(row.enabled) || !Array.isArray(row.skill_approvals)) {
|
|
425
|
+
throw new Error("Context pack project pointer is invalid.");
|
|
426
|
+
}
|
|
427
|
+
const enabled = [];
|
|
428
|
+
const seenIds = new Set();
|
|
429
|
+
for (const value of row.enabled) {
|
|
430
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
431
|
+
throw new Error("Context pack project pointer is invalid.");
|
|
432
|
+
const entry = value;
|
|
433
|
+
if (Object.keys(entry).some((key) => !["id", "version", "digest"].includes(key)) || typeof entry.id !== "string"
|
|
434
|
+
|| !PACK_ID.test(entry.id) || typeof entry.version !== "string" || !SEMVER.test(entry.version)
|
|
435
|
+
|| typeof entry.digest !== "string" || !DIGEST.test(entry.digest) || seenIds.has(entry.id)) {
|
|
436
|
+
throw new Error("Context pack project pointer is invalid.");
|
|
437
|
+
}
|
|
438
|
+
seenIds.add(entry.id);
|
|
439
|
+
enabled.push(entry);
|
|
440
|
+
}
|
|
441
|
+
const approvals = [];
|
|
442
|
+
const seenApprovals = new Set();
|
|
443
|
+
for (const value of row.skill_approvals) {
|
|
444
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
445
|
+
throw new Error("Context pack project pointer is invalid.");
|
|
446
|
+
const entry = value;
|
|
447
|
+
if (Object.keys(entry).some((key) => !["pack_digest", "path", "file_digest", "approved_at"].includes(key))
|
|
448
|
+
|| typeof entry.pack_digest !== "string" || !DIGEST.test(entry.pack_digest)
|
|
449
|
+
|| typeof entry.file_digest !== "string" || !DIGEST.test(entry.file_digest)
|
|
450
|
+
|| typeof entry.path !== "string" || typeof entry.approved_at !== "string" || !Number.isFinite(Date.parse(entry.approved_at))) {
|
|
451
|
+
throw new Error("Context pack project pointer is invalid.");
|
|
452
|
+
}
|
|
453
|
+
normalizePackPath(entry.path);
|
|
454
|
+
const key = `${entry.pack_digest}:${entry.path}`;
|
|
455
|
+
if (seenApprovals.has(key))
|
|
456
|
+
throw new Error("Context pack project pointer is invalid.");
|
|
457
|
+
seenApprovals.add(key);
|
|
458
|
+
approvals.push(entry);
|
|
459
|
+
}
|
|
460
|
+
return { schema_version: 1, project_id: expectedId, enabled, skill_approvals: approvals };
|
|
461
|
+
}
|
|
462
|
+
export function readProjectPointer(options) {
|
|
463
|
+
const identity = projectIdentity(options);
|
|
464
|
+
if (!existsSync(identity.path))
|
|
465
|
+
return emptyPointer(identity.id);
|
|
466
|
+
const info = lstatSync(identity.path);
|
|
467
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024
|
|
468
|
+
|| !privatePathIsSafe(identity.path))
|
|
469
|
+
throw new Error("Context pack project pointer is unsafe.");
|
|
470
|
+
return parseProjectPointer(JSON.parse(readFileSync(identity.path, "utf8")), identity.id);
|
|
471
|
+
}
|
|
472
|
+
function delay(milliseconds) {
|
|
473
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
|
|
474
|
+
}
|
|
475
|
+
async function acquirePointerLock(options) {
|
|
476
|
+
const identity = projectIdentity(options);
|
|
477
|
+
await mkdir(dirname(identity.path), { recursive: true, mode: 0o700 });
|
|
478
|
+
const lock = `${identity.path}.lock`;
|
|
479
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
480
|
+
try {
|
|
481
|
+
await mkdir(lock, { mode: 0o700 });
|
|
482
|
+
return () => rm(lock, { recursive: true, force: true });
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
if (error.code !== "EEXIST")
|
|
486
|
+
throw error;
|
|
487
|
+
const info = lstatSync(lock);
|
|
488
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
489
|
+
throw new Error("Context pack project pointer lock is unsafe.");
|
|
490
|
+
await delay(10);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
throw new Error("Context pack project pointer is locked; retry after the active update finishes.");
|
|
494
|
+
}
|
|
495
|
+
async function mutateProjectPointer(options, mutate) {
|
|
496
|
+
const release = await acquirePointerLock(options);
|
|
497
|
+
try {
|
|
498
|
+
const pointer = readProjectPointer(options);
|
|
499
|
+
await mutate(pointer);
|
|
500
|
+
await atomicJson(projectIdentity(options).path, pointer);
|
|
501
|
+
return pointer;
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
await release();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
export async function enableContextPack(selector, options) {
|
|
508
|
+
const pack = await resolveInstalledPack(selector);
|
|
509
|
+
return mutateProjectPointer(options, (pointer) => {
|
|
510
|
+
const previous = pointer.enabled.find((entry) => entry.id === pack.manifest.id);
|
|
511
|
+
pointer.enabled = pointer.enabled.filter((entry) => entry.id !== pack.manifest.id);
|
|
512
|
+
pointer.enabled.push({ id: pack.manifest.id, version: pack.manifest.version, digest: pack.digest });
|
|
513
|
+
pointer.enabled.sort((a, b) => a.id.localeCompare(b.id, "en"));
|
|
514
|
+
if (previous?.digest !== pack.digest)
|
|
515
|
+
pointer.skill_approvals = pointer.skill_approvals.filter((approval) => approval.pack_digest !== previous?.digest);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
export async function disableContextPack(selector, options) {
|
|
519
|
+
return mutateProjectPointer(options, (pointer) => {
|
|
520
|
+
const removed = pointer.enabled.filter((entry) => entry.id === selector || entry.digest === selector || entry.digest.startsWith(selector));
|
|
521
|
+
if (removed.length === 0)
|
|
522
|
+
throw new Error("Enabled context pack not found.");
|
|
523
|
+
const digests = new Set(removed.map(({ digest }) => digest));
|
|
524
|
+
pointer.enabled = pointer.enabled.filter((entry) => !digests.has(entry.digest));
|
|
525
|
+
pointer.skill_approvals = pointer.skill_approvals.filter((entry) => !digests.has(entry.pack_digest));
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
async function allPointers() {
|
|
529
|
+
const directory = join(packBaseDir(), "projects");
|
|
530
|
+
if (!existsSync(directory))
|
|
531
|
+
return [];
|
|
532
|
+
const directoryInfo = lstatSync(directory);
|
|
533
|
+
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
|
|
534
|
+
throw new Error("Context pack project pointer directory is unsafe.");
|
|
535
|
+
const result = [];
|
|
536
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
537
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
538
|
+
continue;
|
|
539
|
+
const path = join(directory, entry.name);
|
|
540
|
+
const info = lstatSync(path);
|
|
541
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024
|
|
542
|
+
|| !privatePathIsSafe(path)) {
|
|
543
|
+
throw new Error(`Context pack project pointer is unsafe: ${path}`);
|
|
544
|
+
}
|
|
545
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
546
|
+
const value = raw;
|
|
547
|
+
if (typeof value.project_id !== "string")
|
|
548
|
+
throw new Error("Context pack project pointer is invalid.");
|
|
549
|
+
const pointer = parseProjectPointer(raw, value.project_id);
|
|
550
|
+
result.push({ path, pointer });
|
|
551
|
+
}
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
export async function removeContextPack(selector) {
|
|
555
|
+
const pack = await resolveInstalledPack(selector);
|
|
556
|
+
for (const { pointer } of await allPointers()) {
|
|
557
|
+
if (pointer.enabled.some(({ digest }) => digest === pack.digest))
|
|
558
|
+
throw new Error("Context pack is enabled by a project and cannot be removed.");
|
|
559
|
+
}
|
|
560
|
+
const root = objectRoot(pack.digest);
|
|
561
|
+
await makeWritable(root);
|
|
562
|
+
await rm(root, { recursive: true, force: true });
|
|
563
|
+
await rm(join(packBaseDir(), "sources", `${pack.digest}.json`), { force: true });
|
|
564
|
+
return pack.digest;
|
|
565
|
+
}
|
|
566
|
+
export async function listPackSkills(options) {
|
|
567
|
+
const pointer = readProjectPointer(options);
|
|
568
|
+
const approvals = new Set(pointer.skill_approvals.map((row) => `${row.pack_digest}:${row.path}:${row.file_digest}`));
|
|
569
|
+
const result = [];
|
|
570
|
+
for (const enabled of pointer.enabled) {
|
|
571
|
+
const pack = await validateContextPack(objectRoot(enabled.digest));
|
|
572
|
+
for (const file of pack.manifest.files.filter(({ kind }) => kind === "skill")) {
|
|
573
|
+
result.push({ pack_id: pack.manifest.id, version: pack.manifest.version, pack_digest: pack.digest, path: file.path, file_digest: file.sha256, title: file.title, approved: approvals.has(`${pack.digest}:${file.path}:${file.sha256}`) });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
export async function approvePackSkill(selector, path, confirm, options) {
|
|
579
|
+
return mutateProjectPointer(options, async (pointer) => {
|
|
580
|
+
const enabled = pointer.enabled.find((entry) => entry.id === selector || entry.digest === selector || entry.digest.startsWith(selector));
|
|
581
|
+
if (!enabled)
|
|
582
|
+
throw new Error("Context pack is not enabled for this project.");
|
|
583
|
+
const pack = await validateContextPack(objectRoot(enabled.digest));
|
|
584
|
+
const file = pack.manifest.files.find((entry) => entry.path === normalizePackPath(path) && entry.kind === "skill");
|
|
585
|
+
if (!file)
|
|
586
|
+
throw new Error("Skill file not found in the enabled context pack.");
|
|
587
|
+
if (confirm !== file.sha256)
|
|
588
|
+
throw new Error("Skill approval confirmation does not match the file digest.");
|
|
589
|
+
pointer.skill_approvals = pointer.skill_approvals.filter((row) => !(row.pack_digest === pack.digest && row.path === file.path));
|
|
590
|
+
pointer.skill_approvals.push({ pack_digest: pack.digest, path: file.path, file_digest: file.sha256, approved_at: new Date().toISOString() });
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
export async function revokePackSkill(selector, path, options) {
|
|
594
|
+
return mutateProjectPointer(options, (pointer) => {
|
|
595
|
+
const enabled = pointer.enabled.find((entry) => entry.id === selector || entry.digest === selector || entry.digest.startsWith(selector));
|
|
596
|
+
if (!enabled)
|
|
597
|
+
throw new Error("Context pack is not enabled for this project.");
|
|
598
|
+
const before = pointer.skill_approvals.length;
|
|
599
|
+
pointer.skill_approvals = pointer.skill_approvals.filter((row) => !(row.pack_digest === enabled.digest && row.path === normalizePackPath(path)));
|
|
600
|
+
if (before === pointer.skill_approvals.length)
|
|
601
|
+
throw new Error("Skill approval not found.");
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
export async function visiblePackFiles(options) {
|
|
605
|
+
const pointer = readProjectPointer(options);
|
|
606
|
+
const approvals = new Set(pointer.skill_approvals.map((row) => `${row.pack_digest}:${row.path}:${row.file_digest}`));
|
|
607
|
+
const result = [];
|
|
608
|
+
for (const enabled of pointer.enabled) {
|
|
609
|
+
const pack = await validateContextPack(objectRoot(enabled.digest));
|
|
610
|
+
for (const file of pack.manifest.files) {
|
|
611
|
+
if (file.kind === "skill" && !approvals.has(`${pack.digest}:${file.path}:${file.sha256}`))
|
|
612
|
+
continue;
|
|
613
|
+
result.push({ pack, file });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
function provenance(pack, file) {
|
|
619
|
+
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
|
+
}
|
|
621
|
+
function utf8Slice(bytes, start, maximum) {
|
|
622
|
+
const requestedStart = Math.min(start, bytes.length);
|
|
623
|
+
let safeStart = requestedStart;
|
|
624
|
+
while (safeStart < bytes.length && (bytes[safeStart] & 0xc0) === 0x80)
|
|
625
|
+
safeStart += 1;
|
|
626
|
+
let end = Math.min(bytes.length, safeStart + maximum);
|
|
627
|
+
while (end > safeStart) {
|
|
628
|
+
try {
|
|
629
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(safeStart, end));
|
|
630
|
+
return { text, bytes: end - requestedStart };
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
end -= 1;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return { text: "", bytes: Math.max(0, safeStart - requestedStart) };
|
|
637
|
+
}
|
|
638
|
+
function chunks(text) {
|
|
639
|
+
const boundaries = new Set([0, text.length]);
|
|
640
|
+
for (const match of text.matchAll(/^#{1,6}\s|\n{2,}(?=\S)/gm)) {
|
|
641
|
+
const boundary = match[0].startsWith("\n") ? match.index + match[0].length : match.index;
|
|
642
|
+
boundaries.add(boundary);
|
|
643
|
+
}
|
|
644
|
+
const positions = [...boundaries].sort((a, b) => a - b);
|
|
645
|
+
const blocks = positions.slice(0, -1).map((start, index) => text.slice(start, positions[index + 1])).filter(Boolean);
|
|
646
|
+
const result = [];
|
|
647
|
+
let current = "";
|
|
648
|
+
let offset = 0;
|
|
649
|
+
const push = () => {
|
|
650
|
+
if (!current)
|
|
651
|
+
return;
|
|
652
|
+
const bytes = Buffer.from(current, "utf8");
|
|
653
|
+
let localOffset = 0;
|
|
654
|
+
while (localOffset < bytes.length) {
|
|
655
|
+
while (localOffset < bytes.length && (bytes[localOffset] & 0xc0) === 0x80)
|
|
656
|
+
localOffset += 1;
|
|
657
|
+
const part = utf8Slice(bytes, localOffset, 8192);
|
|
658
|
+
if (!part.bytes)
|
|
659
|
+
break;
|
|
660
|
+
result.push({ offset: offset + localOffset, text: part.text });
|
|
661
|
+
if (localOffset + part.bytes >= bytes.length)
|
|
662
|
+
break;
|
|
663
|
+
localOffset += Math.max(1, part.bytes - 512);
|
|
664
|
+
}
|
|
665
|
+
offset += bytes.length;
|
|
666
|
+
current = "";
|
|
667
|
+
};
|
|
668
|
+
for (const block of blocks) {
|
|
669
|
+
if (Buffer.byteLength(current + block, "utf8") > 8192)
|
|
670
|
+
push();
|
|
671
|
+
current += block;
|
|
672
|
+
}
|
|
673
|
+
push();
|
|
674
|
+
return result;
|
|
675
|
+
}
|
|
676
|
+
export async function listVisibleContext(options) {
|
|
677
|
+
const files = await visiblePackFiles(options);
|
|
678
|
+
const grouped = new Map();
|
|
679
|
+
for (const { pack, file } of files) {
|
|
680
|
+
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 });
|
|
682
|
+
grouped.set(pack.digest, row);
|
|
683
|
+
}
|
|
684
|
+
return { schema_version: 1, packs: [...grouped.values()] };
|
|
685
|
+
}
|
|
686
|
+
export async function searchVisibleContext(query, options) {
|
|
687
|
+
const normalized = query.trim().toLocaleLowerCase("en");
|
|
688
|
+
if (!normalized)
|
|
689
|
+
throw new Error("Context pack search query must not be empty.");
|
|
690
|
+
const candidates = [];
|
|
691
|
+
for (const { pack, file } of await visiblePackFiles(options)) {
|
|
692
|
+
const text = await readFile(join(pack.root, file.path), "utf8");
|
|
693
|
+
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}` });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const config = getEmbeddingConfig();
|
|
698
|
+
if (config && candidates.length) {
|
|
699
|
+
try {
|
|
700
|
+
const cache = new EmbeddingCache(join(packBaseDir(), "cache", "embeddings.json"), config.model);
|
|
701
|
+
const vectors = candidates.map((candidate) => {
|
|
702
|
+
const key = `${candidate.pack.digest}:${candidate.file.sha256}:${sha256(candidate.text)}`;
|
|
703
|
+
return cache.get(key, hashText(candidate.searchable));
|
|
704
|
+
});
|
|
705
|
+
const missing = candidates.map((candidate, index) => ({ candidate, index })).filter(({ index }) => !vectors[index]);
|
|
706
|
+
const embedded = await embedTexts(config, [query, ...missing.map(({ candidate }) => candidate.searchable)]);
|
|
707
|
+
if (embedded.length !== missing.length + 1)
|
|
708
|
+
throw new Error("Embedding endpoint returned an incomplete result.");
|
|
709
|
+
missing.forEach(({ candidate, index }, missingIndex) => {
|
|
710
|
+
const vector = embedded[missingIndex + 1];
|
|
711
|
+
vectors[index] = vector;
|
|
712
|
+
cache.set(`${candidate.pack.digest}:${candidate.file.sha256}:${sha256(candidate.text)}`, hashText(candidate.searchable), vector);
|
|
713
|
+
});
|
|
714
|
+
cache.save();
|
|
715
|
+
const results = candidates.map((candidate, index) => ({
|
|
716
|
+
...provenance(candidate.pack, candidate.file), offset: candidate.offset, text: candidate.text,
|
|
717
|
+
score: cosineSimilarity(embedded[0], vectors[index] ?? []),
|
|
718
|
+
})).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
|
+
return { schema_version: 1, query, search_mode: "embedding", results: results.slice(0, options.limit ?? 20) };
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// Retrieval remains available when an optional embedding service fails.
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const results = candidates.flatMap((candidate) => {
|
|
726
|
+
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 }] : [];
|
|
728
|
+
});
|
|
729
|
+
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
|
+
return { schema_version: 1, query, search_mode: "substring", results: results.slice(0, options.limit ?? 20) };
|
|
731
|
+
}
|
|
732
|
+
export async function readVisibleContext(packSelector, path, options) {
|
|
733
|
+
const normalized = normalizePackPath(path);
|
|
734
|
+
const visible = await visiblePackFiles(options);
|
|
735
|
+
const matches = visible.filter(({ pack, file }) => (pack.digest === packSelector || pack.digest.startsWith(packSelector) || pack.manifest.id === packSelector) && file.path === normalized);
|
|
736
|
+
if (matches.length !== 1)
|
|
737
|
+
throw new Error(matches.length ? "Context pack selector is ambiguous." : "Context pack file is not enabled or approved.");
|
|
738
|
+
const { pack, file } = matches[0];
|
|
739
|
+
const bytes = await readFile(join(pack.root, file.path));
|
|
740
|
+
const offset = options.offset ?? 0;
|
|
741
|
+
const maxBytes = options.maxBytes ?? 8192;
|
|
742
|
+
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 8192)
|
|
743
|
+
throw new Error("Invalid context pack read range.");
|
|
744
|
+
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 };
|
|
746
|
+
}
|
|
747
|
+
export async function doctorContextPacks() {
|
|
748
|
+
const issues = [];
|
|
749
|
+
const remnants = [];
|
|
750
|
+
let objects = 0;
|
|
751
|
+
try {
|
|
752
|
+
objects = (await listInstalledContextPacks()).length;
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
756
|
+
}
|
|
757
|
+
const findRemnants = async (directory) => {
|
|
758
|
+
if (!existsSync(directory))
|
|
759
|
+
return;
|
|
760
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
761
|
+
const path = join(directory, entry.name);
|
|
762
|
+
if ((entry.name.startsWith(".") && entry.name.endsWith(".tmp")) || entry.name.endsWith(".lock"))
|
|
763
|
+
remnants.push(path);
|
|
764
|
+
else if (entry.isDirectory())
|
|
765
|
+
await findRemnants(path);
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
await findRemnants(packBaseDir());
|
|
769
|
+
const sourceDirectory = join(packBaseDir(), "sources");
|
|
770
|
+
if (existsSync(sourceDirectory)) {
|
|
771
|
+
const directoryInfo = lstatSync(sourceDirectory);
|
|
772
|
+
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) {
|
|
773
|
+
issues.push("Context pack source directory is unsafe.");
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
for (const entry of await readdir(sourceDirectory, { withFileTypes: true })) {
|
|
777
|
+
const match = /^([a-f0-9]{64})\.json$/.exec(entry.name);
|
|
778
|
+
if (!entry.isFile() || entry.isSymbolicLink() || !match) {
|
|
779
|
+
issues.push(`Invalid context pack source index entry: ${entry.name}`);
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (!existsSync(objectRoot(match[1])))
|
|
783
|
+
issues.push(`Orphaned context pack source record: ${entry.name}`);
|
|
784
|
+
try {
|
|
785
|
+
readPackSource(match[1]);
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
const pointers = await allPointers().catch((error) => { issues.push(error instanceof Error ? error.message : String(error)); return []; });
|
|
794
|
+
for (const { path, pointer } of pointers) {
|
|
795
|
+
const packs = new Map();
|
|
796
|
+
for (const enabled of pointer.enabled) {
|
|
797
|
+
if (!existsSync(objectRoot(enabled.digest))) {
|
|
798
|
+
issues.push(`Stale context pack pointer: ${path} -> ${enabled.digest}`);
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
try {
|
|
802
|
+
const pack = await validateContextPack(objectRoot(enabled.digest));
|
|
803
|
+
packs.set(enabled.digest, pack);
|
|
804
|
+
if (pack.digest !== enabled.digest || pack.manifest.id !== enabled.id || pack.manifest.version !== enabled.version) {
|
|
805
|
+
issues.push(`Mismatched context pack pointer: ${path} -> ${enabled.digest}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch (error) {
|
|
809
|
+
issues.push(`Invalid context pack pointer target: ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
for (const approval of pointer.skill_approvals) {
|
|
813
|
+
const pack = packs.get(approval.pack_digest);
|
|
814
|
+
const file = pack?.manifest.files.find((entry) => entry.path === approval.path && entry.kind === "skill");
|
|
815
|
+
if (!file || file.sha256 !== approval.file_digest)
|
|
816
|
+
issues.push(`Stale context pack skill approval: ${path} -> ${approval.path}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return { ok: issues.length === 0 && remnants.length === 0, objects, projects: pointers.length, issues, temporary_remnants: remnants };
|
|
820
|
+
}
|
|
821
|
+
export async function inspectContextPackUpdate(selector, options) {
|
|
822
|
+
const pointer = readProjectPointer(options);
|
|
823
|
+
const enabled = pointer.enabled.find((entry) => entry.id === selector || entry.digest === selector || entry.digest.startsWith(selector));
|
|
824
|
+
if (!enabled)
|
|
825
|
+
throw new Error("Context pack is not enabled for this project.");
|
|
826
|
+
const sourceRecord = JSON.parse(await readFile(join(packBaseDir(), "sources", `${enabled.digest}.json`), "utf8"));
|
|
827
|
+
const source = sourceRecord.source;
|
|
828
|
+
const materialized = await materializeSource(source.kind === "local" ? source.path : source.url, source.kind === "git" ? source.ref : undefined);
|
|
829
|
+
try {
|
|
830
|
+
const candidate = await validateContextPack(materialized.root);
|
|
831
|
+
return { current_digest: enabled.digest, candidate_digest: candidate.digest, changed: candidate.digest !== enabled.digest, source: materialized.source };
|
|
832
|
+
}
|
|
833
|
+
finally {
|
|
834
|
+
if (materialized.cleanup)
|
|
835
|
+
await rm(materialized.cleanup, { recursive: true, force: true });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
export async function applyContextPackUpdate(selector, confirm, options) {
|
|
839
|
+
const check = await inspectContextPackUpdate(selector, options);
|
|
840
|
+
if (confirm !== check.candidate_digest)
|
|
841
|
+
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 } : {});
|
|
843
|
+
if (installed.pack.digest !== confirm)
|
|
844
|
+
throw new Error("Context pack changed after update inspection.");
|
|
845
|
+
const pointer = await enableContextPack(installed.pack.digest, options);
|
|
846
|
+
return { ...check, applied: true, pointer };
|
|
847
|
+
}
|
|
848
|
+
export async function initializeContextPack(directory, options) {
|
|
849
|
+
const root = resolve(directory);
|
|
850
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
851
|
+
const manifestPath = join(root, CONTEXT_PACK_MANIFEST);
|
|
852
|
+
if (existsSync(manifestPath))
|
|
853
|
+
throw new Error("Context pack manifest already exists.");
|
|
854
|
+
const id = options.id ?? basename(root).toLocaleLowerCase("en").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
855
|
+
const files = (await walkFiles(root)).filter((path) => path !== CONTEXT_PACK_MANIFEST);
|
|
856
|
+
const entries = [];
|
|
857
|
+
for (const path of files) {
|
|
858
|
+
const bytes = await readFile(join(root, path));
|
|
859
|
+
assertUtf8(bytes, path);
|
|
860
|
+
entries.push({ path, kind: "document", title: basename(path).replace(/\.[^.]+$/, ""), description: "", keywords: [], sha256: sha256(bytes) });
|
|
861
|
+
}
|
|
862
|
+
const manifest = contextPackManifestSchema.parse({ schema_version: 1, id, version: options.version ?? "0.1.0", title: options.title ?? id, description: options.description ?? `Context pack ${id}`, license: options.license ?? "UNLICENSED", files: entries });
|
|
863
|
+
await atomicJson(manifestPath, manifest);
|
|
864
|
+
return manifest;
|
|
865
|
+
}
|
|
866
|
+
export async function lockContextPack(directory) {
|
|
867
|
+
const path = join(resolve(directory), CONTEXT_PACK_MANIFEST);
|
|
868
|
+
const value = contextPackManifestSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
|
869
|
+
const files = [];
|
|
870
|
+
for (const entry of value.files) {
|
|
871
|
+
const normalized = normalizePackPath(entry.path);
|
|
872
|
+
const bytes = await readFile(join(resolve(directory), normalized));
|
|
873
|
+
assertUtf8(bytes, normalized);
|
|
874
|
+
if (bytes.byteLength > CONTEXT_PACK_MAX_FILE_BYTES)
|
|
875
|
+
throw new Error(`Context pack file is too large: ${normalized}`);
|
|
876
|
+
files.push({ ...entry, sha256: sha256(bytes) });
|
|
877
|
+
}
|
|
878
|
+
const locked = { ...value, files };
|
|
879
|
+
await atomicJson(path, locked);
|
|
880
|
+
await validateContextPack(directory);
|
|
881
|
+
return locked;
|
|
882
|
+
}
|
|
883
|
+
export function contextPacksEnabled(value = process.env.CAIRN_CONTEXT_PACKS) {
|
|
884
|
+
return /^(?:1|true|yes|on)$/i.test(value?.trim() ?? "");
|
|
885
|
+
}
|
|
886
|
+
export function contextPackHttpEnabled(value = process.env.CAIRN_CONTEXT_PACK_HTTP) {
|
|
887
|
+
return /^(?:1|true|yes|on)$/i.test(value?.trim() ?? "");
|
|
888
|
+
}
|
|
889
|
+
export function contextPackBaseDirectory() { return packBaseDir(); }
|