@nurix/nustack 0.23.20 → 0.23.22

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,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
+ }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { performance } from "node:perf_hooks";
5
5
  import { assessBashRead, classifyBashRead } from "./bashRead.js";
6
6
  import { assessFullRead, readLeadingLines, regularFileSize, tokensOfLength } from "./estimate.js";
7
+ import { assessRedirect, redirectHandler } from "./redirect.js";
7
8
  import { GATE_MIN_THRESHOLD_TOKENS, isGateOff, resolveThreshold } from "./settings.js";
8
9
  export class GateInputError extends Error {
9
10
  code = "HOOK_INPUT_INVALID";
@@ -26,7 +27,7 @@ function realPathOf(file) {
26
27
  return file;
27
28
  }
28
29
  }
29
- export async function gateReadCall(call, env = process.env) {
30
+ export async function gateReadCall(call, env = process.env, deps = {}) {
30
31
  if (isGateOff(env))
31
32
  return PASS;
32
33
  const filePath = call.toolInput.file_path;
@@ -35,8 +36,12 @@ export async function gateReadCall(call, env = process.env) {
35
36
  if (call.toolInput.offset != null || call.toolInput.limit != null)
36
37
  return PASS;
37
38
  const resolved = resolvePath(call.cwd, filePath);
39
+ const redirect = await assessRedirect(resolved, call.cwd, env, deps);
38
40
  const assessment = assessFullRead(resolved, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS);
39
- return answerAssessment(call, resolved, assessment, "read");
41
+ if (assessment.decision === "gate" || !redirect.isRedirect)
42
+ return answerAssessment(call, resolved, assessment, "read");
43
+ const { LABEL_ROW_HANDLERS } = await import("../local_store/routing.js");
44
+ return answerLargeRead(call, resolved, redirect.estimatedTokens, "read", redirectHandler(LABEL_ROW_HANDLERS));
40
45
  }
41
46
  export async function gateBashCall(call, env = process.env) {
42
47
  if (isGateOff(env))
@@ -56,7 +61,7 @@ async function answerAssessment(call, resolved, assessment, tool) {
56
61
  return PASS;
57
62
  return answerLargeRead(call, resolved, assessment.estimatedTokens, tool);
58
63
  }
59
- async function answerLargeRead(call, resolved, estimatedTokens, tool) {
64
+ async function answerLargeRead(call, resolved, estimatedTokens, tool, deniedHandler = "outline") {
60
65
  const [{ openLocalStore, withTransaction }, routing, outline] = await Promise.all([
61
66
  import("../local_store/database.js"),
62
67
  import("../local_store/routing.js"),
@@ -84,7 +89,7 @@ async function answerLargeRead(call, resolved, estimatedTokens, tool) {
84
89
  path: realPath,
85
90
  estimated_tokens: estimatedTokens,
86
91
  returned_tokens: returnedTokens,
87
- handler: isInserted ? "outline" : "bypass",
92
+ handler: isInserted ? deniedHandler : "bypass",
88
93
  check_result: null,
89
94
  bypass_used: isInserted ? 0 : 1,
90
95
  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
+ }
@@ -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.20",
3
+ "version": "0.23.22",
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"