@nurix/nustack 0.23.21 → 0.23.23
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/context.js +78 -0
- package/dist/commands/gate.js +7 -1
- package/dist/index.js +5 -0
- package/dist/lib/context/compose.js +269 -0
- package/dist/lib/context/editCard.js +31 -0
- package/dist/lib/context/graph.js +107 -0
- package/dist/lib/context/hook.js +287 -0
- package/dist/lib/context/recall.js +26 -0
- package/dist/lib/context/register.js +211 -0
- package/dist/lib/context/render.js +103 -0
- package/dist/lib/context/resolve.js +104 -0
- package/dist/lib/context/writers.js +58 -0
- package/dist/lib/gate/bashRead.js +13 -0
- package/dist/lib/gate/estimate.js +14 -0
- package/dist/lib/gate/hook.js +6 -2
- package/dist/lib/gate/readGate.js +22 -10
- package/dist/lib/gate/redirect.js +61 -0
- package/dist/lib/gate/settings.js +22 -0
- package/dist/lib/graph/projectRoot.js +39 -0
- package/dist/lib/local_store/paths.js +3 -0
- package/dist/program.js +14 -0
- package/package.json +3 -3
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const CONTEXT_MAX_OBJECTS = 3;
|
|
4
|
+
const SOURCE_EXTENSION = /\.(ts|tsx|js|jsx|mjs|cjs|json|md|mdx|sql|py|rs|go|yaml|yml|toml|css|html|sh)$/i;
|
|
5
|
+
export function isIdentifierShaped(token) {
|
|
6
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*[A-Za-z0-9]$/.test(token))
|
|
7
|
+
return false;
|
|
8
|
+
if (/[a-z][A-Z]/.test(token))
|
|
9
|
+
return true;
|
|
10
|
+
if (/[A-Za-z0-9]_[A-Za-z0-9]/.test(token))
|
|
11
|
+
return true;
|
|
12
|
+
if (/[a-z0-9]-[a-z0-9]/i.test(token))
|
|
13
|
+
return true;
|
|
14
|
+
return /^[A-Z][A-Z0-9_]+$/.test(token) && token.length >= 2;
|
|
15
|
+
}
|
|
16
|
+
function isPathShaped(token) {
|
|
17
|
+
return (token.includes("/") && /[A-Za-z]/.test(token)) || SOURCE_EXTENSION.test(token);
|
|
18
|
+
}
|
|
19
|
+
export function extractTerms(text) {
|
|
20
|
+
const paths = [];
|
|
21
|
+
const identifiers = [];
|
|
22
|
+
const add = (list, term) => {
|
|
23
|
+
if (term !== "" && !list.includes(term))
|
|
24
|
+
list.push(term);
|
|
25
|
+
};
|
|
26
|
+
const classify = (raw, isBackticked) => {
|
|
27
|
+
const token = raw.replace(/^[("'[{<]+|[)"'\]}>,;:!?.]+$/g, "").replace(/'s$/, "");
|
|
28
|
+
if (token === "")
|
|
29
|
+
return;
|
|
30
|
+
if (isPathShaped(token))
|
|
31
|
+
add(paths, token.replace(/^\.\//, ""));
|
|
32
|
+
else if (isIdentifierShaped(token))
|
|
33
|
+
add(identifiers, token);
|
|
34
|
+
else if (isBackticked && /^[A-Za-z_][A-Za-z0-9_]*$/.test(token))
|
|
35
|
+
add(identifiers, token);
|
|
36
|
+
};
|
|
37
|
+
for (const match of text.matchAll(/`([^`]+)`/g)) {
|
|
38
|
+
const span = (match[1] ?? "").trim();
|
|
39
|
+
if (/\s/.test(span))
|
|
40
|
+
span.split(/\s+/).forEach((part) => classify(part, true));
|
|
41
|
+
else
|
|
42
|
+
classify(span, true);
|
|
43
|
+
}
|
|
44
|
+
for (const token of text.replace(/`[^`]*`/g, " ").split(/\s+/))
|
|
45
|
+
classify(token, false);
|
|
46
|
+
return { paths, identifiers };
|
|
47
|
+
}
|
|
48
|
+
function escapeRegExp(text) {
|
|
49
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
50
|
+
}
|
|
51
|
+
export function mentions(text, phrase) {
|
|
52
|
+
const body = escapeRegExp(phrase.trim()).replace(/\s+/g, "\\s+");
|
|
53
|
+
if (body === "")
|
|
54
|
+
return false;
|
|
55
|
+
return new RegExp(`(?:^|[^A-Za-z0-9_-])${body}(?:e?s)?(?=$|[^A-Za-z0-9_-])`, "i").test(text);
|
|
56
|
+
}
|
|
57
|
+
export function resolvePaths(root, cwd, candidates) {
|
|
58
|
+
const resolved = [];
|
|
59
|
+
const missing = [];
|
|
60
|
+
for (const candidate of candidates) {
|
|
61
|
+
const fromRoot = path.resolve(root, candidate);
|
|
62
|
+
const absolute = existsSync(fromRoot) ? fromRoot : path.resolve(cwd, candidate);
|
|
63
|
+
const relative = path.relative(root, absolute).split(path.sep).join("/");
|
|
64
|
+
if (!existsSync(absolute) || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
65
|
+
missing.push(candidate);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (relative === "" || resolved.some((entry) => entry.path === relative))
|
|
69
|
+
continue;
|
|
70
|
+
resolved.push({ path: relative, isFile: statSync(absolute).isFile() });
|
|
71
|
+
}
|
|
72
|
+
return { resolved, missing };
|
|
73
|
+
}
|
|
74
|
+
export function resolveObjects(objects, text, identifiers, pathSites) {
|
|
75
|
+
const lowered = new Set(identifiers.map((term) => term.toLowerCase()));
|
|
76
|
+
const ranked = [];
|
|
77
|
+
objects.forEach((object, order) => {
|
|
78
|
+
const via = [];
|
|
79
|
+
for (const site of pathSites) {
|
|
80
|
+
if (object.tables.includes(site.table)) {
|
|
81
|
+
const entry = `path ${site.path} (writes ${site.table} at line ${site.line})`;
|
|
82
|
+
if (!via.some((existing) => existing.startsWith(`path ${site.path} `)))
|
|
83
|
+
via.push(entry);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const table of object.tables) {
|
|
87
|
+
if (lowered.has(table.toLowerCase()) || mentions(text, table))
|
|
88
|
+
via.push(`table ${table}`);
|
|
89
|
+
}
|
|
90
|
+
if (mentions(text, object.name))
|
|
91
|
+
via.push(`name "${object.name}"`);
|
|
92
|
+
for (const alias of object.aliases) {
|
|
93
|
+
if (alias.toLowerCase() !== object.name.toLowerCase() && mentions(text, alias))
|
|
94
|
+
via.push(`alias "${alias}"`);
|
|
95
|
+
}
|
|
96
|
+
if (via.length > 0)
|
|
97
|
+
ranked.push({ object, via, order });
|
|
98
|
+
});
|
|
99
|
+
ranked.sort((left, right) => right.via.length - left.via.length || left.order - right.order);
|
|
100
|
+
return ranked.slice(0, CONTEXT_MAX_OBJECTS).map(({ object, via }) => ({ object, via }));
|
|
101
|
+
}
|
|
102
|
+
export function isRegisterTerm(objects, term) {
|
|
103
|
+
return objects.some((object) => [object.name, ...object.aliases, ...object.tables].some((name) => mentions(term, name) && mentions(name, term)));
|
|
104
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const WRITE_SITE_CANDIDATE_FILES = 20;
|
|
4
|
+
export function camelCaseOf(table) {
|
|
5
|
+
return table.replace(/[_-]+([a-z0-9])/g, (_match, letter) => letter.toUpperCase());
|
|
6
|
+
}
|
|
7
|
+
function escapeRegExp(text) {
|
|
8
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9
|
+
}
|
|
10
|
+
export function tableSymbols(table, graph) {
|
|
11
|
+
const camel = camelCaseOf(table);
|
|
12
|
+
const candidates = camel === table ? [table] : [camel, table];
|
|
13
|
+
if (graph === null)
|
|
14
|
+
return [camel];
|
|
15
|
+
const confirmed = candidates.find((symbol) => graph.isDefined(symbol) || graph.importers(symbol, 1).length > 0);
|
|
16
|
+
return confirmed === undefined ? [] : [confirmed];
|
|
17
|
+
}
|
|
18
|
+
export function writePatterns(table, symbols) {
|
|
19
|
+
const patterns = symbols.map((symbol) => new RegExp(`\\.(insert|update|delete)\\(\\s*(?:\\w+\\.)?${escapeRegExp(symbol)}\\b`));
|
|
20
|
+
patterns.push(new RegExp(`(INSERT\\s+INTO|UPDATE|DELETE\\s+FROM)\\s+["'\`]?${escapeRegExp(table)}\\b`));
|
|
21
|
+
return patterns;
|
|
22
|
+
}
|
|
23
|
+
function verbOf(match) {
|
|
24
|
+
const lowered = match.toLowerCase();
|
|
25
|
+
if (lowered.includes("insert"))
|
|
26
|
+
return "insert";
|
|
27
|
+
if (lowered.includes("delete"))
|
|
28
|
+
return "delete";
|
|
29
|
+
return "update";
|
|
30
|
+
}
|
|
31
|
+
export function scanWriteSites(root, files, table, symbols) {
|
|
32
|
+
const patterns = writePatterns(table, symbols);
|
|
33
|
+
const sites = [];
|
|
34
|
+
for (const file of [...new Set(files)]) {
|
|
35
|
+
let text;
|
|
36
|
+
try {
|
|
37
|
+
text = readFileSync(path.join(root, file), "utf8");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
text.split("\n").forEach((line, index) => {
|
|
43
|
+
for (const pattern of patterns) {
|
|
44
|
+
const match = pattern.exec(line);
|
|
45
|
+
if (match !== null) {
|
|
46
|
+
sites.push({ path: file, line: index + 1, verb: verbOf(match[1] ?? ""), table });
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return sites;
|
|
53
|
+
}
|
|
54
|
+
export function findWriteSites(root, table, graph, scrutinised = []) {
|
|
55
|
+
const symbols = tableSymbols(table, graph);
|
|
56
|
+
const candidates = graph === null || symbols.length === 0 ? [] : graph.importers(symbols[0] ?? table, WRITE_SITE_CANDIDATE_FILES);
|
|
57
|
+
return scanWriteSites(root, [...candidates, ...scrutinised], table, graph === null ? [camelCaseOf(table)] : symbols);
|
|
58
|
+
}
|
|
@@ -199,3 +199,16 @@ export function assessBashRead(file, range, thresholdTokens, minimumThresholdTok
|
|
|
199
199
|
return { decision: "pass", reason: "under-threshold", source: "read", estimatedTokens };
|
|
200
200
|
return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: threshold };
|
|
201
201
|
}
|
|
202
|
+
export function assessBashReadByCoin(file, range, isSelected, env = process.env) {
|
|
203
|
+
const size = regularFileSize(file);
|
|
204
|
+
if (size === null)
|
|
205
|
+
return { decision: "pass", reason: "not-a-file", source: "stat", estimatedTokens: null };
|
|
206
|
+
if (isSkippedExtension(file))
|
|
207
|
+
return { decision: "pass", reason: "skipped-extension", source: "stat", estimatedTokens: null };
|
|
208
|
+
if (hasLeadingNul(file))
|
|
209
|
+
return { decision: "pass", reason: "binary", source: "read", estimatedTokens: null };
|
|
210
|
+
const estimatedTokens = Math.min(tokensOfLength(printedBytes(file, size, range)), tokensOfLength(bashMaxOutputLength(env)));
|
|
211
|
+
if (!isSelected())
|
|
212
|
+
return { decision: "pass", reason: "coin-not-selected", source: "read", estimatedTokens };
|
|
213
|
+
return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: null };
|
|
214
|
+
}
|
|
@@ -88,3 +88,17 @@ export function assessFullRead(file, thresholdTokens, minimumThresholdTokens) {
|
|
|
88
88
|
return { decision: "pass", reason: "under-threshold", source: "read", estimatedTokens };
|
|
89
89
|
return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: threshold };
|
|
90
90
|
}
|
|
91
|
+
export function assessFullReadByCoin(file, isSelected) {
|
|
92
|
+
const size = regularFileSize(file);
|
|
93
|
+
if (size === null)
|
|
94
|
+
return { decision: "pass", reason: "not-a-file", source: "stat", estimatedTokens: null };
|
|
95
|
+
if (isSkippedExtension(file))
|
|
96
|
+
return { decision: "pass", reason: "skipped-extension", source: "stat", estimatedTokens: null };
|
|
97
|
+
const scan = readLeadingLines(file, READ_DEFAULT_LINE_LIMIT);
|
|
98
|
+
if (scan.isBinary)
|
|
99
|
+
return { decision: "pass", reason: "binary", source: "read", estimatedTokens: null };
|
|
100
|
+
const estimatedTokens = estimateReadTokens(scan);
|
|
101
|
+
if (!isSelected())
|
|
102
|
+
return { decision: "pass", reason: "coin-not-selected", source: "read", estimatedTokens };
|
|
103
|
+
return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: null };
|
|
104
|
+
}
|
package/dist/lib/gate/hook.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { performance } from "node:perf_hooks";
|
|
3
3
|
import { classifyBashRead } from "./bashRead.js";
|
|
4
4
|
import { GateInputError, gateBashCall, gateReadCall } from "./readGate.js";
|
|
5
|
+
import { GATE_COIN_RATE_ENV, resolveCoin } from "./settings.js";
|
|
5
6
|
const TTY_NOTICE = "nustack gate: hook entry point; reads Claude Code's PreToolUse JSON on stdin\n";
|
|
6
7
|
async function readStdin(stdin) {
|
|
7
8
|
const chunks = [];
|
|
@@ -113,11 +114,14 @@ export async function runGateHook(subcommand, io = { stdin: process.stdin, stdou
|
|
|
113
114
|
return;
|
|
114
115
|
}
|
|
115
116
|
const call = parseCall(await readStdin(io.stdin), startedAt);
|
|
117
|
+
const { coin, rejected } = resolveCoin(io.env);
|
|
118
|
+
if (rejected !== null)
|
|
119
|
+
io.stderr.write(`nustack gate: GATE_COIN_RATE_INVALID: ${GATE_COIN_RATE_ENV}=${JSON.stringify(rejected)} is not a number in [0, 1]; the size test applies\n`);
|
|
116
120
|
let answer;
|
|
117
121
|
if (subcommand === "read")
|
|
118
|
-
answer = await gateReadCall(call, io.env);
|
|
122
|
+
answer = await gateReadCall(call, io.env, {}, coin);
|
|
119
123
|
else if (subcommand === "bash")
|
|
120
|
-
answer = await gateBashCall(call, io.env);
|
|
124
|
+
answer = await gateBashCall(call, io.env, coin);
|
|
121
125
|
else
|
|
122
126
|
throw new GateInputError(`unknown hook "${subcommand}"`);
|
|
123
127
|
if (answer.decision === "deny")
|
|
@@ -2,9 +2,10 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { closeSync, openSync, readFileSync, readSync, realpathSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
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 {
|
|
5
|
+
import { assessBashRead, assessBashReadByCoin, classifyBashRead } from "./bashRead.js";
|
|
6
|
+
import { assessFullRead, assessFullReadByCoin, readLeadingLines, regularFileSize, tokensOfLength } from "./estimate.js";
|
|
7
|
+
import { assessRedirect, redirectHandler } from "./redirect.js";
|
|
8
|
+
import { GATE_MIN_THRESHOLD_TOKENS, isCoinSelected, isGateOff, resolveCoin, resolveThreshold } from "./settings.js";
|
|
8
9
|
export class GateInputError extends Error {
|
|
9
10
|
code = "HOOK_INPUT_INVALID";
|
|
10
11
|
constructor(message) {
|
|
@@ -26,7 +27,10 @@ function realPathOf(file) {
|
|
|
26
27
|
return file;
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
+
function coinSelects(coin, call, resolved) {
|
|
31
|
+
return () => isCoinSelected(coin, call.sessionId, realPathOf(resolved));
|
|
32
|
+
}
|
|
33
|
+
export async function gateReadCall(call, env = process.env, deps = {}, coin = resolveCoin(env).coin) {
|
|
30
34
|
if (isGateOff(env))
|
|
31
35
|
return PASS;
|
|
32
36
|
const filePath = call.toolInput.file_path;
|
|
@@ -35,10 +39,16 @@ export async function gateReadCall(call, env = process.env) {
|
|
|
35
39
|
if (call.toolInput.offset != null || call.toolInput.limit != null)
|
|
36
40
|
return PASS;
|
|
37
41
|
const resolved = resolvePath(call.cwd, filePath);
|
|
38
|
-
const
|
|
39
|
-
|
|
42
|
+
const redirect = await assessRedirect(resolved, call.cwd, env, deps);
|
|
43
|
+
const assessment = coin === null
|
|
44
|
+
? assessFullRead(resolved, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS)
|
|
45
|
+
: assessFullReadByCoin(resolved, coinSelects(coin, call, resolved));
|
|
46
|
+
if (assessment.decision === "gate" || !redirect.isRedirect)
|
|
47
|
+
return answerAssessment(call, resolved, assessment, "read");
|
|
48
|
+
const { LABEL_ROW_HANDLERS } = await import("../local_store/routing.js");
|
|
49
|
+
return answerLargeRead(call, resolved, redirect.estimatedTokens, "read", redirectHandler(LABEL_ROW_HANDLERS));
|
|
40
50
|
}
|
|
41
|
-
export async function gateBashCall(call, env = process.env) {
|
|
51
|
+
export async function gateBashCall(call, env = process.env, coin = resolveCoin(env).coin) {
|
|
42
52
|
if (isGateOff(env))
|
|
43
53
|
return PASS;
|
|
44
54
|
const command = call.toolInput.command;
|
|
@@ -48,7 +58,9 @@ export async function gateBashCall(call, env = process.env) {
|
|
|
48
58
|
if (read === null)
|
|
49
59
|
return PASS;
|
|
50
60
|
const resolved = resolvePath(call.cwd, read.path);
|
|
51
|
-
const assessment =
|
|
61
|
+
const assessment = coin === null
|
|
62
|
+
? assessBashRead(resolved, read.range, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS, env)
|
|
63
|
+
: assessBashReadByCoin(resolved, read.range, coinSelects(coin, call, resolved), env);
|
|
52
64
|
return answerAssessment(call, resolved, assessment, "bash");
|
|
53
65
|
}
|
|
54
66
|
async function answerAssessment(call, resolved, assessment, tool) {
|
|
@@ -56,7 +68,7 @@ async function answerAssessment(call, resolved, assessment, tool) {
|
|
|
56
68
|
return PASS;
|
|
57
69
|
return answerLargeRead(call, resolved, assessment.estimatedTokens, tool);
|
|
58
70
|
}
|
|
59
|
-
async function answerLargeRead(call, resolved, estimatedTokens, tool) {
|
|
71
|
+
async function answerLargeRead(call, resolved, estimatedTokens, tool, deniedHandler = "outline") {
|
|
60
72
|
const [{ openLocalStore, withTransaction }, routing, outline] = await Promise.all([
|
|
61
73
|
import("../local_store/database.js"),
|
|
62
74
|
import("../local_store/routing.js"),
|
|
@@ -84,7 +96,7 @@ async function answerLargeRead(call, resolved, estimatedTokens, tool) {
|
|
|
84
96
|
path: realPath,
|
|
85
97
|
estimated_tokens: estimatedTokens,
|
|
86
98
|
returned_tokens: returnedTokens,
|
|
87
|
-
handler: isInserted ?
|
|
99
|
+
handler: isInserted ? deniedHandler : "bypass",
|
|
88
100
|
check_result: null,
|
|
89
101
|
bypass_used: isInserted ? 0 : 1,
|
|
90
102
|
latency_ms: Math.max(0, Math.round(performance.now() - call.startedAt)),
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { relativeToRoot, resolveProjectRoot } from "../graph/projectRoot.js";
|
|
4
|
+
import { estimateReadTokens, isSkippedExtension, readLeadingLines, READ_DEFAULT_LINE_LIMIT, regularFileSize } from "./estimate.js";
|
|
5
|
+
export const REDIRECT_MIN_LINES = 350;
|
|
6
|
+
export const REDIRECT_RULE_PATH = path.join(".claude", "rules", "large-read-redirect.md");
|
|
7
|
+
export const NUSTACK_READ_REDIRECT_ENV = "NUSTACK_READ_REDIRECT";
|
|
8
|
+
export function isRedirectOn(root, env = process.env) {
|
|
9
|
+
const override = env[NUSTACK_READ_REDIRECT_ENV];
|
|
10
|
+
if (override === "on" || override === "off")
|
|
11
|
+
return { isOn: override === "on", source: "env" };
|
|
12
|
+
if (root === null)
|
|
13
|
+
return { isOn: false, source: "absent" };
|
|
14
|
+
try {
|
|
15
|
+
return statSync(path.join(root, REDIRECT_RULE_PATH)).isFile() ? { isOn: true, source: "rule" } : { isOn: false, source: "absent" };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { isOn: false, source: "absent" };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function redirectHandler(handlers) {
|
|
22
|
+
return handlers.includes("redirect") ? "redirect" : "outline";
|
|
23
|
+
}
|
|
24
|
+
const NO_REDIRECT = { isRedirect: false };
|
|
25
|
+
async function defaultLoader() {
|
|
26
|
+
const { loadCodegraph } = await import("../graph/library.js");
|
|
27
|
+
return loadCodegraph();
|
|
28
|
+
}
|
|
29
|
+
export async function assessRedirect(file, cwd, env = process.env, deps = {}) {
|
|
30
|
+
try {
|
|
31
|
+
const size = regularFileSize(file);
|
|
32
|
+
if (size === null || size < REDIRECT_MIN_LINES + 1 || isSkippedExtension(file))
|
|
33
|
+
return NO_REDIRECT;
|
|
34
|
+
const root = resolveProjectRoot(env.CLAUDE_PROJECT_DIR, cwd);
|
|
35
|
+
if (root === null || !isRedirectOn(root, env).isOn)
|
|
36
|
+
return NO_REDIRECT;
|
|
37
|
+
const head = readLeadingLines(file, REDIRECT_MIN_LINES + 1);
|
|
38
|
+
if (head.isBinary || head.lines <= REDIRECT_MIN_LINES)
|
|
39
|
+
return NO_REDIRECT;
|
|
40
|
+
const relPath = relativeToRoot(root, file, cwd);
|
|
41
|
+
if (relPath === null)
|
|
42
|
+
return NO_REDIRECT;
|
|
43
|
+
const library = await (deps.loadLibrary ?? defaultLoader)();
|
|
44
|
+
if (typeof library.openReadOnly !== "function" || typeof library.indexedFile !== "function")
|
|
45
|
+
return NO_REDIRECT;
|
|
46
|
+
const db = library.openReadOnly(root);
|
|
47
|
+
if (db === null)
|
|
48
|
+
return NO_REDIRECT;
|
|
49
|
+
try {
|
|
50
|
+
if (library.indexedFile(db, relPath) === null)
|
|
51
|
+
return NO_REDIRECT;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
db.close();
|
|
55
|
+
}
|
|
56
|
+
return { isRedirect: true, estimatedTokens: estimateReadTokens(readLeadingLines(file, READ_DEFAULT_LINE_LIMIT)) };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return NO_REDIRECT;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { DIGEST_BUDGET_MS_MAX, DIGEST_BUDGET_MS_MIN, GATE_THRESHOLD_TOKENS_MAX, GATE_THRESHOLD_TOKENS_MIN, readDesktopSettings, } from "../desktop_settings.js";
|
|
2
3
|
export const NUSTACK_GATE_ENV = "NUSTACK_GATE";
|
|
3
4
|
export const NUSTACK_GATE_THRESHOLD_ENV = "NUSTACK_GATE_THRESHOLD_TOKENS";
|
|
@@ -45,3 +46,24 @@ export function resolveReadBulkBudget(flagBudgetMs, env = process.env, readSetti
|
|
|
45
46
|
return { budgetMs: fromSettings, budgetSource: "settings" };
|
|
46
47
|
return { budgetMs: null, budgetSource: "none" };
|
|
47
48
|
}
|
|
49
|
+
export const GATE_COIN_RATE_ENV = "NUSTACK_GATE_COIN_RATE";
|
|
50
|
+
export const GATE_COIN_SEED_ENV = "NUSTACK_GATE_COIN_SEED";
|
|
51
|
+
export const GATE_COIN_DEFAULT_SEED = "0";
|
|
52
|
+
const COIN_RATE_PATTERN = /^(?:\d+(?:\.\d*)?|\.\d+)$/;
|
|
53
|
+
export function resolveCoin(env = process.env) {
|
|
54
|
+
const text = env[GATE_COIN_RATE_ENV]?.trim();
|
|
55
|
+
if (text === undefined || text === "")
|
|
56
|
+
return { coin: null, rejected: null };
|
|
57
|
+
const rate = COIN_RATE_PATTERN.test(text) ? Number(text) : Number.NaN;
|
|
58
|
+
if (!(rate >= 0 && rate <= 1))
|
|
59
|
+
return { coin: null, rejected: text };
|
|
60
|
+
const seed = env[GATE_COIN_SEED_ENV];
|
|
61
|
+
return { coin: { rate, seed: seed === undefined || seed === "" ? GATE_COIN_DEFAULT_SEED : seed }, rejected: null };
|
|
62
|
+
}
|
|
63
|
+
export function coinDraw(seed, sessionId, realPath) {
|
|
64
|
+
const digest = createHash("sha256").update(`${seed}\0${sessionId}\0${realPath}`).digest("hex");
|
|
65
|
+
return Number.parseInt(digest.slice(0, 8), 16) / 2 ** 32;
|
|
66
|
+
}
|
|
67
|
+
export function isCoinSelected(coin, sessionId, realPath) {
|
|
68
|
+
return coinDraw(coin.seed, sessionId, realPath) < coin.rate;
|
|
69
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function startOf(projectDir, cwd) {
|
|
4
|
+
if (projectDir !== undefined && projectDir.trim() !== "")
|
|
5
|
+
return projectDir;
|
|
6
|
+
if (cwd !== undefined && cwd.trim() !== "")
|
|
7
|
+
return cwd;
|
|
8
|
+
return process.cwd();
|
|
9
|
+
}
|
|
10
|
+
export function resolveProjectRoot(projectDir, cwd) {
|
|
11
|
+
let current;
|
|
12
|
+
try {
|
|
13
|
+
current = realpathSync(startOf(projectDir, cwd));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
for (;;) {
|
|
19
|
+
if (existsSync(path.join(current, ".git")))
|
|
20
|
+
return current;
|
|
21
|
+
const parent = path.dirname(current);
|
|
22
|
+
if (parent === current)
|
|
23
|
+
return null;
|
|
24
|
+
current = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function relativeToRoot(root, file, base = root) {
|
|
28
|
+
let real;
|
|
29
|
+
try {
|
|
30
|
+
real = realpathSync(path.isAbsolute(file) ? file : path.resolve(base, file));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const relative = path.relative(root, real);
|
|
36
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative))
|
|
37
|
+
return null;
|
|
38
|
+
return relative.split(path.sep).join("/");
|
|
39
|
+
}
|
|
@@ -19,6 +19,9 @@ export function desktopSettingsPath() {
|
|
|
19
19
|
export function cacheDir() {
|
|
20
20
|
return path.join(nustackHomeDir(), "cache");
|
|
21
21
|
}
|
|
22
|
+
export function contextMarkersDir() {
|
|
23
|
+
return path.join(cacheDir(), "context");
|
|
24
|
+
}
|
|
22
25
|
export function sessionEventsDir() {
|
|
23
26
|
return path.join(nustackHomeDir(), "session-events");
|
|
24
27
|
}
|
package/dist/program.js
CHANGED
|
@@ -8,6 +8,7 @@ import { runSync } from "./commands/sync.js";
|
|
|
8
8
|
import { runDescribe } from "./commands/describe.js";
|
|
9
9
|
import { runValidate } from "./commands/validate.js";
|
|
10
10
|
import { runGraph } from "./commands/graph.js";
|
|
11
|
+
import { runContext } from "./commands/context.js";
|
|
11
12
|
import { runLogscope } from "./commands/logscope.js";
|
|
12
13
|
import { runDoctor } from "./commands/doctor.js";
|
|
13
14
|
import { runTokenCreate, runTokenList, runTokenRevoke } from "./commands/token.js";
|
|
@@ -116,6 +117,16 @@ program
|
|
|
116
117
|
.allowUnknownOption()
|
|
117
118
|
.allowExcessArguments()
|
|
118
119
|
.action(async (_fn, _args, options) => runGraph(process.argv, options));
|
|
120
|
+
program
|
|
121
|
+
.command("context [text...]")
|
|
122
|
+
.description("Resolve task text, paths or a diff to register objects and print the context pack")
|
|
123
|
+
.option("--path <p>", "a path the task touches (repeatable)", (value, previous = []) => [...previous, value])
|
|
124
|
+
.option("--diff <fileOrDash>", "a unified diff whose files the task touches; - reads stdin")
|
|
125
|
+
.option("--json", "emit the versioned machine envelope")
|
|
126
|
+
.option("--no-refresh", "answer from the code-graph index as it stands, however old")
|
|
127
|
+
.option("--budget <tokens>", "the pack's token cap (default 2500)")
|
|
128
|
+
.option("--hook", "Claude Code hook entry for UserPromptSubmit, PostToolUse and SessionStart (reads stdin)")
|
|
129
|
+
.action(async (text, options) => runContext(text, options));
|
|
119
130
|
program
|
|
120
131
|
.command("logscope [fn] [args...]")
|
|
121
132
|
.description("Log tracking: ask this machine's log corpus a question, or `index` / `corpora` / `allow` / `prune`")
|
|
@@ -253,11 +264,14 @@ registerRecordsCommands(program.command("records").description("Emit, send and l
|
|
|
253
264
|
registerRecordAliases(program, () => program.opts());
|
|
254
265
|
const LOCAL_ONLY_COMMANDS = ["census", "check"];
|
|
255
266
|
const UNDRAINED_GROUPS = ["records", "gate", "session", "read", "lane", ...RECORD_ALIAS_GROUPS];
|
|
267
|
+
const HOOK_PATH_COMMANDS = ["context"];
|
|
256
268
|
program.hook("preAction", async (_program, action) => {
|
|
257
269
|
if (program.opts().nonInteractive)
|
|
258
270
|
process.env[NON_INTERACTIVE_VARIABLE] = "1";
|
|
259
271
|
if (action.parent === program && LOCAL_ONLY_COMMANDS.includes(action.name()))
|
|
260
272
|
return;
|
|
273
|
+
if (action.parent === program && HOOK_PATH_COMMANDS.includes(action.name()))
|
|
274
|
+
return;
|
|
261
275
|
const group = action.parent?.name() ?? "";
|
|
262
276
|
if (!UNDRAINED_GROUPS.includes(group))
|
|
263
277
|
await startDrainIfDue(program.opts());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/nustack",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.23",
|
|
4
4
|
"description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
"@types/node": "^22.19.20",
|
|
35
35
|
"typescript": "^5.7.2",
|
|
36
36
|
"vitest": "^4.1.11",
|
|
37
|
+
"@nurix/contracts": "2.0.2",
|
|
37
38
|
"@nurix/harness": "0.0.0",
|
|
38
39
|
"@nurix/nustack.studio": "0.0.0",
|
|
39
|
-
"@nurix/nustack.ui": "0.0.0"
|
|
40
|
-
"@nurix/contracts": "2.0.2"
|
|
40
|
+
"@nurix/nustack.ui": "0.0.0"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public"
|