@nurix/nustack 0.23.9 → 0.23.11

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.
@@ -2,11 +2,14 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  const MANAGED_MARKER = "nustack:managed";
5
- const MANAGED_COMMAND = `nustack agent context # ${MANAGED_MARKER}`;
6
- function managedEntry() {
7
- return { matcher: "", hooks: [{ type: "command", command: MANAGED_COMMAND }] };
5
+ const SESSION_START_COMMAND = "nustack agent context";
6
+ function managedEntry(matcher, command, options) {
7
+ const hook = { type: "command", command: `${command} # ${MANAGED_MARKER}` };
8
+ if (options.timeout !== undefined)
9
+ hook.timeout = options.timeout;
10
+ return { matcher, hooks: [hook] };
8
11
  }
9
- function isManagedEntry(entry) {
12
+ function carriesMarker(entry) {
10
13
  if (entry === null || typeof entry !== "object")
11
14
  return false;
12
15
  const hooks = entry.hooks;
@@ -17,6 +20,12 @@ function isManagedEntry(entry) {
17
20
  typeof hook.command === "string" &&
18
21
  hook.command.includes(MANAGED_MARKER));
19
22
  }
23
+ function isManagedEntry(entry, matcher) {
24
+ if (!carriesMarker(entry))
25
+ return false;
26
+ const entryMatcher = entry.matcher;
27
+ return (typeof entryMatcher === "string" ? entryMatcher : "") === matcher;
28
+ }
20
29
  const DEFAULT_INDENT = 2;
21
30
  function detectIndent(raw) {
22
31
  const indented = /\n(\x20+)"/.exec(raw);
@@ -34,44 +43,55 @@ async function writeSettings(settingsPath, settings, indent) {
34
43
  await mkdir(path.dirname(settingsPath), { recursive: true });
35
44
  await writeFile(settingsPath, `${JSON.stringify(settings, null, indent)}\n`, "utf8");
36
45
  }
37
- export async function installSessionStartHook(settingsPath) {
46
+ export async function installManagedHook(settingsPath, event, matcher, command, options = {}) {
38
47
  const { settings, indent } = await readSettings(settingsPath);
39
48
  const hooks = { ...(settings.hooks ?? {}) };
40
- const sessionStart = Array.isArray(hooks.SessionStart) ? [...hooks.SessionStart] : [];
41
- const existingIndex = sessionStart.findIndex(isManagedEntry);
42
- const entry = managedEntry();
49
+ const entries = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
50
+ const existingIndex = entries.findIndex((entry) => isManagedEntry(entry, matcher));
51
+ const entry = managedEntry(matcher, command, options);
43
52
  if (existingIndex !== -1) {
44
- const current = sessionStart[existingIndex];
53
+ const current = entries[existingIndex];
45
54
  if (JSON.stringify(current) === JSON.stringify(entry)) {
46
55
  return { changed: false };
47
56
  }
48
- sessionStart[existingIndex] = entry;
57
+ entries[existingIndex] = entry;
49
58
  }
50
59
  else {
51
- sessionStart.push(entry);
60
+ entries.push(entry);
52
61
  }
53
- hooks.SessionStart = sessionStart;
62
+ hooks[event] = entries;
54
63
  settings.hooks = hooks;
55
64
  await writeSettings(settingsPath, settings, indent);
56
65
  return { changed: true };
57
66
  }
58
- export async function removeSessionStartHook(settingsPath) {
67
+ export async function removeManagedHook(settingsPath, event, matcher) {
59
68
  const { settings, indent } = await readSettings(settingsPath);
60
69
  const hooks = settings.hooks;
61
- const sessionStart = hooks?.SessionStart;
62
- if (!hooks || !Array.isArray(sessionStart))
70
+ const entries = hooks?.[event];
71
+ if (!hooks || !Array.isArray(entries))
63
72
  return { removed: false };
64
- const filtered = sessionStart.filter((entry) => !isManagedEntry(entry));
65
- if (filtered.length === sessionStart.length)
73
+ const filtered = entries.filter((entry) => !isManagedEntry(entry, matcher));
74
+ if (filtered.length === entries.length)
66
75
  return { removed: false };
67
76
  const nextHooks = { ...hooks };
68
77
  if (filtered.length > 0) {
69
- nextHooks.SessionStart = filtered;
78
+ nextHooks[event] = filtered;
70
79
  }
71
80
  else {
72
- delete nextHooks.SessionStart;
81
+ delete nextHooks[event];
73
82
  }
74
83
  settings.hooks = nextHooks;
75
84
  await writeSettings(settingsPath, settings, indent);
76
85
  return { removed: true };
77
86
  }
87
+ export async function hasManagedHook(settingsPath, event, matcher) {
88
+ const { settings } = await readSettings(settingsPath);
89
+ const entries = settings.hooks?.[event];
90
+ return Array.isArray(entries) && entries.some((entry) => isManagedEntry(entry, matcher));
91
+ }
92
+ export async function installSessionStartHook(settingsPath) {
93
+ return installManagedHook(settingsPath, "SessionStart", "", SESSION_START_COMMAND);
94
+ }
95
+ export async function removeSessionStartHook(settingsPath) {
96
+ return removeManagedHook(settingsPath, "SessionStart", "");
97
+ }
@@ -0,0 +1,120 @@
1
+ import { classifyBashRead } from "../gate/bashRead.js";
2
+ import { tokensOfLength } from "../gate/estimate.js";
3
+ export const CENSUS_CATEGORIES = [
4
+ "Read full",
5
+ "Read ranged",
6
+ "Bash read",
7
+ "Bash other",
8
+ "Grep",
9
+ "Glob",
10
+ "Agent",
11
+ "WebFetch",
12
+ "other",
13
+ ];
14
+ export const CENSUS_SCOPES = ["main", "sidechain"];
15
+ function isObject(value) {
16
+ return value !== null && typeof value === "object" && !Array.isArray(value);
17
+ }
18
+ function numberOf(value) {
19
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
20
+ }
21
+ export function categoryOf(name, input) {
22
+ const toolInput = isObject(input) ? input : {};
23
+ switch (name) {
24
+ case "Read":
25
+ return toolInput.offset == null && toolInput.limit == null ? "Read full" : "Read ranged";
26
+ case "Bash":
27
+ return typeof toolInput.command === "string" && classifyBashRead(toolInput.command) !== null
28
+ ? "Bash read"
29
+ : "Bash other";
30
+ case "Grep":
31
+ case "Glob":
32
+ case "WebFetch":
33
+ return name;
34
+ case "Agent":
35
+ case "Task":
36
+ return "Agent";
37
+ default:
38
+ return "other";
39
+ }
40
+ }
41
+ function resultLength(content) {
42
+ if (typeof content === "string")
43
+ return content.length;
44
+ if (!Array.isArray(content))
45
+ return 0;
46
+ let length = 0;
47
+ for (const block of content) {
48
+ if (isObject(block) && block.type === "text" && typeof block.text === "string")
49
+ length += block.text.length;
50
+ }
51
+ return length;
52
+ }
53
+ export async function attributeTranscript(lines, fileScope) {
54
+ const categoryByToolUse = new Map();
55
+ const seenCalls = new Set();
56
+ const pending = [];
57
+ const results = [];
58
+ let apiCalls = 0;
59
+ let contextTokens = 0;
60
+ let skippedLines = 0;
61
+ for await (const line of lines) {
62
+ let entry;
63
+ try {
64
+ entry = JSON.parse(line);
65
+ }
66
+ catch {
67
+ skippedLines++;
68
+ continue;
69
+ }
70
+ if (!isObject(entry)) {
71
+ skippedLines++;
72
+ continue;
73
+ }
74
+ if (entry.type === "system" && entry.subtype === "compact_boundary") {
75
+ settle(pending, apiCalls, results);
76
+ continue;
77
+ }
78
+ const scope = fileScope === "sidechain" || entry.isSidechain === true ? "sidechain" : "main";
79
+ const message = isObject(entry.message) ? entry.message : undefined;
80
+ const content = Array.isArray(message?.content) ? message.content : [];
81
+ if (entry.type === "assistant" && message !== undefined) {
82
+ const messageId = typeof message.id === "string" ? message.id : undefined;
83
+ if (messageId === undefined || !seenCalls.has(messageId)) {
84
+ if (messageId !== undefined)
85
+ seenCalls.add(messageId);
86
+ apiCalls++;
87
+ const usage = isObject(message.usage) ? message.usage : {};
88
+ contextTokens +=
89
+ numberOf(usage.input_tokens) +
90
+ numberOf(usage.cache_read_input_tokens) +
91
+ numberOf(usage.cache_creation_input_tokens);
92
+ }
93
+ for (const block of content) {
94
+ if (isObject(block) && block.type === "tool_use" && typeof block.id === "string") {
95
+ categoryByToolUse.set(block.id, categoryOf(block.name, block.input));
96
+ }
97
+ }
98
+ continue;
99
+ }
100
+ for (const block of content) {
101
+ if (!isObject(block) || block.type !== "tool_result")
102
+ continue;
103
+ const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
104
+ pending.push({
105
+ category: categoryByToolUse.get(toolUseId) ?? "other",
106
+ scope,
107
+ tokens: tokensOfLength(resultLength(block.content)),
108
+ callsBefore: apiCalls,
109
+ });
110
+ }
111
+ }
112
+ settle(pending, apiCalls, results);
113
+ return { results, apiCalls, contextTokens, skippedLines };
114
+ }
115
+ function settle(pending, apiCalls, results) {
116
+ for (const { category, scope, tokens, callsBefore } of pending) {
117
+ results.push({ category, scope, tokens, weightedTokens: tokens * Math.max(1, apiCalls - callsBefore) });
118
+ }
119
+ pending.length = 0;
120
+ }
@@ -0,0 +1,108 @@
1
+ import { CENSUS_CATEGORIES, CENSUS_SCOPES } from "./attribute.js";
2
+ export const CENSUS_THRESHOLD_CANDIDATES = [2_000, 4_000, 8_000, 16_000];
3
+ export const CENSUS_KEEP_SHARE = 0.7;
4
+ export const CENSUS_FALLBACK_THRESHOLD_TOKENS = 2_000;
5
+ export const CENSUS_MIN_SESSIONS = 50;
6
+ export const CENSUS_GATE_SHARE = 0.3;
7
+ export const CENSUS_BASH_FIRST_SHARE = 0.15;
8
+ export const CENSUS_VERDICTS = ["gate-as-designed", "gate-marginal", "bash-output-first"];
9
+ const READ_CATEGORIES = ["Read full", "Read ranged", "Bash read"];
10
+ export function recommendThreshold(candidates, sessions) {
11
+ if (sessions < CENSUS_MIN_SESSIONS)
12
+ return null;
13
+ const kept = candidates.filter((candidate) => candidate.shareAtOrAbove >= CENSUS_KEEP_SHARE).map((candidate) => candidate.tokens);
14
+ return kept.length === 0 ? CENSUS_FALLBACK_THRESHOLD_TOKENS : Math.max(...kept);
15
+ }
16
+ export function verdictOf(readsShare) {
17
+ if (readsShare >= CENSUS_GATE_SHARE)
18
+ return "gate-as-designed";
19
+ if (readsShare < CENSUS_BASH_FIRST_SHARE)
20
+ return "bash-output-first";
21
+ return "gate-marginal";
22
+ }
23
+ function ratio(part, whole) {
24
+ return whole > 0 ? part / whole : 0;
25
+ }
26
+ export class CensusAccumulator {
27
+ sessions = 0;
28
+ transcripts = 0;
29
+ sidechainTranscripts = 0;
30
+ skippedLines = 0;
31
+ contextTokens = 0;
32
+ rows = new Map();
33
+ fullReadWeighted = 0;
34
+ fullReadWeightedAtOrAbove = new Map(CENSUS_THRESHOLD_CANDIDATES.map((tokens) => [tokens, 0]));
35
+ constructor() {
36
+ for (const category of CENSUS_CATEGORIES) {
37
+ for (const scope of CENSUS_SCOPES) {
38
+ this.rows.set(`${category}|${scope}`, { category, scope, count: 0, tokens: 0, weightedTokens: 0 });
39
+ }
40
+ }
41
+ }
42
+ addSession() {
43
+ this.sessions++;
44
+ }
45
+ addTranscript(attribution, kind) {
46
+ if (kind === "main")
47
+ this.transcripts++;
48
+ else
49
+ this.sidechainTranscripts++;
50
+ this.skippedLines += attribution.skippedLines;
51
+ this.contextTokens += attribution.contextTokens;
52
+ for (const result of attribution.results) {
53
+ const row = this.rows.get(`${result.category}|${result.scope}`);
54
+ row.count++;
55
+ row.tokens += result.tokens;
56
+ row.weightedTokens += result.weightedTokens;
57
+ if (result.category !== "Read full")
58
+ continue;
59
+ this.fullReadWeighted += result.weightedTokens;
60
+ for (const candidate of CENSUS_THRESHOLD_CANDIDATES) {
61
+ if (result.tokens >= candidate) {
62
+ this.fullReadWeightedAtOrAbove.set(candidate, this.fullReadWeightedAtOrAbove.get(candidate) + result.weightedTokens);
63
+ }
64
+ }
65
+ }
66
+ }
67
+ finish() {
68
+ const categories = [...this.rows.values()].map((row) => ({ ...row, share: ratio(row.weightedTokens, this.contextTokens) }));
69
+ const weightedOf = (names) => categories.filter((row) => names.includes(row.category)).reduce((sum, row) => sum + row.weightedTokens, 0);
70
+ const readsShare = ratio(weightedOf(READ_CATEGORIES), this.contextTokens);
71
+ const bashShare = ratio(weightedOf(["Bash other"]), this.contextTokens);
72
+ const thresholdCandidates = CENSUS_THRESHOLD_CANDIDATES.map((tokens) => ({
73
+ tokens,
74
+ shareAtOrAbove: ratio(this.fullReadWeightedAtOrAbove.get(tokens), this.fullReadWeighted),
75
+ }));
76
+ return {
77
+ sessions: this.sessions,
78
+ transcripts: this.transcripts,
79
+ sidechainTranscripts: this.sidechainTranscripts,
80
+ skippedLines: this.skippedLines,
81
+ categories,
82
+ readsShare,
83
+ bashShare,
84
+ thresholdCandidates,
85
+ recommendedThresholdTokens: recommendThreshold(thresholdCandidates, this.sessions),
86
+ verdict: verdictOf(readsShare),
87
+ };
88
+ }
89
+ }
90
+ function percent(value) {
91
+ return `${(value * 100).toFixed(1)}%`;
92
+ }
93
+ export function renderCensusText(report) {
94
+ const lines = [
95
+ `Token census: ${report.sessions} sessions, ${report.transcripts} transcripts, ${report.sidechainTranscripts} sub-agent transcripts, ${report.skippedLines} unreadable lines skipped.`,
96
+ "",
97
+ `${"category".padEnd(12)} ${"scope".padEnd(10)} ${"results".padStart(9)} ${"tokens".padStart(13)} ${"weighted".padStart(16)} ${"share".padStart(7)}`,
98
+ ];
99
+ for (const row of report.categories) {
100
+ if (row.count === 0)
101
+ continue;
102
+ lines.push(`${row.category.padEnd(12)} ${row.scope.padEnd(10)} ${String(row.count).padStart(9)} ${String(row.tokens).padStart(13)} ${String(row.weightedTokens).padStart(16)} ${percent(row.share).padStart(7)}`);
103
+ }
104
+ lines.push("", `Reads' share: ${percent(report.readsShare)} · Bash output share: ${percent(report.bashShare)}`, "Full-read weighted cost at or above each threshold:", ...report.thresholdCandidates.map((candidate) => ` ${String(candidate.tokens).padStart(6)} tokens: ${percent(candidate.shareAtOrAbove)}`), report.recommendedThresholdTokens === null
105
+ ? `Recommended threshold: withheld (fewer than ${CENSUS_MIN_SESSIONS} sessions).`
106
+ : `Recommended threshold: ${report.recommendedThresholdTokens} tokens.`, `Verdict: ${report.verdict}`);
107
+ return lines.join("\n");
108
+ }
@@ -0,0 +1,62 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ export function defaultTranscriptRoot() {
7
+ return path.join(os.homedir(), ".claude", "projects");
8
+ }
9
+ async function listDirectory(directory) {
10
+ try {
11
+ return await readdir(directory, { withFileTypes: true });
12
+ }
13
+ catch {
14
+ return [];
15
+ }
16
+ }
17
+ async function subagentTranscripts(projectDirectory, sessionId) {
18
+ const directory = path.join(projectDirectory, sessionId, "subagents");
19
+ const entries = await listDirectory(directory);
20
+ return entries
21
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
22
+ .map((entry) => path.join(directory, entry.name))
23
+ .sort();
24
+ }
25
+ export async function discoverSessions(options = {}) {
26
+ const root = options.root ?? defaultTranscriptRoot();
27
+ const sessions = [];
28
+ for (const project of await listDirectory(root)) {
29
+ if (!project.isDirectory())
30
+ continue;
31
+ const projectDirectory = path.join(root, project.name);
32
+ for (const entry of await listDirectory(projectDirectory)) {
33
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
34
+ continue;
35
+ const mainPath = path.join(projectDirectory, entry.name);
36
+ const modifiedAtMs = (await stat(mainPath)).mtimeMs;
37
+ if (options.sinceMs !== undefined && modifiedAtMs < options.sinceMs)
38
+ continue;
39
+ const sessionId = entry.name.slice(0, -".jsonl".length);
40
+ sessions.push({
41
+ sessionId,
42
+ mainPath,
43
+ sidechainPaths: await subagentTranscripts(projectDirectory, sessionId),
44
+ modifiedAtMs,
45
+ });
46
+ }
47
+ }
48
+ sessions.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs || a.mainPath.localeCompare(b.mainPath));
49
+ return options.maxSessions === undefined ? sessions : sessions.slice(0, options.maxSessions);
50
+ }
51
+ export async function* readLines(file) {
52
+ const lines = createInterface({ input: createReadStream(file, { encoding: "utf8" }), crlfDelay: Infinity });
53
+ try {
54
+ for await (const line of lines) {
55
+ if (line.trim() !== "")
56
+ yield line;
57
+ }
58
+ }
59
+ finally {
60
+ lines.close();
61
+ }
62
+ }
@@ -1,11 +1,24 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { desktopSettingsPath } from "./local_store/paths.js";
3
+ export const GATE_THRESHOLD_TOKENS_MIN = 1_000;
4
+ export const GATE_THRESHOLD_TOKENS_MAX = 200_000;
5
+ export const DIGEST_BUDGET_MS_MIN = 5_000;
6
+ export const DIGEST_BUDGET_MS_MAX = 300_000;
3
7
  const DEFAULT_PLACEMENT = "server";
4
8
  function placementOf(value) {
5
9
  return value === "device" || value === "server" ? value : DEFAULT_PLACEMENT;
6
10
  }
11
+ function boundedIntegerOf(value, min, max) {
12
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : null;
13
+ }
14
+ function sectionOf(value) {
15
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
16
+ }
7
17
  export function readDesktopSettings() {
8
- const defaults = { placement: { memory: DEFAULT_PLACEMENT, securityFindings: DEFAULT_PLACEMENT } };
18
+ const defaults = {
19
+ placement: { memory: DEFAULT_PLACEMENT, securityFindings: DEFAULT_PLACEMENT },
20
+ routing: { localReader: false, gateThresholdTokens: null, digestBudgetMs: null },
21
+ };
9
22
  let parsed;
10
23
  try {
11
24
  parsed = JSON.parse(readFileSync(desktopSettingsPath(), "utf8"));
@@ -19,5 +32,13 @@ export function readDesktopSettings() {
19
32
  if (file.schemaVersion !== 1)
20
33
  return defaults;
21
34
  const placement = file.placement && typeof file.placement === "object" ? file.placement : {};
22
- return { placement: { memory: placementOf(placement.memory), securityFindings: placementOf(placement.securityFindings) } };
35
+ const routing = sectionOf(file.routing);
36
+ return {
37
+ placement: { memory: placementOf(placement.memory), securityFindings: placementOf(placement.securityFindings) },
38
+ routing: {
39
+ localReader: routing.localReader === true,
40
+ gateThresholdTokens: boundedIntegerOf(routing.gateThresholdTokens, GATE_THRESHOLD_TOKENS_MIN, GATE_THRESHOLD_TOKENS_MAX),
41
+ digestBudgetMs: boundedIntegerOf(routing.digestBudgetMs, DIGEST_BUDGET_MS_MIN, DIGEST_BUDGET_MS_MAX),
42
+ },
43
+ };
23
44
  }
@@ -0,0 +1,201 @@
1
+ import { closeSync, openSync, readFileSync, readSync } from "node:fs";
2
+ import { BINARY_PROBE_BYTES, isSkippedExtension, readLeadingLines, regularFileSize, tokensOfLength, } from "./estimate.js";
3
+ export const BASH_DEFAULT_LINE_COUNT = 10;
4
+ const COMPOUND_MARKERS = ["|", ";", "&&", "||", ">", "<", "`", "$(", "\n", "\r"];
5
+ const WHOLE_FILE_COMMANDS = new Set(["cat", "less", "more"]);
6
+ function splitWords(command) {
7
+ const words = [];
8
+ let current = "";
9
+ let hasWord = false;
10
+ let quote = null;
11
+ for (const character of command) {
12
+ if (quote !== null) {
13
+ if (character === quote)
14
+ quote = null;
15
+ else
16
+ current += character;
17
+ continue;
18
+ }
19
+ if (character === "'" || character === '"') {
20
+ quote = character;
21
+ hasWord = true;
22
+ continue;
23
+ }
24
+ if (/\s/.test(character)) {
25
+ if (hasWord)
26
+ words.push(current);
27
+ current = "";
28
+ hasWord = false;
29
+ continue;
30
+ }
31
+ current += character;
32
+ hasWord = true;
33
+ }
34
+ if (quote !== null)
35
+ return null;
36
+ if (hasWord)
37
+ words.push(current);
38
+ return words;
39
+ }
40
+ function basename(word) {
41
+ const slash = word.lastIndexOf("/");
42
+ return slash === -1 ? word : word.slice(slash + 1);
43
+ }
44
+ function positiveInteger(text) {
45
+ if (!/^\d+$/.test(text))
46
+ return null;
47
+ const value = Number.parseInt(text, 10);
48
+ return Number.isSafeInteger(value) ? value : null;
49
+ }
50
+ function classifyWholeFile(name, args) {
51
+ const operands = args.filter((arg) => !arg.startsWith("-") || arg === "-");
52
+ if (operands.length !== 1 || operands[0] === "-")
53
+ return null;
54
+ return { command: name, path: operands[0], range: { kind: "whole" } };
55
+ }
56
+ function classifyLineCount(name, args) {
57
+ let lines = BASH_DEFAULT_LINE_COUNT;
58
+ const operands = [];
59
+ for (let index = 0; index < args.length; index++) {
60
+ const arg = args[index];
61
+ let count;
62
+ if (arg === "-n") {
63
+ count = positiveInteger(args[++index] ?? "");
64
+ }
65
+ else if (arg.startsWith("--lines=")) {
66
+ count = positiveInteger(arg.slice("--lines=".length));
67
+ }
68
+ else if (/^-n\d+$/.test(arg)) {
69
+ count = positiveInteger(arg.slice(2));
70
+ }
71
+ else if (/^-\d+$/.test(arg)) {
72
+ count = positiveInteger(arg.slice(1));
73
+ }
74
+ else if (arg.startsWith("-") && arg !== "-") {
75
+ return null;
76
+ }
77
+ else {
78
+ operands.push(arg);
79
+ continue;
80
+ }
81
+ if (count === null)
82
+ return null;
83
+ lines = count;
84
+ }
85
+ if (operands.length !== 1 || operands[0] === "-")
86
+ return null;
87
+ return { command: name, path: operands[0], range: { kind: name, lines } };
88
+ }
89
+ function classifySed(args) {
90
+ let isQuiet = false;
91
+ const operands = [];
92
+ for (const arg of args) {
93
+ if (arg === "-n" || arg === "--quiet" || arg === "--silent")
94
+ isQuiet = true;
95
+ else if (arg.startsWith("-"))
96
+ return null;
97
+ else
98
+ operands.push(arg);
99
+ }
100
+ if (!isQuiet || operands.length !== 2)
101
+ return null;
102
+ const match = /^(\d+)(?:,(\d+))?p$/.exec(operands[0]);
103
+ if (match === null)
104
+ return null;
105
+ const start = positiveInteger(match[1]);
106
+ const end = match[2] === undefined ? start : positiveInteger(match[2]);
107
+ if (start === null || end === null || start < 1 || end < start)
108
+ return null;
109
+ return { command: "sed", path: operands[1], range: { kind: "lines", start, end } };
110
+ }
111
+ export function classifyBashRead(command) {
112
+ if (COMPOUND_MARKERS.some((marker) => command.includes(marker)))
113
+ return null;
114
+ const words = splitWords(command.trim());
115
+ if (words === null || words.length < 2)
116
+ return null;
117
+ const name = basename(words[0]);
118
+ const args = words.slice(1);
119
+ if (WHOLE_FILE_COMMANDS.has(name))
120
+ return classifyWholeFile(name, args);
121
+ if (name === "head" || name === "tail")
122
+ return classifyLineCount(name, args);
123
+ if (name === "sed")
124
+ return classifySed(args);
125
+ return null;
126
+ }
127
+ export const BASH_DEFAULT_MAX_OUTPUT_LENGTH = 30_000;
128
+ export const BASH_MAX_OUTPUT_LENGTH_ENV = "BASH_MAX_OUTPUT_LENGTH";
129
+ const TAIL_SCAN_MAX_BYTES = 32 * 1024 * 1024;
130
+ export function bashMaxOutputLength(env = process.env) {
131
+ const text = env[BASH_MAX_OUTPUT_LENGTH_ENV]?.trim();
132
+ if (text !== undefined && /^\d+$/.test(text)) {
133
+ const value = Number.parseInt(text, 10);
134
+ if (value > 0)
135
+ return value;
136
+ }
137
+ return BASH_DEFAULT_MAX_OUTPUT_LENGTH;
138
+ }
139
+ function hasLeadingNul(file) {
140
+ const descriptor = openSync(file, "r");
141
+ try {
142
+ const probe = Buffer.alloc(BINARY_PROBE_BYTES);
143
+ const read = readSync(descriptor, probe, 0, BINARY_PROBE_BYTES, 0);
144
+ return probe.subarray(0, read).includes(0);
145
+ }
146
+ finally {
147
+ closeSync(descriptor);
148
+ }
149
+ }
150
+ function tailBytes(file, size, lines) {
151
+ if (size > TAIL_SCAN_MAX_BYTES)
152
+ return size;
153
+ const content = readFileSync(file);
154
+ let end = content.length;
155
+ if (end > 0 && content[end - 1] === 10)
156
+ end--;
157
+ let cursor = end;
158
+ let seen = 0;
159
+ while (cursor > 0) {
160
+ const newline = content.lastIndexOf(10, cursor - 1);
161
+ seen++;
162
+ if (newline === -1 || seen === lines) {
163
+ cursor = newline + 1;
164
+ break;
165
+ }
166
+ cursor = newline;
167
+ }
168
+ return content.length - cursor;
169
+ }
170
+ function printedBytes(file, size, range) {
171
+ switch (range.kind) {
172
+ case "whole":
173
+ return size;
174
+ case "head":
175
+ return readLeadingLines(file, range.lines).bytes;
176
+ case "tail":
177
+ return tailBytes(file, size, range.lines);
178
+ case "lines":
179
+ return readLeadingLines(file, range.end).bytes - readLeadingLines(file, range.start - 1).bytes;
180
+ }
181
+ }
182
+ export function assessBashRead(file, range, thresholdTokens, minimumThresholdTokens, env = process.env) {
183
+ const size = regularFileSize(file);
184
+ if (size === null)
185
+ return { decision: "pass", reason: "not-a-file", source: "stat", estimatedTokens: null };
186
+ if (isSkippedExtension(file))
187
+ return { decision: "pass", reason: "skipped-extension", source: "stat", estimatedTokens: null };
188
+ const capTokens = tokensOfLength(bashMaxOutputLength(env));
189
+ const upperBound = Math.min(tokensOfLength(size), capTokens);
190
+ if (upperBound < minimumThresholdTokens)
191
+ return { decision: "pass", reason: "under-threshold", source: "stat", estimatedTokens: null };
192
+ const threshold = thresholdTokens();
193
+ if (upperBound < threshold)
194
+ return { decision: "pass", reason: "under-threshold", source: "stat", estimatedTokens: null };
195
+ if (hasLeadingNul(file))
196
+ return { decision: "pass", reason: "binary", source: "read", estimatedTokens: null };
197
+ const estimatedTokens = Math.min(tokensOfLength(printedBytes(file, size, range)), capTokens);
198
+ if (estimatedTokens < threshold)
199
+ return { decision: "pass", reason: "under-threshold", source: "read", estimatedTokens };
200
+ return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: threshold };
201
+ }