@nurix/nustack 0.23.9 → 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.
@@ -0,0 +1,90 @@
1
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ export const GATE_BYTES_PER_TOKEN = 4;
4
+ export const READ_DEFAULT_LINE_LIMIT = 2_000;
5
+ export const READ_LINE_PREFIX_BYTES = 7;
6
+ export const BINARY_PROBE_BYTES = 8_192;
7
+ export const GATE_SKIPPED_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "pdf", "ipynb"];
8
+ const READ_CHUNK_BYTES = 64 * 1024;
9
+ export function tokensOfLength(length) {
10
+ return Math.ceil(length / GATE_BYTES_PER_TOKEN);
11
+ }
12
+ export function isSkippedExtension(file) {
13
+ return GATE_SKIPPED_EXTENSIONS.includes(path.extname(file).slice(1).toLowerCase());
14
+ }
15
+ export function regularFileSize(file) {
16
+ try {
17
+ const stats = statSync(file);
18
+ return stats.isFile() ? stats.size : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function couldReachThreshold(sizeBytes, thresholdTokens) {
25
+ return sizeBytes / GATE_BYTES_PER_TOKEN >= thresholdTokens / 2;
26
+ }
27
+ export function readLeadingLines(file, maxLines) {
28
+ const descriptor = openSync(file, "r");
29
+ try {
30
+ const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
31
+ let offset = 0;
32
+ let lines = 0;
33
+ let isBinary = false;
34
+ let bytesInOpenLine = 0;
35
+ while (lines < maxLines) {
36
+ const read = readSync(descriptor, chunk, 0, READ_CHUNK_BYTES, offset);
37
+ if (read === 0)
38
+ break;
39
+ if (offset < BINARY_PROBE_BYTES && chunk.subarray(0, Math.min(read, BINARY_PROBE_BYTES - offset)).includes(0)) {
40
+ isBinary = true;
41
+ }
42
+ let cursor = 0;
43
+ while (lines < maxLines) {
44
+ const newline = chunk.indexOf(10, cursor);
45
+ if (newline === -1 || newline >= read) {
46
+ bytesInOpenLine += read - cursor;
47
+ break;
48
+ }
49
+ lines++;
50
+ bytesInOpenLine = 0;
51
+ cursor = newline + 1;
52
+ if (lines === maxLines) {
53
+ offset += cursor;
54
+ return { bytes: offset, lines, isBinary };
55
+ }
56
+ }
57
+ offset += read;
58
+ }
59
+ if (bytesInOpenLine > 0 && lines < maxLines)
60
+ lines++;
61
+ return { bytes: offset, lines, isBinary };
62
+ }
63
+ finally {
64
+ closeSync(descriptor);
65
+ }
66
+ }
67
+ export function estimateReadTokens(scan) {
68
+ return tokensOfLength(scan.bytes + READ_LINE_PREFIX_BYTES * scan.lines);
69
+ }
70
+ export function assessFullRead(file, thresholdTokens, minimumThresholdTokens) {
71
+ const size = regularFileSize(file);
72
+ if (size === null)
73
+ return { decision: "pass", reason: "not-a-file", source: "stat", estimatedTokens: null };
74
+ if (isSkippedExtension(file))
75
+ return { decision: "pass", reason: "skipped-extension", source: "stat", estimatedTokens: null };
76
+ if (!couldReachThreshold(size, minimumThresholdTokens)) {
77
+ return { decision: "pass", reason: "under-threshold", source: "stat", estimatedTokens: null };
78
+ }
79
+ const threshold = thresholdTokens();
80
+ if (!couldReachThreshold(size, threshold)) {
81
+ return { decision: "pass", reason: "under-threshold", source: "stat", estimatedTokens: null };
82
+ }
83
+ const scan = readLeadingLines(file, READ_DEFAULT_LINE_LIMIT);
84
+ if (scan.isBinary)
85
+ return { decision: "pass", reason: "binary", source: "read", estimatedTokens: null };
86
+ const estimatedTokens = estimateReadTokens(scan);
87
+ if (estimatedTokens < threshold)
88
+ return { decision: "pass", reason: "under-threshold", source: "read", estimatedTokens };
89
+ return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: threshold };
90
+ }
@@ -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 { chmod } from "node:fs/promises";
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");
@@ -12,3 +12,4 @@ export * from "./history_decisions.js";
12
12
  export * from "./accepted_uploads.js";
13
13
  export * from "./project_events_cache.js";
14
14
  export * from "./filebeat_state.js";
15
+ export * from "./routing.js";
@@ -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 {