knodin 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/bin/cli.js +272 -24
  2. package/dist/src/cli-model.js +26 -0
  3. package/dist/src/engine/candidate-database.js +167 -0
  4. package/dist/src/engine/embeddings.js +8 -5
  5. package/dist/src/engine/index.js +1244 -305
  6. package/dist/src/engine/prune.js +8 -0
  7. package/dist/src/file-metrics.js +153 -0
  8. package/dist/src/repair-lease.js +8 -5
  9. package/dist/src/response-budget.js +2 -1
  10. package/dist/src/shared-index/artifact.js +65 -0
  11. package/dist/src/shared-index/cache.js +267 -0
  12. package/dist/src/shared-index/compatibility.js +70 -0
  13. package/dist/src/shared-index/config.js +224 -0
  14. package/dist/src/shared-index/contract.js +117 -0
  15. package/dist/src/shared-index/manifest.js +71 -0
  16. package/dist/src/shared-index/opportunistic.js +37 -0
  17. package/dist/src/shared-index/overlay.js +98 -0
  18. package/dist/src/shared-index/provenance.js +149 -0
  19. package/dist/src/shared-index/publisher.js +198 -0
  20. package/dist/src/shared-index/restore.js +209 -0
  21. package/dist/src/shared-index/s3-client.js +124 -0
  22. package/dist/src/shared-index/schemas.js +86 -0
  23. package/dist/src/shared-index/selection.js +111 -0
  24. package/dist/src/tools/knodin-tools.js +70 -3
  25. package/docs/CLI.md +39 -0
  26. package/docs/MCP.md +12 -0
  27. package/docs/SHARED-INDEX-CONTRACT.md +132 -0
  28. package/docs/releases/0.8.5.md +25 -0
  29. package/docs/releases/0.8.6.md +10 -0
  30. package/package.json +12 -3
  31. package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
  32. package/schemas/shared-index-config-v1.schema.json +97 -0
  33. package/schemas/shared-index-manifest-v1.schema.json +104 -0
  34. package/schemas/shared-index-provenance-v1.schema.json +100 -0
@@ -43,6 +43,14 @@ export const PRUNE_DIR_NAMES = new Set([
43
43
  "vendor",
44
44
  "__pycache__",
45
45
  ]);
46
+ /** Frozen input to shared-index compatibility; update with any pruning behavior change. */
47
+ export function prunePolicyDescriptor() {
48
+ return {
49
+ version: 1,
50
+ directories: [...PRUNE_DIR_NAMES].sort((left, right) => left.localeCompare(right)),
51
+ patterns: [".claude/worktrees"],
52
+ };
53
+ }
46
54
  /** True when a single path segment (one directory name) is a pruned directory. */
