knodin 0.8.3 → 0.8.5

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 (42) hide show
  1. package/README.md +7 -1
  2. package/dist/bin/cli.js +489 -78
  3. package/dist/src/agent-events.js +128 -0
  4. package/dist/src/agent-hooks.js +156 -0
  5. package/dist/src/cli-model.js +42 -1
  6. package/dist/src/engine/candidate-database.js +167 -0
  7. package/dist/src/engine/embeddings.js +8 -5
  8. package/dist/src/engine/index.js +930 -262
  9. package/dist/src/engine/prune.js +8 -0
  10. package/dist/src/file-metrics.js +153 -0
  11. package/dist/src/output-telemetry.js +8 -3
  12. package/dist/src/repair-lease.js +8 -5
  13. package/dist/src/response-budget.js +2 -1
  14. package/dist/src/session-telemetry.js +163 -0
  15. package/dist/src/shared-index/artifact.js +65 -0
  16. package/dist/src/shared-index/cache.js +267 -0
  17. package/dist/src/shared-index/compatibility.js +70 -0
  18. package/dist/src/shared-index/config.js +224 -0
  19. package/dist/src/shared-index/contract.js +117 -0
  20. package/dist/src/shared-index/manifest.js +71 -0
  21. package/dist/src/shared-index/opportunistic.js +37 -0
  22. package/dist/src/shared-index/overlay.js +98 -0
  23. package/dist/src/shared-index/provenance.js +149 -0
  24. package/dist/src/shared-index/publisher.js +198 -0
  25. package/dist/src/shared-index/restore.js +209 -0
  26. package/dist/src/shared-index/s3-client.js +124 -0
  27. package/dist/src/shared-index/schemas.js +86 -0
  28. package/dist/src/shared-index/selection.js +111 -0
  29. package/dist/src/tools/knodin-tools.js +70 -3
  30. package/docs/CLI.md +56 -0
  31. package/docs/INSTALLATION.md +11 -0
  32. package/docs/MCP.md +12 -0
  33. package/docs/SHARED-INDEX-CONTRACT.md +132 -0
  34. package/docs/TELEMETRY.md +19 -1
  35. package/docs/releases/0.8.4.md +22 -0
  36. package/docs/releases/0.8.5.md +25 -0
  37. package/package.json +12 -3
  38. package/roadmap/competitive-roadmap.md +20 -0
  39. package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
  40. package/schemas/shared-index-config-v1.schema.json +97 -0
  41. package/schemas/shared-index-manifest-v1.schema.json +104 -0
  42. package/schemas/shared-index-provenance-v1.schema.json +100 -0
