@nurix/nustack 0.23.8 → 0.23.10
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/dist/commands/census.js +48 -0
- package/dist/commands/gate.js +75 -0
- package/dist/commands/telemetry.js +37 -5
- package/dist/index.js +8 -451
- package/dist/lib/agent/claudeHooks.js +39 -19
- package/dist/lib/census/attribute.js +120 -0
- package/dist/lib/census/report.js +108 -0
- package/dist/lib/census/transcripts.js +62 -0
- package/dist/lib/desktop_settings.js +23 -2
- package/dist/lib/filebeat_installer.js +5 -1
- package/dist/lib/gate/bashRead.js +201 -0
- package/dist/lib/gate/estimate.js +90 -0
- package/dist/lib/gate/hook.js +79 -0
- package/dist/lib/gate/outline.js +146 -0
- package/dist/lib/gate/readGate.js +126 -0
- package/dist/lib/gate/settings.js +24 -0
- package/dist/lib/gate/subcommands.js +1 -0
- package/dist/lib/local_store/database.js +42 -1
- package/dist/lib/local_store/index.js +1 -0
- package/dist/lib/local_store/migrations.js +36 -0
- package/dist/lib/local_store/routing.js +137 -0
- package/dist/lib/nustack_client.js +4 -2
- package/dist/lib/session_telemetry.js +108 -40
- package/dist/lib/telemetry/config_renderer.js +4 -4
- package/dist/lib/telemetry/environment.js +2 -0
- package/dist/lib/telemetry/health_report.js +10 -3
- package/dist/lib/telemetry/part_state.js +168 -0
- package/dist/program.js +491 -0
- package/package.json +2 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { GateInputError, gateBashCall, gateReadCall } from "./readGate.js";
|
|
3
|
+
const TTY_NOTICE = "nustack gate: hook entry point; reads Claude Code's PreToolUse JSON on stdin\n";
|
|
4
|
+
async function readStdin(stdin) {
|
|
5
|
+
const chunks = [];
|
|
6
|
+
for await (const chunk of stdin)
|
|
7
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
8
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
9
|
+
}
|
|
10
|
+
function parseCall(text, startedAt) {
|
|
11
|
+
let payload;
|
|
12
|
+
try {
|
|
13
|
+
payload = JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new GateInputError("stdin is not JSON");
|
|
17
|
+
}
|
|
18
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload))
|
|
19
|
+
throw new GateInputError("stdin is not a JSON object");
|
|
20
|
+
const fields = payload;
|
|
21
|
+
if (typeof fields.session_id !== "string" || fields.session_id === "")
|
|
22
|
+
throw new GateInputError("session_id is missing");
|
|
23
|
+
if (typeof fields.cwd !== "string" || fields.cwd === "")
|
|
24
|
+
throw new GateInputError("cwd is missing");
|
|
25
|
+
const toolInput = fields.tool_input;
|
|
26
|
+
if (toolInput === null || typeof toolInput !== "object" || Array.isArray(toolInput))
|
|
27
|
+
throw new GateInputError("tool_input is missing");
|
|
28
|
+
return {
|
|
29
|
+
sessionId: fields.session_id,
|
|
30
|
+
cwd: fields.cwd,
|
|
31
|
+
toolInput: toolInput,
|
|
32
|
+
agentType: typeof fields.agent_type === "string" && fields.agent_type !== "" ? fields.agent_type : "main",
|
|
33
|
+
startedAt,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function codeOf(error) {
|
|
37
|
+
const code = error?.code;
|
|
38
|
+
if (typeof code === "string" && code !== "")
|
|
39
|
+
return code;
|
|
40
|
+
if (error instanceof Error)
|
|
41
|
+
return error.constructor.name || error.name;
|
|
42
|
+
return "Error";
|
|
43
|
+
}
|
|
44
|
+
function messageOf(error) {
|
|
45
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
46
|
+
return text.replace(/\s+/g, " ").trim();
|
|
47
|
+
}
|
|
48
|
+
function denialOf(answer) {
|
|
49
|
+
return `${JSON.stringify({
|
|
50
|
+
hookSpecificOutput: {
|
|
51
|
+
hookEventName: "PreToolUse",
|
|
52
|
+
permissionDecision: "deny",
|
|
53
|
+
permissionDecisionReason: answer.reason,
|
|
54
|
+
},
|
|
55
|
+
})}\n`;
|
|
56
|
+
}
|
|
57
|
+
export async function runGateHook(subcommand, io = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env: process.env }) {
|
|
58
|
+
const startedAt = performance.now();
|
|
59
|
+
process.exitCode = 0;
|
|
60
|
+
if (io.stdin.isTTY) {
|
|
61
|
+
io.stderr.write(TTY_NOTICE);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const call = parseCall(await readStdin(io.stdin), startedAt);
|
|
66
|
+
let answer;
|
|
67
|
+
if (subcommand === "read")
|
|
68
|
+
answer = await gateReadCall(call, io.env);
|
|
69
|
+
else if (subcommand === "bash")
|
|
70
|
+
answer = await gateBashCall(call, io.env);
|
|
71
|
+
else
|
|
72
|
+
throw new GateInputError(`unknown hook "${subcommand}"`);
|
|
73
|
+
if (answer.decision === "deny")
|
|
74
|
+
io.stdout.write(denialOf(answer));
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
io.stderr.write(`nustack gate: ${codeOf(error)}: ${messageOf(error)}\n`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { loadCodegraph } from "../graph/library.js";
|
|
3
|
+
export const OUTLINE_MAX_CHARS = 6_000;
|
|
4
|
+
export const OUTLINE_HEAD_LINES = 20;
|
|
5
|
+
const HEAD_LINE_MAX_CHARS = 200;
|
|
6
|
+
const IMPORTS_MAX = 40;
|
|
7
|
+
const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"]);
|
|
8
|
+
const NO_SYMBOL_OUTLINE = "no symbol outline for this file type";
|
|
9
|
+
export async function loadOutlineLibrary() {
|
|
10
|
+
try {
|
|
11
|
+
return (await loadCodegraph());
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function lineCountOf(source) {
|
|
18
|
+
if (source === "")
|
|
19
|
+
return 0;
|
|
20
|
+
const lines = source.split("\n").length;
|
|
21
|
+
return source.endsWith("\n") ? lines - 1 : lines;
|
|
22
|
+
}
|
|
23
|
+
export function markdownOutline(source) {
|
|
24
|
+
const lines = source.split("\n");
|
|
25
|
+
const headings = [];
|
|
26
|
+
let fence = null;
|
|
27
|
+
for (let index = 0; index < lines.length; index++) {
|
|
28
|
+
const line = lines[index];
|
|
29
|
+
const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
|
30
|
+
if (fenceMatch !== null) {
|
|
31
|
+
const marker = fenceMatch[1];
|
|
32
|
+
if (fence === null)
|
|
33
|
+
fence = marker[0];
|
|
34
|
+
else if (marker[0] === fence)
|
|
35
|
+
fence = null;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (fence !== null)
|
|
39
|
+
continue;
|
|
40
|
+
const heading = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
41
|
+
if (heading !== null)
|
|
42
|
+
headings.push({ level: heading[1].length, title: heading[2], line: index + 1 });
|
|
43
|
+
}
|
|
44
|
+
const lastLine = lineCountOf(source);
|
|
45
|
+
return headings.map((heading, index) => {
|
|
46
|
+
const next = headings.slice(index + 1).find((candidate) => candidate.level <= heading.level);
|
|
47
|
+
return { name: heading.title, kind: `h${heading.level}`, startLine: heading.line, endLine: next === undefined ? lastLine : next.line - 1 };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function isSourceOutlineAnswer(value) {
|
|
51
|
+
if (value === null || typeof value !== "object")
|
|
52
|
+
return false;
|
|
53
|
+
const answer = value;
|
|
54
|
+
return Array.isArray(answer.symbols) && Array.isArray(answer.imports) && typeof answer.lineCount === "number";
|
|
55
|
+
}
|
|
56
|
+
function entryLine(entry) {
|
|
57
|
+
const range = entry.endLine === undefined ? `L${entry.startLine}` : `L${entry.startLine}-${entry.endLine}`;
|
|
58
|
+
return `${range} ${entry.kind} ${entry.name}`;
|
|
59
|
+
}
|
|
60
|
+
function headOf(source) {
|
|
61
|
+
return source
|
|
62
|
+
.split("\n")
|
|
63
|
+
.slice(0, OUTLINE_HEAD_LINES)
|
|
64
|
+
.map((line) => (line.length > HEAD_LINE_MAX_CHARS ? `${line.slice(0, HEAD_LINE_MAX_CHARS)}…` : line));
|
|
65
|
+
}
|
|
66
|
+
function waysForward(entries, lineCount, tool) {
|
|
67
|
+
const ranged = entries.filter((entry) => entry.endLine !== undefined);
|
|
68
|
+
const largest = ranged.reduce((best, entry) => (best === undefined || entry.endLine - entry.startLine > best.endLine - best.startLine ? entry : best), undefined);
|
|
69
|
+
const example = largest === undefined
|
|
70
|
+
? `offset=1 limit=${Math.min(lineCount, 200)}`
|
|
71
|
+
: `offset=${largest.startLine} limit=${largest.endLine - largest.startLine + 1} for ${largest.name} (L${largest.startLine}-${largest.endLine})`;
|
|
72
|
+
return [
|
|
73
|
+
"Ways forward:",
|
|
74
|
+
`1. Read only the range you need: Read with offset and limit, e.g. ${example}.`,
|
|
75
|
+
tool === "bash"
|
|
76
|
+
? "2. Re-issue the same full read (this command, or a Read with no offset or limit); it passes this time."
|
|
77
|
+
: "2. Re-issue the same full Read; it passes this time.",
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
export function renderOutline(input, outline) {
|
|
81
|
+
const header = [
|
|
82
|
+
`${input.displayPath}: ${outline.lineCount} lines, about ${input.estimatedTokens} tokens. This full read is answered with an outline instead (NuStack large-read gate).`,
|
|
83
|
+
];
|
|
84
|
+
const importLines = outline.imports.length === 0
|
|
85
|
+
? []
|
|
86
|
+
: [
|
|
87
|
+
`Imports: ${outline.imports.slice(0, IMPORTS_MAX).join(", ")}${outline.imports.length > IMPORTS_MAX ? `, +${outline.imports.length - IMPORTS_MAX} more` : ""}`,
|
|
88
|
+
];
|
|
89
|
+
const head = ["", `First ${OUTLINE_HEAD_LINES} lines:`, ...headOf(input.source)];
|
|
90
|
+
const tail = ["", ...waysForward(outline.entries, outline.lineCount, input.tool)];
|
|
91
|
+
const fixed = (body) => [...header, ...importLines, ...body, ...head, ...tail].join("\n");
|
|
92
|
+
if (!outline.hasOutline)
|
|
93
|
+
return clip(fixed(["", NO_SYMBOL_OUTLINE]));
|
|
94
|
+
const ordered = [...outline.entries].sort((a, b) => a.startLine - b.startLine);
|
|
95
|
+
const shown = ["", `${outline.label}:`];
|
|
96
|
+
let text = fixed(shown);
|
|
97
|
+
for (let index = 0; index < ordered.length; index++) {
|
|
98
|
+
const remaining = ordered.length - index - 1;
|
|
99
|
+
const candidate = [...shown, entryLine(ordered[index])];
|
|
100
|
+
const withMore = remaining > 0 ? [...candidate, `+${remaining} more`] : candidate;
|
|
101
|
+
if (fixed(withMore).length > OUTLINE_MAX_CHARS) {
|
|
102
|
+
shown.push(`+${ordered.length - index} more`);
|
|
103
|
+
return clip(fixed(shown));
|
|
104
|
+
}
|
|
105
|
+
shown.push(entryLine(ordered[index]));
|
|
106
|
+
text = fixed(shown);
|
|
107
|
+
}
|
|
108
|
+
return clip(text);
|
|
109
|
+
}
|
|
110
|
+
function clip(text) {
|
|
111
|
+
return text.length <= OUTLINE_MAX_CHARS ? text : `${text.slice(0, OUTLINE_MAX_CHARS - 1)}…`;
|
|
112
|
+
}
|
|
113
|
+
export async function buildOutline(input, library) {
|
|
114
|
+
const lineCount = input.partialLineCount ?? lineCountOf(input.source);
|
|
115
|
+
if (input.partialLineCount !== undefined) {
|
|
116
|
+
return { text: renderOutline(input, { entries: [], imports: [], lineCount, hasOutline: false, label: "" }), kind: "fallback" };
|
|
117
|
+
}
|
|
118
|
+
if (MARKDOWN_EXTENSIONS.has(path.extname(input.displayPath).toLowerCase())) {
|
|
119
|
+
const entries = markdownOutline(input.source);
|
|
120
|
+
return {
|
|
121
|
+
text: renderOutline(input, { entries, imports: [], lineCount, hasOutline: entries.length > 0, label: "Headings" }),
|
|
122
|
+
kind: entries.length > 0 ? "headings" : "fallback",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (library !== null && typeof library.outlineSource === "function") {
|
|
126
|
+
let answer = null;
|
|
127
|
+
try {
|
|
128
|
+
answer = await library.outlineSource(input.displayPath, input.source);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
}
|
|
132
|
+
if (isSourceOutlineAnswer(answer) && (answer.symbols.length > 0 || answer.imports.length > 0)) {
|
|
133
|
+
return {
|
|
134
|
+
text: renderOutline(input, {
|
|
135
|
+
entries: answer.symbols,
|
|
136
|
+
imports: answer.imports,
|
|
137
|
+
lineCount: answer.lineCount,
|
|
138
|
+
hasOutline: true,
|
|
139
|
+
label: "Symbols",
|
|
140
|
+
}),
|
|
141
|
+
kind: "symbols",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { text: renderOutline(input, { entries: [], imports: [], lineCount, hasOutline: false, label: "" }), kind: "fallback" };
|
|
146
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, openSync, readFileSync, readSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
5
|
+
import { assessBashRead, classifyBashRead } from "./bashRead.js";
|
|
6
|
+
import { assessFullRead, readLeadingLines, regularFileSize, tokensOfLength } from "./estimate.js";
|
|
7
|
+
import { GATE_MIN_THRESHOLD_TOKENS, isGateOff, resolveThreshold } from "./settings.js";
|
|
8
|
+
export class GateInputError extends Error {
|
|
9
|
+
code = "HOOK_INPUT_INVALID";
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "GateInputError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const PASS = { decision: "pass" };
|
|
16
|
+
const OUTLINE_SOURCE_MAX_BYTES = 8 * 1024 * 1024;
|
|
17
|
+
const OUTLINE_HEAD_SCAN_LINES = 20;
|
|
18
|
+
function resolvePath(cwd, file) {
|
|
19
|
+
return path.isAbsolute(file) ? file : path.resolve(cwd, file);
|
|
20
|
+
}
|
|
21
|
+
function realPathOf(file) {
|
|
22
|
+
try {
|
|
23
|
+
return realpathSync(file);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function gateReadCall(call, env = process.env) {
|
|
30
|
+
if (isGateOff(env))
|
|
31
|
+
return PASS;
|
|
32
|
+
const filePath = call.toolInput.file_path;
|
|
33
|
+
if (typeof filePath !== "string" || filePath === "")
|
|
34
|
+
throw new GateInputError("tool_input.file_path is missing");
|
|
35
|
+
if (call.toolInput.offset != null || call.toolInput.limit != null)
|
|
36
|
+
return PASS;
|
|
37
|
+
const resolved = resolvePath(call.cwd, filePath);
|
|
38
|
+
const assessment = assessFullRead(resolved, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS);
|
|
39
|
+
return answerAssessment(call, resolved, assessment, "read");
|
|
40
|
+
}
|
|
41
|
+
export async function gateBashCall(call, env = process.env) {
|
|
42
|
+
if (isGateOff(env))
|
|
43
|
+
return PASS;
|
|
44
|
+
const command = call.toolInput.command;
|
|
45
|
+
if (typeof command !== "string")
|
|
46
|
+
throw new GateInputError("tool_input.command is missing");
|
|
47
|
+
const read = classifyBashRead(command);
|
|
48
|
+
if (read === null)
|
|
49
|
+
return PASS;
|
|
50
|
+
const resolved = resolvePath(call.cwd, read.path);
|
|
51
|
+
const assessment = assessBashRead(resolved, read.range, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS, env);
|
|
52
|
+
return answerAssessment(call, resolved, assessment, "bash");
|
|
53
|
+
}
|
|
54
|
+
async function answerAssessment(call, resolved, assessment, tool) {
|
|
55
|
+
if (assessment.decision === "pass")
|
|
56
|
+
return PASS;
|
|
57
|
+
return answerLargeRead(call, resolved, assessment.estimatedTokens, tool);
|
|
58
|
+
}
|
|
59
|
+
async function answerLargeRead(call, resolved, estimatedTokens, tool) {
|
|
60
|
+
const [{ openLocalStore, withTransaction }, routing, outline] = await Promise.all([
|
|
61
|
+
import("../local_store/database.js"),
|
|
62
|
+
import("../local_store/routing.js"),
|
|
63
|
+
import("./outline.js"),
|
|
64
|
+
]);
|
|
65
|
+
const realPath = realPathOf(resolved);
|
|
66
|
+
const store = await openLocalStore();
|
|
67
|
+
try {
|
|
68
|
+
let denial = null;
|
|
69
|
+
if (!routing.isPathGated(store, call.sessionId, realPath)) {
|
|
70
|
+
const { source, partialLineCount } = readOutlineSource(realPath);
|
|
71
|
+
const library = partialLineCount === undefined ? await outline.loadOutlineLibrary() : null;
|
|
72
|
+
const rendered = await outline.buildOutline({ displayPath: resolved, source, estimatedTokens, tool, partialLineCount }, library);
|
|
73
|
+
denial = rendered.text;
|
|
74
|
+
}
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const isDenied = withTransaction(store, () => {
|
|
77
|
+
const isInserted = routing.gatePathOnce(store, call.sessionId, realPath, now);
|
|
78
|
+
const reason = isInserted ? (denial ?? fallbackDenial(resolved, estimatedTokens)) : null;
|
|
79
|
+
const returnedTokens = reason === null ? estimatedTokens : tokensOfLength(reason.length);
|
|
80
|
+
routing.insertLabelRow(store, {
|
|
81
|
+
id: `lr_${randomUUID()}`,
|
|
82
|
+
session_id: call.sessionId,
|
|
83
|
+
agent_type: call.agentType,
|
|
84
|
+
path: realPath,
|
|
85
|
+
estimated_tokens: estimatedTokens,
|
|
86
|
+
returned_tokens: returnedTokens,
|
|
87
|
+
handler: isInserted ? "outline" : "bypass",
|
|
88
|
+
check_result: null,
|
|
89
|
+
bypass_used: isInserted ? 0 : 1,
|
|
90
|
+
latency_ms: Math.max(0, Math.round(performance.now() - call.startedAt)),
|
|
91
|
+
tokens_kept_out: isInserted ? estimatedTokens - returnedTokens : -estimatedTokens,
|
|
92
|
+
created_at: now,
|
|
93
|
+
});
|
|
94
|
+
denial = reason;
|
|
95
|
+
return isInserted;
|
|
96
|
+
});
|
|
97
|
+
return isDenied && denial !== null ? { decision: "deny", reason: denial } : PASS;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
store.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function readOutlineSource(file) {
|
|
104
|
+
const size = regularFileSize(file) ?? 0;
|
|
105
|
+
if (size <= OUTLINE_SOURCE_MAX_BYTES)
|
|
106
|
+
return { source: readFileSync(file, "utf8") };
|
|
107
|
+
const head = readLeadingLines(file, OUTLINE_HEAD_SCAN_LINES);
|
|
108
|
+
const descriptor = openSync(file, "r");
|
|
109
|
+
try {
|
|
110
|
+
const buffer = Buffer.alloc(head.bytes);
|
|
111
|
+
readSync(descriptor, buffer, 0, head.bytes, 0);
|
|
112
|
+
return { source: buffer.toString("utf8"), partialLineCount: readLeadingLines(file, Number.POSITIVE_INFINITY).lines };
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
closeSync(descriptor);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function fallbackDenial(resolved, estimatedTokens) {
|
|
119
|
+
return [
|
|
120
|
+
`${resolved}: about ${estimatedTokens} tokens. This full read is answered without its content (NuStack large-read gate).`,
|
|
121
|
+
"",
|
|
122
|
+
"Ways forward:",
|
|
123
|
+
"1. Read only the range you need: Read with offset and limit.",
|
|
124
|
+
"2. Re-issue the same full read; it passes this time.",
|
|
125
|
+
].join("\n");
|
|
126
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { GATE_THRESHOLD_TOKENS_MAX, GATE_THRESHOLD_TOKENS_MIN, readDesktopSettings } from "../desktop_settings.js";
|
|
2
|
+
export const NUSTACK_GATE_ENV = "NUSTACK_GATE";
|
|
3
|
+
export const NUSTACK_GATE_THRESHOLD_ENV = "NUSTACK_GATE_THRESHOLD_TOKENS";
|
|
4
|
+
export const GATE_DEFAULT_THRESHOLD_TOKENS = 4_000;
|
|
5
|
+
export const GATE_MIN_THRESHOLD_TOKENS = GATE_THRESHOLD_TOKENS_MIN;
|
|
6
|
+
export function isGateOff(env = process.env) {
|
|
7
|
+
return env[NUSTACK_GATE_ENV] === "off";
|
|
8
|
+
}
|
|
9
|
+
function envThreshold(env) {
|
|
10
|
+
const text = env[NUSTACK_GATE_THRESHOLD_ENV]?.trim();
|
|
11
|
+
if (text === undefined || !/^\d+$/.test(text))
|
|
12
|
+
return null;
|
|
13
|
+
const value = Number.parseInt(text, 10);
|
|
14
|
+
return value >= GATE_THRESHOLD_TOKENS_MIN && value <= GATE_THRESHOLD_TOKENS_MAX ? value : null;
|
|
15
|
+
}
|
|
16
|
+
export function resolveThreshold(env = process.env, readSettings = readDesktopSettings) {
|
|
17
|
+
const fromEnv = envThreshold(env);
|
|
18
|
+
if (fromEnv !== null)
|
|
19
|
+
return { thresholdTokens: fromEnv, thresholdSource: "env" };
|
|
20
|
+
const fromSettings = readSettings().routing.gateThresholdTokens;
|
|
21
|
+
if (fromSettings !== null)
|
|
22
|
+
return { thresholdTokens: fromSettings, thresholdSource: "settings" };
|
|
23
|
+
return { thresholdTokens: GATE_DEFAULT_THRESHOLD_TOKENS, thresholdSource: "default" };
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const HOOK_SUBCOMMANDS = ["read", "bash"];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { chmod, open } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { mkdirSecure } from "../secure_file.js";
|
|
4
5
|
import { stateDbPath } from "./paths.js";
|
|
@@ -53,6 +54,46 @@ export async function openLocalStore(options = {}) {
|
|
|
53
54
|
},
|
|
54
55
|
};
|
|
55
56
|
}
|
|
57
|
+
export async function readStoreVersion(dbFile = stateDbPath()) {
|
|
58
|
+
if (!existsSync(dbFile))
|
|
59
|
+
return null;
|
|
60
|
+
let db;
|
|
61
|
+
try {
|
|
62
|
+
db = await withSuppressedSqliteWarning(async () => {
|
|
63
|
+
const { DatabaseSync } = await loadSqlite();
|
|
64
|
+
return new DatabaseSync(dbFile, { readOnly: true });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return headerUserVersion(dbFile);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const row = db.prepare("PRAGMA user_version").get();
|
|
72
|
+
return typeof row?.user_version === "number" ? row.user_version : null;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return headerUserVersion(dbFile);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
db.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function headerUserVersion(dbFile) {
|
|
82
|
+
try {
|
|
83
|
+
const handle = await open(dbFile, "r");
|
|
84
|
+
try {
|
|
85
|
+
const header = Buffer.alloc(64);
|
|
86
|
+
const { bytesRead } = await handle.read(header, 0, 64, 0);
|
|
87
|
+
return bytesRead === 64 ? header.readInt32BE(60) : null;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await handle.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
56
97
|
export function withTransaction(store, fn) {
|
|
57
98
|
const { db } = store;
|
|
58
99
|
db.exec("BEGIN IMMEDIATE");
|
|
@@ -309,6 +309,41 @@ const MIGRATION_V9 = {
|
|
|
309
309
|
`DROP TABLE record_store`,
|
|
310
310
|
],
|
|
311
311
|
};
|
|
312
|
+
const MIGRATION_V10 = {
|
|
313
|
+
version: 10,
|
|
314
|
+
statements: [
|
|
315
|
+
`CREATE TABLE routing_latch (
|
|
316
|
+
session_id TEXT,
|
|
317
|
+
job_kind TEXT,
|
|
318
|
+
failures INTEGER NOT NULL DEFAULT 0,
|
|
319
|
+
budget_misses INTEGER NOT NULL DEFAULT 0,
|
|
320
|
+
latched_at INTEGER,
|
|
321
|
+
PRIMARY KEY (session_id, job_kind)
|
|
322
|
+
)`,
|
|
323
|
+
`CREATE TABLE label_rows (
|
|
324
|
+
id TEXT PRIMARY KEY,
|
|
325
|
+
session_id TEXT,
|
|
326
|
+
agent_type TEXT,
|
|
327
|
+
path TEXT,
|
|
328
|
+
estimated_tokens INTEGER,
|
|
329
|
+
returned_tokens INTEGER,
|
|
330
|
+
handler TEXT,
|
|
331
|
+
check_result TEXT,
|
|
332
|
+
bypass_used INTEGER,
|
|
333
|
+
latency_ms INTEGER,
|
|
334
|
+
tokens_kept_out INTEGER,
|
|
335
|
+
created_at INTEGER,
|
|
336
|
+
sync_state TEXT NOT NULL DEFAULT 'pending'
|
|
337
|
+
)`,
|
|
338
|
+
`CREATE INDEX label_rows_sync_state_created_at_idx ON label_rows (sync_state, created_at)`,
|
|
339
|
+
`CREATE TABLE gated_paths (
|
|
340
|
+
session_id TEXT,
|
|
341
|
+
path TEXT,
|
|
342
|
+
gated_at INTEGER,
|
|
343
|
+
PRIMARY KEY (session_id, path)
|
|
344
|
+
)`,
|
|
345
|
+
],
|
|
346
|
+
};
|
|
312
347
|
export const MIGRATIONS = [
|
|
313
348
|
MIGRATION_V1,
|
|
314
349
|
MIGRATION_V2,
|
|
@@ -319,6 +354,7 @@ export const MIGRATIONS = [
|
|
|
319
354
|
MIGRATION_V7,
|
|
320
355
|
MIGRATION_V8,
|
|
321
356
|
MIGRATION_V9,
|
|
357
|
+
MIGRATION_V10,
|
|
322
358
|
];
|
|
323
359
|
export const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
324
360
|
export class LocalSchemaTooNewError extends Error {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
export const ROUTING_LATCH_LIMIT = 2;
|
|
2
|
+
export const ROUTING_JOB_KINDS = ["read.bulk", "lane.subagent"];
|
|
3
|
+
export const LABEL_ROW_HANDLERS = [
|
|
4
|
+
"outline",
|
|
5
|
+
"bypass",
|
|
6
|
+
"digest",
|
|
7
|
+
"full_read",
|
|
8
|
+
"local_model",
|
|
9
|
+
"redirect",
|
|
10
|
+
"pack_hit",
|
|
11
|
+
"pack_miss",
|
|
12
|
+
];
|
|
13
|
+
export const LABEL_ROW_CHECK_RESULTS = ["pass", "fail", "budget", "unavailable", "latched"];
|
|
14
|
+
export const LABEL_ROW_ID_MAX = 128;
|
|
15
|
+
export const LABEL_ROW_PATH_MAX = 512;
|
|
16
|
+
export const LABEL_ROW_SYNC_STATES = ["pending", "synced", "refused", "local"];
|
|
17
|
+
export const GATED_PATH_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
18
|
+
export class LabelRowRefusedError extends Error {
|
|
19
|
+
code = "LABEL_ROW_REFUSED";
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "LabelRowRefusedError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function isOneOf(values, value) {
|
|
26
|
+
return typeof value === "string" && values.includes(value);
|
|
27
|
+
}
|
|
28
|
+
function requireIdentifier(field, value) {
|
|
29
|
+
if (typeof value !== "string" || value.length < 1 || value.length > LABEL_ROW_ID_MAX) {
|
|
30
|
+
throw new LabelRowRefusedError(`${field} must be 1-${LABEL_ROW_ID_MAX} characters`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function requireInteger(field, value, isSigned) {
|
|
34
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || (!isSigned && value < 0)) {
|
|
35
|
+
throw new LabelRowRefusedError(`${field} must be a${isSigned ? "n" : " non-negative"} integer`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function clipLabelRowPath(path) {
|
|
39
|
+
return path.length <= LABEL_ROW_PATH_MAX ? path : `…${path.slice(path.length - (LABEL_ROW_PATH_MAX - 1))}`;
|
|
40
|
+
}
|
|
41
|
+
export function insertLabelRow(store, row, syncState = "pending") {
|
|
42
|
+
requireIdentifier("id", row.id);
|
|
43
|
+
requireIdentifier("session_id", row.session_id);
|
|
44
|
+
requireIdentifier("agent_type", row.agent_type);
|
|
45
|
+
if (!isOneOf(LABEL_ROW_HANDLERS, row.handler))
|
|
46
|
+
throw new LabelRowRefusedError(`unknown handler "${String(row.handler)}"`);
|
|
47
|
+
if (row.check_result !== null && !isOneOf(LABEL_ROW_CHECK_RESULTS, row.check_result)) {
|
|
48
|
+
throw new LabelRowRefusedError(`unknown check result "${String(row.check_result)}"`);
|
|
49
|
+
}
|
|
50
|
+
if (!isOneOf(LABEL_ROW_SYNC_STATES, syncState))
|
|
51
|
+
throw new LabelRowRefusedError(`unknown sync state "${String(syncState)}"`);
|
|
52
|
+
if (row.bypass_used !== 0 && row.bypass_used !== 1)
|
|
53
|
+
throw new LabelRowRefusedError("bypass_used must be 0 or 1");
|
|
54
|
+
if (row.path !== null && typeof row.path !== "string")
|
|
55
|
+
throw new LabelRowRefusedError("path must be a string or null");
|
|
56
|
+
requireInteger("estimated_tokens", row.estimated_tokens, false);
|
|
57
|
+
requireInteger("returned_tokens", row.returned_tokens, false);
|
|
58
|
+
requireInteger("latency_ms", row.latency_ms, false);
|
|
59
|
+
requireInteger("tokens_kept_out", row.tokens_kept_out, true);
|
|
60
|
+
requireInteger("created_at", row.created_at, false);
|
|
61
|
+
store.db
|
|
62
|
+
.prepare(`INSERT INTO label_rows (id, session_id, agent_type, path, estimated_tokens, returned_tokens, handler,
|
|
63
|
+
check_result, bypass_used, latency_ms, tokens_kept_out, created_at, sync_state)
|
|
64
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
65
|
+
.run(row.id, row.session_id, row.agent_type, row.path === null ? null : clipLabelRowPath(row.path), row.estimated_tokens, row.returned_tokens, row.handler, row.check_result, row.bypass_used, row.latency_ms, row.tokens_kept_out, row.created_at, syncState);
|
|
66
|
+
}
|
|
67
|
+
export function labelRowWire(record) {
|
|
68
|
+
return {
|
|
69
|
+
id: record.id,
|
|
70
|
+
sessionId: record.session_id,
|
|
71
|
+
agentType: record.agent_type,
|
|
72
|
+
path: record.path,
|
|
73
|
+
estimatedTokens: record.estimated_tokens,
|
|
74
|
+
returnedTokens: record.returned_tokens,
|
|
75
|
+
handler: record.handler,
|
|
76
|
+
checkResult: record.check_result,
|
|
77
|
+
isBypassUsed: record.bypass_used === 1,
|
|
78
|
+
latencyMs: record.latency_ms,
|
|
79
|
+
tokensKeptOut: record.tokens_kept_out,
|
|
80
|
+
createdAt: record.created_at,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function listPendingLabelRows(store, limit) {
|
|
84
|
+
return store.db
|
|
85
|
+
.prepare("SELECT * FROM label_rows WHERE sync_state = 'pending' ORDER BY created_at, id LIMIT ?")
|
|
86
|
+
.all(limit);
|
|
87
|
+
}
|
|
88
|
+
function markPending(store, ids, state) {
|
|
89
|
+
if (ids.length === 0)
|
|
90
|
+
return 0;
|
|
91
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
92
|
+
const result = store.db
|
|
93
|
+
.prepare(`UPDATE label_rows SET sync_state = ? WHERE sync_state = 'pending' AND id IN (${placeholders})`)
|
|
94
|
+
.run(state, ...ids);
|
|
95
|
+
return Number(result.changes);
|
|
96
|
+
}
|
|
97
|
+
export function markLabelRowsSynced(store, ids) {
|
|
98
|
+
return markPending(store, ids, "synced");
|
|
99
|
+
}
|
|
100
|
+
export function markLabelRowsRefused(store, ids) {
|
|
101
|
+
return markPending(store, ids, "refused");
|
|
102
|
+
}
|
|
103
|
+
export function isLatched(store, sessionId, jobKind) {
|
|
104
|
+
const row = store.db
|
|
105
|
+
.prepare("SELECT latched_at FROM routing_latch WHERE session_id = ? AND job_kind = ?")
|
|
106
|
+
.get(sessionId, jobKind);
|
|
107
|
+
return row?.latched_at != null;
|
|
108
|
+
}
|
|
109
|
+
export function recordRoutingMiss(store, sessionId, jobKind, miss, now = Date.now()) {
|
|
110
|
+
if (!isOneOf(ROUTING_JOB_KINDS, jobKind))
|
|
111
|
+
throw new Error(`unknown routing job kind "${String(jobKind)}"`);
|
|
112
|
+
const failures = miss === "failure" ? 1 : 0;
|
|
113
|
+
const budgetMisses = miss === "budget" ? 1 : 0;
|
|
114
|
+
store.db
|
|
115
|
+
.prepare(`INSERT INTO routing_latch (session_id, job_kind, failures, budget_misses, latched_at)
|
|
116
|
+
VALUES (?, ?, ?, ?, NULL)
|
|
117
|
+
ON CONFLICT (session_id, job_kind) DO UPDATE SET
|
|
118
|
+
failures = failures + excluded.failures,
|
|
119
|
+
budget_misses = budget_misses + excluded.budget_misses`)
|
|
120
|
+
.run(sessionId, jobKind, failures, budgetMisses);
|
|
121
|
+
store.db
|
|
122
|
+
.prepare(`UPDATE routing_latch SET latched_at = ?
|
|
123
|
+
WHERE session_id = ? AND job_kind = ? AND latched_at IS NULL AND failures + budget_misses >= ?`)
|
|
124
|
+
.run(now, sessionId, jobKind, ROUTING_LATCH_LIMIT);
|
|
125
|
+
}
|
|
126
|
+
export function isPathGated(store, sessionId, path) {
|
|
127
|
+
return store.db.prepare("SELECT 1 FROM gated_paths WHERE session_id = ? AND path = ?").get(sessionId, path) !== undefined;
|
|
128
|
+
}
|
|
129
|
+
export function gatePathOnce(store, sessionId, path, now = Date.now()) {
|
|
130
|
+
const result = store.db
|
|
131
|
+
.prepare("INSERT INTO gated_paths (session_id, path, gated_at) VALUES (?, ?, ?) ON CONFLICT (session_id, path) DO NOTHING")
|
|
132
|
+
.run(sessionId, path, now);
|
|
133
|
+
const isInserted = Number(result.changes) === 1;
|
|
134
|
+
if (isInserted)
|
|
135
|
+
store.db.prepare("DELETE FROM gated_paths WHERE gated_at < ?").run(now - GATED_PATH_RETENTION_MS);
|
|
136
|
+
return isInserted;
|
|
137
|
+
}
|
|
@@ -21,8 +21,10 @@ const KEYS_PLANE = "the credential plane (/api/v1/keys)";
|
|
|
21
21
|
export function apiError(payload, status, label, plane) {
|
|
22
22
|
if (status === 401 && !envToken())
|
|
23
23
|
return Object.assign(new Error(deadCredentialCopy("revoked")), { code: "SIGNED_OUT" });
|
|
24
|
-
const
|
|
25
|
-
|
|
24
|
+
const envelope = typeof payload?.error === "object" ? payload.error : undefined;
|
|
25
|
+
const code = typeof envelope?.code === "string" ? envelope.code : "UNEXPECTED";
|
|
26
|
+
const details = envelope?.details && typeof envelope.details === "object" && !Array.isArray(envelope.details) ? envelope.details : undefined;
|
|
27
|
+
return Object.assign(new Error(errorMessage(payload, status, label, plane)), details ? { code, details } : { code });
|
|
26
28
|
}
|
|
27
29
|
export function errorMessage(payload, status, label, plane) {
|
|
28
30
|
if (plane && status === 404 && typeof payload?.error === "string") {
|