47
55
  export function isPrunedSegment(segment) {
48
56
  return PRUNE_DIR_NAMES.has(segment);
@@ -0,0 +1,153 @@
1
+ import { execFileSync } from "node:child_process";
2
+ const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?|py|java|cls|trigger|go|rs|rb|php|cs)$/i;
3
+ const TEST_PATH = /(?:(?:^|\/)(?:__tests__|test|tests|spec|specs)(?:\/|$)|[._](?:test|spec)\.[^/]+$)/i;
4
+ function git(repo, args, input) {
5
+ try {
6
+ return execFileSync("git", ["-C", repo, ...args], {
7
+ encoding: "buffer",
8
+ input,
9
+ maxBuffer: 512 * 1024 * 1024,
10
+ stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
11
+ });
12
+ }
13
+ catch (error) {
14
+ const stderr = error.stderr;
15
+ const message = Buffer.isBuffer(stderr) ? stderr.toString("utf8").trim() : String(stderr ?? "");
16
+ throw new Error(`knodin query file_metrics: git ${args[0]} failed${message ? `: ${message}` : ""}`);
17
+ }
18
+ }
19
+ function resolveCommit(repo, ref) {
20
+ try {
21
+ return git(repo, ["rev-parse", "--verify", `${ref}^{commit}`])
22
+ .toString("utf8")
23
+ .trim();
24
+ }
25
+ catch {
26
+ throw new Error(`knodin query file_metrics: invalid or unreachable Git ref ${ref}`);
27
+ }
28
+ }
29
+ function tree(repo, commit) {
30
+ const listing = git(repo, ["ls-tree", "-r", "-z", commit]);
31
+ const entries = listing
32
+ .toString("utf8")
33
+ .split("\0")
34
+ .filter(Boolean)
35
+ .flatMap((entry) => {
36
+ const match = /^(\d+) blob ([a-f0-9]+)\t([\s\S]+)$/.exec(entry);
37
+ if (!match || !SOURCE_EXTENSION.test(match[3]))
38
+ return [];
39
+ return [{ path: match[3], hash: match[2] }];
40
+ });
41
+ const hashes = [...new Set(entries.map((entry) => entry.hash))];
42
+ const batch = hashes.length
43
+ ? git(repo, ["cat-file", "--batch"], Buffer.from(`${hashes.join("\n")}\n`))
44
+ : Buffer.alloc(0);
45
+ const contents = new Map();
46
+ let offset = 0;
47
+ for (const expected of hashes) {
48
+ const newline = batch.indexOf(0x0a, offset);
49
+ if (newline < 0)
50
+ throw new Error("knodin query file_metrics: truncated git object batch");
51
+ const header = batch.subarray(offset, newline).toString("utf8");
52
+ const match = /^([a-f0-9]+) blob (\d+)$/.exec(header);
53
+ if (!match || match[1] !== expected)
54
+ throw new Error("knodin query file_metrics: unexpected git object batch response");
55
+ const size = Number(match[2]);
56
+ const start = newline + 1;
57
+ const end = start + size;
58
+ if (end >= batch.length || batch[end] !== 0x0a)
59
+ throw new Error("knodin query file_metrics: malformed git object batch response");
60
+ contents.set(expected, batch.subarray(start, end));
61
+ offset = end + 1;
62
+ }
63
+ return new Map(entries.map((entry) => {
64
+ const content = contents.get(entry.hash);
65
+ if (!content)
66
+ throw new Error("knodin query file_metrics: missing Git object content");
67
+ return [entry.path, { hash: entry.hash, content }];
68
+ }));
69
+ }
70
+ function text(buffer) {
71
+ if (buffer.includes(0))
72
+ return null;
73
+ const decoded = buffer.toString("utf8");
74
+ return Buffer.from(decoded, "utf8").equals(buffer) ? decoded : null;
75
+ }
76
+ export function measureFile(content) {
77
+ const lines = content.length === 0
78
+ ? 0
79
+ : content.split(/\r\n|\n|\r/).length - (/[\r\n]$/.test(content) ? 1 : 0);
80
+ const imports = (content.match(/^\s*(?:import\b|export\s+[^\n;]*\sfrom\b|(?:const|let|var)\s+[^=\n]+?=\s*require\s*\(|from\s+\S+\s+import\b|import\s+\S+)/gm) ?? []).length;
81
+ const routes = (content.match(/\b(?:app|router)\s*\.\s*(?:get|post|put|patch|delete|options|head|use)\s*\(|@(?:Get|Post|Put|Patch|Delete|RequestMapping|GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\b|@app\.(?:get|post|put|patch|delete|route)\s*\(/g) ?? []).length;
82
+ const effects = (content.match(/\b(?:fetch|axios\.(?:get|post|put|patch|delete)|console\.(?:log|warn|error)|(?:read|write|append|unlink|mkdir|rm)FileSync|(?:read|write|append|unlink|mkdir|rm)File|process\.env|\.query|\.execute|\.save|\.send|\.publish)\s*[[(]/g) ?? []).length;
83
+ return { lines, imports, routes, effects };
84
+ }
85
+ function subtract(head, base) {
86
+ return {
87
+ lines: head.lines - base.lines,
88
+ imports: head.imports - base.imports,
89
+ routes: head.routes - base.routes,
90
+ effects: head.effects - base.effects,
91
+ };
92
+ }
93
+ export function collectFileMetrics(repo, options = {}) {
94
+ const head = resolveCommit(repo, "HEAD");
95
+ let base;
96
+ if (options.base) {
97
+ resolveCommit(repo, options.base);
98
+ const mergeBase = git(repo, ["merge-base", "HEAD", options.base]).toString("utf8").trim();
99
+ if (!mergeBase)
100
+ throw new Error(`knodin query file_metrics: cannot resolve merge-base for ${options.base}`);
101
+ base = { requested: options.base, mergeBase };
102
+ }
103
+ const testScope = options.testScope ?? "all";
104
+ const headTree = tree(repo, head);
105
+ const baseTree = base ? tree(repo, base.mergeBase) : undefined;
106
+ const paths = [...new Set([...headTree.keys(), ...(baseTree?.keys() ?? [])])]
107
+ .filter((file) => testScope === "all" || (testScope === "test") === TEST_PATH.test(file))
108
+ .sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
109
+ const files = [];
110
+ for (const file of paths) {
111
+ const headEntry = headTree.get(file);
112
+ const baseEntry = baseTree?.get(file);
113
+ const headText = headEntry ? text(headEntry.content) : null;
114
+ const baseText = baseEntry ? text(baseEntry.content) : null;
115
+ // Binary or invalid UTF-8 inputs are explicitly skipped rather than reported as zero.
116
+ if ((headEntry && headText === null) || (baseEntry && baseText === null))
117
+ continue;
118
+ const headMetrics = headText === null ? null : measureFile(headText);
119
+ const baseMetrics = baseText === null ? null : measureFile(baseText);
120
+ const status = !headEntry
121
+ ? "removed"
122
+ : !baseTree
123
+ ? "unchanged"
124
+ : !baseEntry
125
+ ? "added"
126
+ : headEntry.hash === baseEntry.hash
127
+ ? "unchanged"
128
+ : "changed";
129
+ files.push({
130
+ file,
131
+ status,
132
+ head: headMetrics,
133
+ ...(baseTree
134
+ ? {
135
+ base: baseMetrics,
136
+ delta: status === "changed" && headMetrics && baseMetrics
137
+ ? subtract(headMetrics, baseMetrics)
138
+ : null,
139
+ }
140
+ : {}),
141
+ });
142
+ }
143
+ return {
144
+ schemaVersion: 1,
145
+ status: "ok",
146
+ head,
147
+ ...(base ? { base } : {}),
148
+ testScope,
149
+ files,
150
+ confidentiality: "paths-and-integer-counts-only",
151
+ policy: "measurements-only",
152
+ };
153
+ }
@@ -41,10 +41,13 @@ function writeLease(target, record) {
41
41
  mode: 0o600,
42
42
  });
43
43
  }
44
- function removeStaleLeaseOrThrow(target) {
44
+ function removeStaleLeaseOrThrow(target, operation) {
45
45
  const current = readLease(target);
46
46
  if (current && processIsAlive(current.pid)) {
47
- throw new Error(`knodin repair: another repair is already in progress (pid ${current.pid}, started ${current.startedAt}); wait for it to finish, then run \`knodin status\``);
47
+ const contention = operation === "repair"
48
+ ? "another repair is already in progress"
49
+ : "another lifecycle mutation is already in progress";
50
+ throw new Error(`knodin ${operation}: ${contention} (pid ${current.pid}, started ${current.startedAt}); wait for it to finish, then run \`knodin status\``);
48
51
  }
49
52
  try {
50
53
  fs.unlinkSync(target);
@@ -55,7 +58,7 @@ function removeStaleLeaseOrThrow(target) {
55
58
  }
56
59
  }
57
60
  /** Atomically serialize repair post-processing across independent CLI/MCP processes. */
58
- export function acquireRepairLease(repo) {
61
+ export function acquireRepairLease(repo, operation = "repair") {
59
62
  const target = leasePath(repo);
60
63
  fs.mkdirSync(path.dirname(target), { recursive: true });
61
64
  const record = {
@@ -78,8 +81,8 @@ export function acquireRepairLease(repo) {
78
81
  catch (error) {
79
82
  if (error.code !== "EEXIST")
80
83
  throw error;
81
- removeStaleLeaseOrThrow(target);
84
+ removeStaleLeaseOrThrow(target, operation);
82
85
  }
83
86
  }
84
- throw new Error("knodin repair: could not acquire the repository repair lease; retry shortly");
87
+ throw new Error(`knodin ${operation}: could not acquire the repository lifecycle lease; retry shortly`);
85
88
  }
@@ -46,7 +46,8 @@ const PROTECTED_CONTRACT_KEYS = new Set([
46
46
  "to",
47
47
  ]);
48
48
  function isTruncatableDetail(entry) {
49
- return typeof entry.key !== "string" || !PROTECTED_CONTRACT_KEYS.has(entry.key);
49
+ return (!entry.path.startsWith("$/sharedIndex/") &&
50
+ (typeof entry.key !== "string" || !PROTECTED_CONTRACT_KEYS.has(entry.key)));
50
51
  }
51
52
  function synchronizeNestedCapabilityBudget(root, metadata, operation) {
52
53
  const cross = root.crossSubstratePath;
@@ -0,0 +1,65 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { createZstdDecompress } from "node:zlib";
4
+ import { assertCandidate } from "../engine/candidate-database.js";
5
+ import { SharedIndexError } from "./s3-client.js";
6
+ const SQLITE_HEADER = Buffer.from("SQLite format 3\0", "binary");
7
+ function writeAll(descriptor, bytes) {
8
+ let offset = 0;
9
+ while (offset < bytes.byteLength)
10
+ offset += fs.writeSync(descriptor, bytes, offset, bytes.byteLength - offset);
11
+ }
12
+ export async function decompressArtifactToCandidate(options) {
13
+ assertCandidate(options.repo, options.candidate);
14
+ if (fs.existsSync(options.candidate.databasePath))
15
+ throw new SharedIndexError("integrity", "shared-index candidate database already exists");
16
+ const source = fs.createReadStream(options.artifact.path);
17
+ const decompressor = createZstdDecompress();
18
+ const output = fs.openSync(options.candidate.databasePath, "wx", 0o600);
19
+ const hash = crypto.createHash("sha256");
20
+ let received = 0;
21
+ try {
22
+ if (options.signal) {
23
+ if (options.signal.aborted)
24
+ throw new SharedIndexError("timeout", "shared-index decompression was cancelled");
25
+ options.signal.addEventListener("abort", () => decompressor.destroy(), { once: true });
26
+ }
27
+ source.pipe(decompressor);
28
+ for await (const chunk of decompressor) {
29
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
30
+ received += bytes.byteLength;
31
+ if (received > options.manifest.graph.uncompressedBytes)
32
+ throw new SharedIndexError("integrity", "shared-index decompressed object exceeded manifest size");
33
+ hash.update(bytes);
34
+ writeAll(output, bytes);
35
+ }
36
+ if (decompressor.bytesWritten !== options.manifest.graph.compressedBytes)
37
+ throw new SharedIndexError("integrity", "shared-index compressed object contains trailing data");
38
+ fs.fsyncSync(output);
39
+ if (received !== options.manifest.graph.uncompressedBytes ||
40
+ hash.digest("hex") !== options.manifest.graph.uncompressedSha256)
41
+ throw new SharedIndexError("integrity", "shared-index decompressed object failed size or digest verification");
42
+ }
43
+ catch (error) {
44
+ fs.closeSync(output);
45
+ fs.rmSync(options.candidate.databasePath, { force: true });
46
+ if (options.signal?.aborted)
47
+ throw new SharedIndexError("timeout", "shared-index decompression was cancelled");
48
+ if (error instanceof SharedIndexError)
49
+ throw error;
50
+ throw new SharedIndexError("integrity", "shared-index compressed object is not valid zstd");
51
+ }
52
+ finally {
53
+ source.destroy();
54
+ decompressor.destroy();
55
+ }
56
+ fs.closeSync(output);
57
+ const descriptor = fs.openSync(options.candidate.databasePath, "r");
58
+ const header = Buffer.alloc(SQLITE_HEADER.byteLength);
59
+ const headerBytes = fs.readSync(descriptor, header, 0, header.byteLength, 0);
60
+ fs.closeSync(descriptor);
61
+ if (headerBytes !== header.byteLength || !header.equals(SQLITE_HEADER)) {
62
+ fs.rmSync(options.candidate.databasePath, { force: true });
63
+ throw new SharedIndexError("integrity", "shared-index artifact is not a SQLite database");
64
+ }
65
+ }
@@ -0,0 +1,267 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { SharedIndexError } from "./s3-client.js";
6
+ const DIGEST = /^[a-f0-9]{64}$/;
7
+ const CACHE_FILE = "graph.sqlite.zst";
8
+ function assertDigest(digest) {
9
+ if (!DIGEST.test(digest))
10
+ throw new SharedIndexError("integrity", "shared-index cache digest is invalid");
11
+ }
12
+ function hashFile(target) {
13
+ const hash = crypto.createHash("sha256");
14
+ const descriptor = fs.openSync(target, "r");
15
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
16
+ try {
17
+ let bytes = 0;
18
+ do {
19
+ bytes = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null);
20
+ if (bytes > 0)
21
+ hash.update(buffer.subarray(0, bytes));
22
+ } while (bytes > 0);
23
+ }
24
+ finally {
25
+ fs.closeSync(descriptor);
26
+ }
27
+ return hash.digest("hex");
28
+ }
29
+ function writeAll(descriptor, bytes) {
30
+ let offset = 0;
31
+ while (offset < bytes.byteLength)
32
+ offset += fs.writeSync(descriptor, bytes, offset, bytes.byteLength - offset);
33
+ }
34
+ function fsyncDirectory(target) {
35
+ try {
36
+ const descriptor = fs.openSync(target, "r");
37
+ try {
38
+ fs.fsyncSync(descriptor);
39
+ }
40
+ finally {
41
+ fs.closeSync(descriptor);
42
+ }
43
+ }
44
+ catch (error) {
45
+ if (process.platform !== "win32")
46
+ throw error;
47
+ }
48
+ }
49
+ function processExists(pid) {
50
+ try {
51
+ process.kill(pid, 0);
52
+ return true;
53
+ }
54
+ catch (error) {
55
+ return error.code === "EPERM";
56
+ }
57
+ }
58
+ function removeStaleLeases(directory) {
59
+ if (!fs.existsSync(directory))
60
+ return;
61
+ for (const name of fs.readdirSync(directory)) {
62
+ const match = /^\.lease-(\d+)-/.exec(name);
63
+ if (match && !processExists(Number(match[1])))
64
+ fs.rmSync(path.join(directory, name), { force: true });
65
+ }
66
+ }
67
+ function isLeased(directory) {
68
+ removeStaleLeases(directory);
69
+ return (fs.existsSync(directory) && fs.readdirSync(directory).some((name) => name.startsWith(".lease-")));
70
+ }
71
+ function wait(milliseconds, signal) {
72
+ return new Promise((resolve, reject) => {
73
+ const finish = () => {
74
+ signal?.removeEventListener("abort", abort);
75
+ resolve();
76
+ };
77
+ const timer = setTimeout(finish, milliseconds);
78
+ const abort = () => {
79
+ clearTimeout(timer);
80
+ signal?.removeEventListener("abort", abort);
81
+ reject(new SharedIndexError("timeout", "shared-index cache wait was cancelled"));
82
+ };
83
+ if (signal?.aborted)
84
+ abort();
85
+ else
86
+ signal?.addEventListener("abort", abort, { once: true });
87
+ });
88
+ }
89
+ export class SharedIndexCache {
90
+ root;
91
+ maxEntries;
92
+ maxBytes;
93
+ lockTimeoutMs;
94
+ constructor(policy = {}) {
95
+ this.root = path.resolve(policy.root ?? path.join(os.homedir(), ".knodin", "shared-index-cache", "v1", "sha256"));
96
+ this.maxEntries = policy.maxEntries ?? 32;
97
+ this.maxBytes = policy.maxBytes ?? 32 * 1024 ** 3;
98
+ this.lockTimeoutMs = policy.lockTimeoutMs ?? 30_000;
99
+ if (!Number.isSafeInteger(this.maxEntries) ||
100
+ this.maxEntries < 1 ||
101
+ !Number.isSafeInteger(this.maxBytes) ||
102
+ this.maxBytes < 1 ||
103
+ !Number.isSafeInteger(this.lockTimeoutMs) ||
104
+ this.lockTimeoutMs < 1)
105
+ throw new SharedIndexError("configuration", "shared-index cache policy is invalid");
106
+ fs.mkdirSync(this.root, { recursive: true, mode: 0o700 });
107
+ if (fs.lstatSync(this.root).isSymbolicLink() || !fs.lstatSync(this.root).isDirectory())
108
+ throw new SharedIndexError("configuration", "shared-index cache root is not a private directory");
109
+ fs.chmodSync(this.root, 0o700);
110
+ }
111
+ entry(digest) {
112
+ assertDigest(digest);
113
+ const directory = path.join(this.root, digest);
114
+ const file = path.join(directory, CACHE_FILE);
115
+ const stat = fs.existsSync(file) ? fs.statSync(file) : undefined;
116
+ return {
117
+ digest,
118
+ directory,
119
+ file,
120
+ bytes: stat?.size ?? 0,
121
+ accessedAt: Math.max(stat?.atimeMs ?? 0, stat?.mtimeMs ?? 0),
122
+ };
123
+ }
124
+ async lock(digest, signal) {
125
+ const target = path.join(this.root, `.${digest}.lock`);
126
+ const deadline = Date.now() + this.lockTimeoutMs;
127
+ while (true) {
128
+ try {
129
+ const descriptor = fs.openSync(target, "wx", 0o600);
130
+ fs.writeFileSync(descriptor, `${process.pid}\n${Date.now()}\n`);
131
+ fs.closeSync(descriptor);
132
+ return () => fs.rmSync(target, { force: true });
133
+ }
134
+ catch (error) {
135
+ if (error.code !== "EEXIST")
136
+ throw error;
137
+ try {
138
+ const [pidText, createdText] = fs.readFileSync(target, "utf8").split("\n");
139
+ if (Date.now() - Number(createdText) > this.lockTimeoutMs &&
140
+ !processExists(Number(pidText))) {
141
+ fs.rmSync(target, { force: true });
142
+ continue;
143
+ }
144
+ }
145
+ catch {
146
+ // The lock owner may be replacing or releasing it; retry within the bound.
147
+ }
148
+ if (Date.now() >= deadline)
149
+ throw new SharedIndexError("timeout", "shared-index cache lock timed out");
150
+ await wait(50, signal);
151
+ }
152
+ }
153
+ }
154
+ valid(entry, expectedBytes, expectedDigest) {
155
+ if (!fs.existsSync(entry.directory))
156
+ return false;
157
+ const directory = fs.lstatSync(entry.directory);
158
+ if (!directory.isDirectory() || directory.isSymbolicLink())
159
+ return false;
160
+ if (!fs.existsSync(entry.file))
161
+ return false;
162
+ const stat = fs.lstatSync(entry.file);
163
+ if (!stat.isFile() ||
164
+ stat.isSymbolicLink() ||
165
+ stat.size !== expectedBytes ||
166
+ (stat.mode & 0o777) !== 0o400)
167
+ return false;
168
+ return hashFile(entry.file) === expectedDigest;
169
+ }
170
+ lease(entry, state) {
171
+ const marker = path.join(entry.directory, `.lease-${process.pid}-${crypto.randomUUID()}`);
172
+ fs.writeFileSync(marker, "", { flag: "wx", mode: 0o600 });
173
+ const now = new Date();
174
+ fs.utimesSync(entry.file, now, now);
175
+ let released = false;
176
+ return {
177
+ path: entry.file,
178
+ state,
179
+ release: () => {
180
+ if (released)
181
+ return;
182
+ released = true;
183
+ fs.rmSync(marker, { force: true });
184
+ this.evict();
185
+ },
186
+ };
187
+ }
188
+ entries() {
189
+ return fs
190
+ .readdirSync(this.root, { withFileTypes: true })
191
+ .filter((entry) => entry.isDirectory() && DIGEST.test(entry.name))
192
+ .map((entry) => this.entry(entry.name))
193
+ .filter((entry) => fs.existsSync(entry.file));
194
+ }
195
+ evict() {
196
+ const entries = this.entries().sort((left, right) => left.accessedAt - right.accessedAt);
197
+ let bytes = entries.reduce((total, entry) => total + entry.bytes, 0);
198
+ let count = entries.length;
199
+ for (const entry of entries) {
200
+ if (count <= this.maxEntries && bytes <= this.maxBytes)
201
+ break;
202
+ if (isLeased(entry.directory) || fs.existsSync(path.join(this.root, `.${entry.digest}.lock`)))
203
+ continue;
204
+ fs.rmSync(entry.directory, { recursive: true, force: true });
205
+ count -= 1;
206
+ bytes -= entry.bytes;
207
+ }
208
+ }
209
+ async acquire(store, manifest, signal) {
210
+ const digest = manifest.graph.compressedSha256;
211
+ const expectedBytes = manifest.graph.compressedBytes;
212
+ if (expectedBytes > this.maxBytes)
213
+ throw new SharedIndexError("configuration", "shared-index artifact exceeds the configured cache-size bound");
214
+ const entry = this.entry(digest);
215
+ const unlock = await this.lock(digest, signal);
216
+ try {
217
+ const corrupt = fs.existsSync(entry.file);
218
+ if (this.valid(entry, expectedBytes, digest))
219
+ return this.lease(entry, "hit");
220
+ if (corrupt && isLeased(entry.directory))
221
+ throw new SharedIndexError("unavailable", "shared-index corrupt cache entry is currently leased");
222
+ if (fs.existsSync(entry.directory))
223
+ fs.rmSync(entry.directory, { recursive: true, force: true });
224
+ fs.mkdirSync(entry.directory, { recursive: true, mode: 0o700 });
225
+ const metadata = await store.head(manifest.graph.key, { signal });
226
+ if (metadata.bytes !== expectedBytes)
227
+ throw new SharedIndexError("integrity", "shared-index compressed object size does not match manifest");
228
+ const temporary = path.join(entry.directory, `.download-${crypto.randomUUID()}.tmp`);
229
+ try {
230
+ const source = await store.get(manifest.graph.key, { maxBytes: expectedBytes, signal });
231
+ const descriptor = fs.openSync(temporary, "wx", 0o600);
232
+ const hash = crypto.createHash("sha256");
233
+ let received = 0;
234
+ try {
235
+ for await (const chunk of source) {
236
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
237
+ received += bytes.byteLength;
238
+ if (received > expectedBytes)
239
+ throw new SharedIndexError("integrity", "shared-index compressed object exceeded manifest size");
240
+ hash.update(bytes);
241
+ writeAll(descriptor, bytes);
242
+ }
243
+ fs.fsyncSync(descriptor);
244
+ }
245
+ finally {
246
+ fs.closeSync(descriptor);
247
+ }
248
+ if (received !== expectedBytes || hash.digest("hex") !== digest)
249
+ throw new SharedIndexError("integrity", "shared-index compressed object failed size or digest verification");
250
+ fs.chmodSync(temporary, 0o400);
251
+ fs.renameSync(temporary, entry.file);
252
+ fsyncDirectory(entry.directory);
253
+ const state = corrupt ? "corrupt-refetched" : "downloaded";
254
+ const lease = this.lease(this.entry(digest), state);
255
+ this.evict();
256
+ return lease;
257
+ }
258
+ catch (error) {
259
+ fs.rmSync(temporary, { force: true });
260
+ throw error;
261
+ }
262
+ }
263
+ finally {
264
+ unlock();
265
+ }
266
+ }
267
+ }
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL_ID, EMBEDDING_MODEL_REVISION, } from "../engine/embeddings.js";
6
+ import { KNODIN_SCHEMA_VERSION } from "../engine/index.js";
7
+ import { prunePolicyDescriptor } from "../engine/prune.js";
8
+ import { KNODIN_VERSION } from "../version.js";
9
+ import { canonicalManifestBytes, sha256Hex } from "./contract.js";
10
+ const require = createRequire(import.meta.url);
11
+ function packageVersion(name) {
12
+ try {
13
+ return require(`${name}/package.json`).version;
14
+ }
15
+ catch {
16
+ let current = path.dirname(fileURLToPath(import.meta.url));
17
+ try {
18
+ current = path.dirname(require.resolve(name));
19
+ }
20
+ catch {
21
+ throw new Error(`cannot resolve compatibility dependency ${name}`);
22
+ }
23
+ for (;;) {
24
+ const manifest = path.join(current, "package.json");
25
+ if (fs.existsSync(manifest)) {
26
+ const value = JSON.parse(fs.readFileSync(manifest, "utf8"));
27
+ if (value.name === name && value.version)
28
+ return value.version;
29
+ }
30
+ const parent = path.dirname(current);
31
+ if (parent === current)
32
+ break;
33
+ current = parent;
34
+ }
35
+ throw new Error(`cannot read compatibility dependency ${name}`);
36
+ }
37
+ }
38
+ function digest(value) {
39
+ return sha256Hex(canonicalManifestBytes(value));
40
+ }
41
+ /** One implementation used by client verification and publisher metadata. */
42
+ export function currentSharedIndexCompatibility() {
43
+ const model = {
44
+ id: EMBEDDING_MODEL_ID,
45
+ revision: EMBEDDING_MODEL_REVISION,
46
+ dimensions: EMBEDDING_DIMENSIONS,
47
+ runtime: `@huggingface/transformers@${packageVersion("@huggingface/transformers")}`,
48
+ };
49
+ const parsers = {
50
+ policyVersion: 1,
51
+ packages: {
52
+ "tree-sitter-wasms": packageVersion("tree-sitter-wasms"),
53
+ "web-tree-sitter": packageVersion("web-tree-sitter"),
54
+ "web-tree-sitter-sfapex": packageVersion("web-tree-sitter-sfapex"),
55
+ },
56
+ };
57
+ return {
58
+ knodinVersion: KNODIN_VERSION,
59
+ databaseSchemaVersion: KNODIN_SCHEMA_VERSION,
60
+ modelId: EMBEDDING_MODEL_ID,
61
+ modelDigest: digest(model),
62
+ parserBundleDigest: digest(parsers),
63
+ indexConfigDigest: digest({
64
+ policyVersion: 1,
65
+ databaseSchemaVersion: KNODIN_SCHEMA_VERSION,
66
+ embeddingDimensions: EMBEDDING_DIMENSIONS,
67
+ }),
68
+ prunePolicyDigest: digest(prunePolicyDescriptor()),
69
+ };
70
+ }