@@ -0,0 +1,128 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { countOutputTokens } from "./output-telemetry.js";
4
+ import { appendSessionEvent, } from "./session-telemetry.js";
5
+ export const SESSION_CONTEXT_BUDGET_LINE = "Budget: at most 600 tokens, 8 KiB, 12 items.";
6
+ const KNOWN_OPERATIONS = new Set([
7
+ "context",
8
+ "explain",
9
+ "review",
10
+ "map",
11
+ "search",
12
+ "query",
13
+ "pack",
14
+ "compress",
15
+ "execute",
16
+ "prs",
17
+ "wiki",
18
+ "docs",
19
+ "remote",
20
+ ]);
21
+ function knownOperation(value) {
22
+ return typeof value === "string" && KNOWN_OPERATIONS.has(value) ? value : undefined;
23
+ }
24
+ export function parseClaudeHookPayload(raw) {
25
+ if (Buffer.byteLength(raw) > 65_536)
26
+ throw new Error("knodin agent-event: hook input exceeds 65536 bytes");
27
+ const parsed = JSON.parse(raw);
28
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
29
+ throw new Error("knodin agent-event: hook input must be a JSON object");
30
+ return parsed;
31
+ }
32
+ export function classifyTool(payload) {
33
+ const name = payload.tool_name ?? "";
34
+ if (name === "mcp__knodin__knodin") {
35
+ const operation = knownOperation(payload.tool_input?.operation);
36
+ return {
37
+ toolCategory: "knodin",
38
+ ...(operation ? { operation } : {}),
39
+ };
40
+ }
41
+ if (["Read", "Glob", "Grep"].includes(name))
42
+ return { toolCategory: "manual_traversal" };
43
+ if (["Edit", "Write", "NotebookEdit", "MultiEdit"].includes(name))
44
+ return { toolCategory: "edit" };
45
+ if (name === "Bash") {
46
+ const command = typeof payload.tool_input?.command === "string" ? payload.tool_input.command : "";
47
+ const knodin = command.match(/(?:^|[;&|]\s*)knodin\s+([a-z][a-z0-9-]*)/i);
48
+ if (knodin) {
49
+ const operation = knownOperation(knodin[1]?.toLowerCase());
50
+ return { toolCategory: "knodin", ...(operation ? { operation } : {}) };
51
+ }
52
+ if (/(?:^|[;&|]\s*)(?:rg|grep|find)\b/.test(command))
53
+ return { toolCategory: "manual_traversal" };
54
+ }
55
+ return { toolCategory: "other" };
56
+ }
57
+ function cachePath(repo) {
58
+ return path.join(repo, ".knodin", "session-context-cache.json");
59
+ }
60
+ export function readSessionContextCache(repo, key, now = Date.now()) {
61
+ try {
62
+ const parsed = JSON.parse(fs.readFileSync(cachePath(repo), "utf8"));
63
+ return parsed.key === key && typeof parsed.at === "number" && now - parsed.at <= 300_000
64
+ ? (parsed.text ?? null)
65
+ : null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ export function writeSessionContextCache(repo, key, text) {
72
+ const filePath = cachePath(repo);
73
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
74
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
75
+ fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, key, at: Date.now(), text }), {
76
+ mode: 0o600,
77
+ flag: "wx",
78
+ });
79
+ fs.renameSync(temporary, filePath);
80
+ }
81
+ export function canUseSessionContextCache(status, freshness) {
82
+ return status === "healthy" && freshness === "fresh";
83
+ }
84
+ export function renderSessionContext(context, freshness) {
85
+ const list = (values, render) => (values ?? []).slice(0, 3).map(render).join(", ") || "none reported";
86
+ let text = [
87
+ "## knodin session context",
88
+ `Graph evidence: ${freshness}. Treat stale or unavailable evidence as incomplete.`,
89
+ SESSION_CONTEXT_BUDGET_LINE,
90
+ `Repository: ${context.stats?.files ?? "?"} files, ${context.stats?.symbols ?? "?"} symbols.`,
91
+ `Subsystems: ${list(context.communities, (value) => `${value.name} (${value.size})`)}`,
92
+ `Hubs: ${list(context.hubs, (value) => `${value.symbol} (${value.degree})`)}`,
93
+ `Flows: ${list(context.flows, (value) => value.symbol ?? "unnamed")}`,
94
+ `Suggested next operation: ${context.suggestedOperation ?? "context"} (heuristic only).`,
95
+ "Use exact source evidence and preserve ambiguity, freshness, omissions, and response budgets.",
96
+ ].join("\n");
97
+ while ((countOutputTokens(text) > 600 || Buffer.byteLength(text) > 8192) && text.includes("\n")) {
98
+ text = `${text.slice(0, text.lastIndexOf("\n"))}\nContext truncated to the configured session budget.`;
99
+ }
100
+ return text;
101
+ }
102
+ export function recordClaudeLifecycleEvent(repo, event, payload) {
103
+ const sessionId = payload.session_id;
104
+ if (!sessionId)
105
+ return false;
106
+ const names = {
107
+ "session-start": "session_start",
108
+ "session-end": "session_end",
109
+ "turn-start": "turn_start",
110
+ "turn-end": "turn_end",
111
+ "pre-tool": "tool_start",
112
+ "post-tool": "tool_success",
113
+ "tool-failure": "tool_failure",
114
+ };
115
+ const mapped = names[event];
116
+ if (!mapped)
117
+ return false;
118
+ const tool = event.includes("tool") ? classifyTool(payload) : undefined;
119
+ return appendSessionEvent(repo, { event: mapped, sessionId, ...tool });
120
+ }
121
+ export function sessionStartHookOutput(text) {
122
+ return {
123
+ hookSpecificOutput: {
124
+ hookEventName: "SessionStart",
125
+ additionalContext: text,
126
+ },
127
+ };
128
+ }
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const OWNED_COMMAND = "knodin agent-event";
5
+ function settingsPath(homeDir = os.homedir()) {
6
+ return path.join(homeDir, ".claude", "settings.json");
7
+ }
8
+ function assertNotSymlink(filePath) {
9
+ for (const candidate of [path.dirname(filePath), filePath]) {
10
+ try {
11
+ if (fs.lstatSync(candidate).isSymbolicLink())
12
+ throw new Error(`knodin agent-hooks: refusing symlinked path ${candidate}`);
13
+ }
14
+ catch (error) {
15
+ if (error.code !== "ENOENT")
16
+ throw error;
17
+ }
18
+ }
19
+ }
20
+ function readSettings(filePath) {
21
+ if (!fs.existsSync(filePath))
22
+ return {};
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
26
+ }
27
+ catch (error) {
28
+ throw new Error(`knodin agent-hooks: invalid Claude settings JSON: ${error.message}`);
29
+ }
30
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
31
+ throw new Error("knodin agent-hooks: Claude settings must contain a JSON object");
32
+ return parsed;
33
+ }
34
+ function atomicWrite(filePath, content) {
35
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
36
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
37
+ fs.writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
38
+ fs.renameSync(temporary, filePath);
39
+ }
40
+ function ownedEntry(value) {
41
+ return JSON.stringify(value).includes(OWNED_COMMAND);
42
+ }
43
+ function removeOwned(document) {
44
+ const hooks = document.hooks;
45
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
46
+ return;
47
+ const hookObject = hooks;
48
+ for (const [event, value] of Object.entries(hookObject)) {
49
+ if (!Array.isArray(value))
50
+ continue;
51
+ const retained = value.filter((entry) => !ownedEntry(entry));
52
+ if (retained.length === 0)
53
+ delete hookObject[event];
54
+ else
55
+ hookObject[event] = retained;
56
+ }
57
+ if (Object.keys(hookObject).length === 0)
58
+ delete document.hooks;
59
+ }
60
+ function hook(command, event, matcher) {
61
+ return {
62
+ ...(matcher ? { matcher } : {}),
63
+ hooks: [
64
+ {
65
+ type: "command",
66
+ command: `${command} agent-event ${event}`,
67
+ timeout: 5,
68
+ },
69
+ ],
70
+ };
71
+ }
72
+ function managedHooks(command) {
73
+ const observed = "mcp__knodin__knodin|Bash|Read|Glob|Grep|Edit|Write|NotebookEdit";
74
+ return {
75
+ SessionStart: [hook(command, "session-start", "startup|resume|clear|compact")],
76
+ UserPromptSubmit: [hook(command, "turn-start")],
77
+ PreToolUse: [hook(command, "pre-tool", observed)],
78
+ PostToolUse: [hook(command, "post-tool", observed)],
79
+ PostToolUseFailure: [hook(command, "tool-failure", observed)],
80
+ Stop: [hook(command, "turn-end")],
81
+ SessionEnd: [hook(command, "session-end")],
82
+ };
83
+ }
84
+ function mergedSettings(existing, command) {
85
+ const document = structuredClone(existing);
86
+ removeOwned(document);
87
+ const hooks = document.hooks && typeof document.hooks === "object" && !Array.isArray(document.hooks)
88
+ ? document.hooks
89
+ : {};
90
+ for (const [event, entries] of Object.entries(managedHooks(command))) {
91
+ const current = Array.isArray(hooks[event]) ? hooks[event] : [];
92
+ hooks[event] = [...current, ...entries];
93
+ }
94
+ document.hooks = hooks;
95
+ return document;
96
+ }
97
+ export function inspectClaudeAgentHooks(options = {}) {
98
+ const filePath = settingsPath(options.homeDir);
99
+ assertNotSymlink(filePath);
100
+ const document = readSettings(filePath);
101
+ const serialized = JSON.stringify(document);
102
+ const count = serialized.split(OWNED_COMMAND).length - 1;
103
+ return {
104
+ schemaVersion: 1,
105
+ client: "claude",
106
+ settingsPath: filePath,
107
+ installed: count > 0,
108
+ managedEntries: count,
109
+ };
110
+ }
111
+ export function installClaudeAgentHooks(options = {}) {
112
+ const filePath = settingsPath(options.homeDir);
113
+ assertNotSymlink(filePath);
114
+ const before = readSettings(filePath);
115
+ const after = mergedSettings(before, options.command ?? "knodin");
116
+ const beforeText = `${JSON.stringify(before, null, 2)}\n`;
117
+ const afterText = `${JSON.stringify(after, null, 2)}\n`;
118
+ const changed = beforeText !== afterText;
119
+ if (changed && !options.dryRun) {
120
+ if (fs.existsSync(filePath)) {
121
+ const backup = `${filePath}.knodin-backup`;
122
+ if (!fs.existsSync(backup))
123
+ fs.copyFileSync(filePath, backup, fs.constants.COPYFILE_EXCL);
124
+ }
125
+ atomicWrite(filePath, afterText);
126
+ }
127
+ return {
128
+ schemaVersion: 1,
129
+ client: "claude",
130
+ settingsPath: filePath,
131
+ changed,
132
+ dryRun: options.dryRun === true,
133
+ managedEntries: Object.keys(managedHooks(options.command ?? "knodin")).length,
134
+ };
135
+ }
136
+ export function uninstallClaudeAgentHooks(options = {}) {
137
+ const filePath = settingsPath(options.homeDir);
138
+ assertNotSymlink(filePath);
139
+ if (!fs.existsSync(filePath))
140
+ return { removed: false, settingsPath: filePath };
141
+ const document = readSettings(filePath);
142
+ const hadOwned = ownedEntry(document);
143
+ removeOwned(document);
144
+ if (hadOwned) {
145
+ if (Object.keys(document).length === 0)
146
+ fs.unlinkSync(filePath);
147
+ else
148
+ atomicWrite(filePath, `${JSON.stringify(document, null, 2)}\n`);
149
+ }
150
+ return {
151
+ schemaVersion: 1,
152
+ client: "claude",
153
+ removed: hadOwned,
154
+ settingsPath: filePath,
155
+ };
156
+ }
@@ -64,6 +64,28 @@ function addRemoteCommands(program, capture) {
64
64
  leaf(remote, "remove <identity>", "delete a mirror's clone, graph, and registry entry", capture);
65
65
  leaf(remote, "refresh <identity>", "fetch the mirror again and re-index it", capture);
66
66
  }
