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.
- package/README.md +7 -1
- package/dist/bin/cli.js +489 -78
- package/dist/src/agent-events.js +128 -0
- package/dist/src/agent-hooks.js +156 -0
- package/dist/src/cli-model.js +42 -1
- package/dist/src/engine/candidate-database.js +167 -0
- package/dist/src/engine/embeddings.js +8 -5
- package/dist/src/engine/index.js +930 -262
- package/dist/src/engine/prune.js +8 -0
- package/dist/src/file-metrics.js +153 -0
- package/dist/src/output-telemetry.js +8 -3
- package/dist/src/repair-lease.js +8 -5
- package/dist/src/response-budget.js +2 -1
- package/dist/src/session-telemetry.js +163 -0
- package/dist/src/shared-index/artifact.js +65 -0
- package/dist/src/shared-index/cache.js +267 -0
- package/dist/src/shared-index/compatibility.js +70 -0
- package/dist/src/shared-index/config.js +224 -0
- package/dist/src/shared-index/contract.js +117 -0
- package/dist/src/shared-index/manifest.js +71 -0
- package/dist/src/shared-index/opportunistic.js +37 -0
- package/dist/src/shared-index/overlay.js +98 -0
- package/dist/src/shared-index/provenance.js +149 -0
- package/dist/src/shared-index/publisher.js +198 -0
- package/dist/src/shared-index/restore.js +209 -0
- package/dist/src/shared-index/s3-client.js +124 -0
- package/dist/src/shared-index/schemas.js +86 -0
- package/dist/src/shared-index/selection.js +111 -0
- package/dist/src/tools/knodin-tools.js +70 -3
- package/docs/CLI.md +56 -0
- package/docs/INSTALLATION.md +11 -0
- package/docs/MCP.md +12 -0
- package/docs/SHARED-INDEX-CONTRACT.md +132 -0
- package/docs/TELEMETRY.md +19 -1
- package/docs/releases/0.8.4.md +22 -0
- package/docs/releases/0.8.5.md +25 -0
- package/package.json +12 -3
- package/roadmap/competitive-roadmap.md +20 -0
- package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
- package/schemas/shared-index-config-v1.schema.json +97 -0
- package/schemas/shared-index-manifest-v1.schema.json +104 -0
- 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
|
+
}
|
package/dist/src/cli-model.js
CHANGED
|
@@ -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([
|
|
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:
|
|
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:
|
|
136
|
-
const pipe = await pipeline("feature-extraction",
|
|
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:
|
|
144
|
+
notify({ status: "ready", name: EMBEDDING_MODEL_ID });
|
|
142
145
|
embedderProgressListeners.clear();
|
|
143
146
|
return pipe;
|
|
144
147
|
}
|