67
+ function addSharedIndexCommands(program, capture) {
68
+ const shared = program.command("shared").description("manage verified shared-index snapshots");
69
+ leaf(shared, "status", "inspect shared-index configuration and provenance without network", capture).option("--probe", "explicitly probe S3 authentication and pointer access");
70
+ leaf(shared, "pull", "discover, verify, reconcile, audit, and promote a shared snapshot", capture)
71
+ .option("--jsonl", "stream lossless phase events as JSONL")
72
+ .option("--progress <mode>", "plain, jsonl, or none");
73
+ leaf(shared, "configure", "write reviewed repository trust configuration", capture)
74
+ .option("--disable", "disable automatic shared-index access without deleting graph or cache")
75
+ .option("--registration-id <id>", "publisher registration identifier")
76
+ .option("--region <region>", "AWS region")
77
+ .option("--bucket <bucket>", "S3 bucket")
78
+ .option("--host <host>", "canonical Git host")
79
+ .option("--owner <owner>", "repository owner")
80
+ .option("--name <name>", "repository name")
81
+ .addOption(option("--branch <pattern>", "exact branch or terminal slash-star pattern", "collect"))
82
+ .option("--key-id <id>", "reviewed signing key identifier")
83
+ .option("--public-key <path>", "PEM public-key file (credentials are never accepted)");
84
+ leaf(shared, "publisher-metadata", "stamp and emit the exact client-derived publisher compatibility metadata", capture);
85
+ leaf(shared, "publisher-bundle <branch>", "stream a graph and canonical manifest bundle", capture).requiredOption("--output <path>", "new or empty private output directory");
86
+ leaf(shared, "publisher-finalize <bundle>", "verify a detached signature and emit the canonical branch pointer", capture);
87
+ leaf(shared, "publisher-guard <pointer> <branch> <commit>", "refuse a backward or diverged branch-pointer update", capture);
88
+ }
67
89
  function addRepositoryCommands(program, capture) {
68
90
  const repos = program.command("repos").description("manage a portfolio of repositories");
69
91
  for (const action of ["discover", "init", "status", "doctor"]) {
@@ -142,6 +164,9 @@ function addGraphCommands(program, capture) {
142
164
  .addOption(option("--min-complexity <count>", "minimum complexity", "integer"))
143
165
  .option("--kinds <values>", "comma-separated symbol kinds")
144
166
  .option("--path <prefix>", "repo-relative path prefix")
167
+ .option("--base <ref>", "compare file_metrics with the merge-base of this Git ref")
168
+ .option("--test-scope <scope>", "file_metrics scope: all, test, or production")
169
+ .option("--format <format>", "file_metrics machine output format; currently json")
145
170
  .option("--variable <name>", "flow-analysis variable")
146
171
  .option("--facets <values>", "comma-separated architecture facets")
147
172
  .addOption(option("--top <count>", "maximum ranked results", "integer"))
@@ -251,9 +276,18 @@ function createCliProgram(capture = () => { }) {
251
276
  leaf(program, "serve", "run the one-tool MCP gateway on stdio", capture);
252
277
  leaf(program, "version", "print the installed knodin version", capture);
253
278
  leaf(program, "hook-refresh <kind> [values...]", "internal Git lifecycle refresh", capture);
279
+ const agentHooks = program
280
+ .command("agent-hooks")
281
+ .description("manage optional user-global coding-agent lifecycle hooks");
282
+ for (const action of ["install", "status", "uninstall"])
283
+ leaf(agentHooks, action, `${action} optional coding-agent hooks`, capture)
284
+ .option("--client <client>", "coding-agent client; currently claude")
285
+ .option("--dry-run", "preview settings changes without writing");
286
+ leaf(program, "agent-event <event>", "internal coding-agent lifecycle event", capture);
254
287
  leaf(program, "refresh-artifacts [event]", "refresh external graph artifacts", capture);
255
288
  addRepositoryCommands(program, capture);
256
289
  addRemoteCommands(program, capture);
290
+ addSharedIndexCommands(program, capture);
257
291
  addGraphCommands(program, capture);
258
292
  addArtifactCommands(program, capture);
259
293
  const system = program.command("system").description("inspect declared multi-repository systems");
@@ -283,7 +317,14 @@ function createCliProgram(capture = () => { }) {
283
317
  .command("telemetry")
284
318
  .description("manage local opt-in ROI telemetry")
285
319
  .allowExcessArguments(false)
286
- .addArgument(new Argument("<action>").choices(["status", "report", "export", "clear"]));
320
+ .addArgument(new Argument("<action>").choices([
321
+ "enable",
322
+ "disable",
323
+ "status",
324
+ "report",
325
+ "export",
326
+ "clear",
327
+ ]));
287
328
  telemetry
288
329
  .option("--input <path>", "telemetry input path")
289
330
  .option("--output <path>", "dashboard or evidence-bundle output path")
@@ -0,0 +1,167 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveDbPath, resolveStateDir } from "./state-paths.js";
5
+ const PROMOTION_SCHEMA_VERSION = 1;
6
+ const CANDIDATES_DIRECTORY = "candidates";
7
+ const PROMOTION_MARKER = "promotion.json";
8
+ function inside(parent, child) {
9
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
10
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
11
+ }
12
+ function assertPromotionRecord(repo, value) {
13
+ if (!value || typeof value !== "object")
14
+ throw new Error("invalid promotion recovery record");
15
+ const record = value;
16
+ const stateDir = resolveStateDir(repo);
17
+ const activePath = resolveDbPath(repo);
18
+ if (record.schemaVersion !== PROMOTION_SCHEMA_VERSION ||
19
+ !record.candidatePath ||
20
+ !record.backupPath ||
21
+ record.activePath !== activePath ||
22
+ !inside(stateDir, record.candidatePath) ||
23
+ !inside(stateDir, record.backupPath) ||
24
+ !record.phase ||
25
+ !["prepared", "backed-up", "promoted"].includes(record.phase)) {
26
+ throw new Error("invalid promotion recovery record");
27
+ }
28
+ return record;
29
+ }
30
+ function fsyncFile(target) {
31
+ const descriptor = fs.openSync(target, "r");
32
+ try {
33
+ fs.fsyncSync(descriptor);
34
+ }
35
+ finally {
36
+ fs.closeSync(descriptor);
37
+ }
38
+ }
39
+ function fsyncDirectory(target) {
40
+ try {
41
+ const descriptor = fs.openSync(target, "r");
42
+ try {
43
+ fs.fsyncSync(descriptor);
44
+ }
45
+ finally {
46
+ fs.closeSync(descriptor);
47
+ }
48
+ }
49
+ catch (error) {
50
+ // Some platforms do not support directory fsync. File fsync and same-filesystem
51
+ // rename remain the strongest available ordering guarantee there.
52
+ if (process.platform !== "win32")
53
+ throw error;
54
+ }
55
+ }
56
+ function atomicRecord(target, record) {
57
+ const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
58
+ fs.writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: 0o600 });
59
+ fsyncFile(temporary);
60
+ fs.renameSync(temporary, target);
61
+ fsyncDirectory(path.dirname(target));
62
+ }
63
+ export function candidateRoot(repo) {
64
+ return path.join(resolveStateDir(repo), CANDIDATES_DIRECTORY);
65
+ }
66
+ export function promotionMarkerPath(repo) {
67
+ return path.join(resolveStateDir(repo), PROMOTION_MARKER);
68
+ }
69
+ export function allocateCandidate(repo) {
70
+ const resolvedRepo = path.resolve(repo);
71
+ const root = candidateRoot(resolvedRepo);
72
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
73
+ const directory = fs.mkdtempSync(path.join(root, "candidate-"));
74
+ fs.chmodSync(directory, 0o700);
75
+ return {
76
+ schemaVersion: 1,
77
+ id: path.basename(directory),
78
+ repo: resolvedRepo,
79
+ databasePath: path.join(directory, "db.sqlite"),
80
+ createdAt: new Date().toISOString(),
81
+ };
82
+ }
83
+ export function assertCandidate(repo, candidate) {
84
+ const resolvedRepo = path.resolve(repo);
85
+ if (candidate.schemaVersion !== 1 ||
86
+ candidate.repo !== resolvedRepo ||
87
+ !inside(candidateRoot(resolvedRepo), candidate.databasePath) ||
88
+ path.basename(candidate.databasePath) !== "db.sqlite") {
89
+ throw new Error("candidate does not belong to this repository");
90
+ }
91
+ }
92
+ /** Restore the previous active graph after a process stopped between promotion renames. */
93
+ export function recoverInterruptedPromotion(repo) {
94
+ const marker = promotionMarkerPath(repo);
95
+ if (!fs.existsSync(marker))
96
+ return "none";
97
+ const record = assertPromotionRecord(repo, JSON.parse(fs.readFileSync(marker, "utf8")));
98
+ const activeExists = fs.existsSync(record.activePath);
99
+ const backupExists = fs.existsSync(record.backupPath);
100
+ let outcome;
101
+ if (!activeExists && backupExists) {
102
+ fs.renameSync(record.backupPath, record.activePath);
103
+ fsyncDirectory(path.dirname(record.activePath));
104
+ outcome = "recovered";
105
+ }
106
+ else if (activeExists) {
107
+ outcome = record.phase === "promoted" ? "completed" : "recovered";
108
+ }
109
+ else {
110
+ throw new Error("interrupted promotion has neither active nor backup database");
111
+ }
112
+ fs.rmSync(marker, { force: true });
113
+ fsyncDirectory(path.dirname(marker));
114
+ return outcome;
115
+ }
116
+ /** Same-filesystem, marker-backed promotion. Caller must close and audit both databases first. */
117
+ export function promoteCandidateFile(repo, candidate) {
118
+ assertCandidate(repo, candidate);
119
+ const activePath = resolveDbPath(repo);
120
+ const stateDir = resolveStateDir(repo);
121
+ if (!fs.existsSync(candidate.databasePath))
122
+ throw new Error("candidate database is missing");
123
+ for (const suffix of ["-wal", "-shm"])
124
+ if (fs.existsSync(`${candidate.databasePath}${suffix}`))
125
+ throw new Error("candidate database has uncheckpointed sidecar files");
126
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
127
+ fsyncFile(candidate.databasePath);
128
+ const backupPath = path.join(stateDir, `db.sqlite.backup.${Date.now()}.${crypto.randomUUID().slice(0, 8)}`);
129
+ const marker = promotionMarkerPath(repo);
130
+ let record = {
131
+ schemaVersion: 1,
132
+ phase: "prepared",
133
+ candidatePath: candidate.databasePath,
134
+ activePath,
135
+ backupPath,
136
+ };
137
+ atomicRecord(marker, record);
138
+ try {
139
+ if (fs.existsSync(activePath))
140
+ fs.renameSync(activePath, backupPath);
141
+ record = { ...record, phase: "backed-up" };
142
+ atomicRecord(marker, record);
143
+ fs.renameSync(candidate.databasePath, activePath);
144
+ record = { ...record, phase: "promoted" };
145
+ atomicRecord(marker, record);
146
+ fsyncFile(activePath);
147
+ fsyncDirectory(stateDir);
148
+ fs.rmSync(marker, { force: true });
149
+ fsyncDirectory(stateDir);
150
+ return fs.existsSync(backupPath) ? backupPath : null;
151
+ }
152
+ catch (error) {
153
+ if (fs.existsSync(activePath) && fs.existsSync(backupPath)) {
154
+ fs.mkdirSync(path.dirname(candidate.databasePath), { recursive: true, mode: 0o700 });
155
+ fs.renameSync(activePath, candidate.databasePath);
156
+ }
157
+ if (!fs.existsSync(activePath) && fs.existsSync(backupPath))
158
+ fs.renameSync(backupPath, activePath);
159
+ fs.rmSync(marker, { force: true });
160
+ fsyncDirectory(stateDir);
161
+ throw error;
162
+ }
163
+ }
164
+ export function discardCandidateFiles(repo, candidate) {
165
+ assertCandidate(repo, candidate);
166
+ fs.rmSync(path.dirname(candidate.databasePath), { recursive: true, force: true });
167
+ }
@@ -15,7 +15,9 @@ let embedderPromise = null;
15
15
  let embedderOverride = null;
16
16
  const embedderProgressListeners = new Set();
17
17
  let embedderReady = false;
18
- const EMBEDDING_DIMENSIONS = 384;
18
+ export const EMBEDDING_DIMENSIONS = 384;
19
+ export const EMBEDDING_MODEL_ID = "Xenova/all-MiniLM-L6-v2";
20
+ export const EMBEDDING_MODEL_REVISION = "751bff37182d3f1213fa05d7196b954e230abad9";
19
21
  const DEFAULT_MODEL_REMOTE_HOST = "https://huggingface.co/";
20
22
  /**
21
23
  * One machine-level model cache shared by every repository and every installed
@@ -113,7 +115,7 @@ export function getEmbedder(onProgress) {
113
115
  return Promise.resolve(embedderOverride);
114
116
  }
115
117
  if (onProgress && embedderReady)
116
- onProgress({ status: "ready", name: "Xenova/all-MiniLM-L6-v2" });
118
+ onProgress({ status: "ready", name: EMBEDDING_MODEL_ID });
117
119
  else if (onProgress)
118
120
  embedderProgressListeners.add(onProgress);
119
121
  if (!embedderPromise) {
@@ -132,13 +134,14 @@ export function getEmbedder(onProgress) {
132
134
  }
133
135
  }
134
136
  };
135
- notify({ status: "loading", name: "Xenova/all-MiniLM-L6-v2" });
136
- const pipe = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", {
137
+ notify({ status: "loading", name: EMBEDDING_MODEL_ID });
138
+ const pipe = await pipeline("feature-extraction", EMBEDDING_MODEL_ID, {
139
+ revision: EMBEDDING_MODEL_REVISION,
137
140
  progress_callback: notify,
138
141
  });
139
142
  realEmbedderLoadCount++;
140
143
  embedderReady = true;
141
- notify({ status: "ready", name: "Xenova/all-MiniLM-L6-v2" });
144
+ notify({ status: "ready", name: EMBEDDING_MODEL_ID });
142
145
  embedderProgressListeners.clear();
143
146
  return pipe;
144
147
  }