@dreb/coding-agent 2.30.1 → 2.31.1

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,107 @@
1
+ export interface LoadedContextFile {
2
+ /** Absolute path of the loaded context file. */
3
+ path: string;
4
+ /** File content (HTML comments already stripped by the loader). */
5
+ content: string;
6
+ }
7
+ export interface NestedContextCollection {
8
+ /** Context files newly loaded during this collection pass. */
9
+ files: LoadedContextFile[];
10
+ /** Whether any existing context file failed to read and should be retried later. */
11
+ hadReadError: boolean;
12
+ }
13
+ /**
14
+ * Extract the target of a leading `cd <dir>` from a bash command.
15
+ *
16
+ * Covers the overwhelming majority of directory-changing bash commands (analysis of
17
+ * real session logs: ~75% of bash calls start with `cd`, ~97% of those with an absolute
18
+ * path). Returns the raw, unresolved path string (with a leading `~` preserved) or
19
+ * `null` when the command does not begin with a simple `cd`.
20
+ */
21
+ export declare function parseLeadingCd(command: string): string | null;
22
+ /**
23
+ * Resolve the absolute directory a tool call is about to operate in, or `null` when the
24
+ * tool/argument shape does not identify a directory we should react to.
25
+ */
26
+ export declare function resolveTargetDir(toolName: string, args: Record<string, unknown> | undefined, cwd: string): string | null;
27
+ /**
28
+ * Predicate deciding whether a collected context file should be *suppressed* from the
29
+ * injected block because the triggering tool call already delivers its content (e.g. a
30
+ * `read` of the file itself, or a `bash` command that prints it). Suppressed files are
31
+ * still marked as loaded so they are never injected later — they are simply not
32
+ * duplicated into the result that already contains them.
33
+ */
34
+ export type SuppressPredicate = (file: LoadedContextFile) => boolean;
35
+ /**
36
+ * Collect nested context files for `targetDir`, walking up to the ceiling described in
37
+ * {@link resolveWalkDirs}. Files whose realpath is already in `alreadyLoaded` are skipped
38
+ * (and not re-reported). Newly collected realpaths are added to `alreadyLoaded` so the
39
+ * caller's per-session set stays authoritative and each file loads at most once. Also
40
+ * reports whether an existing context file failed to read so callers can retry later
41
+ * instead of negatively caching a transient failure.
42
+ *
43
+ * When `suppress` matches a newly-seen file, that file is marked loaded but excluded from
44
+ * the returned `files` — the triggering tool result already contains it, so re-injecting
45
+ * would duplicate the content and waste tokens.
46
+ */
47
+ export declare function collectNestedContext(targetDir: string, cwd: string, alreadyLoaded: Set<string>, suppress?: SuppressPredicate): NestedContextCollection;
48
+ /**
49
+ * Format collected context files into a single text block for injection into a tool
50
+ * result. Leads with *why* the load happened and headers each file with its source path.
51
+ * There is intentionally no size cap — oversized context files are the project's concern.
52
+ */
53
+ export declare function formatNestedContextBlock(targetDir: string, files: LoadedContextFile[]): string;
54
+ /**
55
+ * Resolve the absolute file path a `read` tool call delivers, or `null` when the tool is
56
+ * not `read` or has no usable `path`. Only `read` returns the *full* file content, so it
57
+ * is the only path-tool whose result fully duplicates an injected context file. (`grep`
58
+ * returns matched lines, `ls`/`edit`/`write` do not echo the whole file — those still
59
+ * benefit from injection.)
60
+ */
61
+ export declare function resolveSelfReadFile(toolName: string, args: Record<string, unknown> | undefined, cwd: string): string | null;
62
+ /**
63
+ * Resolve the absolute paths of files a bash command fully delivers to stdout via a
64
+ * full-dump command (`cat`/`bat`). Path arguments are resolved against `workingDir` (the
65
+ * command's effective cwd — e.g. a leading `cd` target). Conservative on purpose:
66
+ *
67
+ * - Segments are split on `&&`, `||`, `;`. Segments that are *only* a `cd` produce no
68
+ * stdout and are ignored, but there must be **exactly one** remaining output-producing
69
+ * segment. The bash tool truncates its *combined* command output from the **tail**
70
+ * (keeping the last {@link DEFAULT_MAX_LINES} lines / {@link DEFAULT_MAX_BYTES} bytes and
71
+ * dropping the head), so any *additional* output-producing segment could evict the dumped
72
+ * file from the visible window while {@link deliveredInFull} — which measures the file
73
+ * alone — still reports a full delivery. Bail to the safe double-load in that case.
74
+ * - That sole segment must not contain a pipe (`|`), output redirection (`>`), or input
75
+ * redirection / here-doc / here-string (`<`, `<<`, `<<<`) — its output is filtered /
76
+ * redirected, or its operands are stdin body words rather than dumped files.
77
+ * - Its first token must be `cat`/`bat`; flags (`-…`) are ignored.
78
+ * - A `bat` segment carrying a partial-range flag (`-r`/`--line-range`, any spelling) is
79
+ * skipped — it emits only a fragment, like `head`/`tail`.
80
+ * - It must have **exactly one** file operand. A multi-file dump (`cat A.md B.md`)
81
+ * concatenates several files; under tail truncation an earlier operand can be evicted
82
+ * while still appearing fully sized on disk, so it is not a provable full delivery.
83
+ * - If the command chains more than one `cd`, the effective cwd is ambiguous (we only
84
+ * resolved the *first* `cd`), so operands cannot be resolved reliably — return nothing.
85
+ *
86
+ * Anything we cannot confidently classify as a full delivery is omitted, so the worst case
87
+ * is a double-load rather than a silently dropped context file.
88
+ */
89
+ export declare function resolveBashDeliveredFiles(command: string, workingDir: string): string[];
90
+ export interface NestedContextState {
91
+ /** Whether auto-loading is enabled (the `context.autoLoadNested` setting). */
92
+ enabled: boolean;
93
+ /** The session's working directory. */
94
+ cwd: string;
95
+ /** Realpaths of context files already loaded this session (seeded at session start). Mutated. */
96
+ loaded: Set<string>;
97
+ /** Realpaths of directories already scanned (negative cache). Mutated. */
98
+ scannedDirs: Set<string>;
99
+ }
100
+ /**
101
+ * Orchestrate a single nested-context decision for a tool call: gate on the setting,
102
+ * resolve the target directory, skip directories already scanned (negative cache),
103
+ * collect not-yet-loaded context files, and format them. Returns the injection block or
104
+ * `null` when nothing should be injected. Mutates `state.scannedDirs` and `state.loaded`.
105
+ */
106
+ export declare function computeNestedContextBlock(toolName: string, args: Record<string, unknown> | undefined, state: NestedContextState): string | null;
107
+ //# sourceMappingURL=nested-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nested-context.d.ts","sourceRoot":"","sources":["../../src/core/nested-context.ts"],"names":[],"mappings":"AAwBA,MAAM,WAAW,iBAAiB;IACjC,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,uBAAuB;IACvC,8DAA8D;IAC9D,KAAK,EAAE,iBAAiB,EAAE,CAAC;IAC3B,oFAAoF;IACpF,YAAY,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsC7D;AAgBD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC/B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,GAAG,EAAE,MAAM,GACT,MAAM,GAAG,IAAI,CA6Bf;AAkGD;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,IAAI,EAAE,iBAAiB,KAAK,OAAO,CAAC;AAErE;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CACnC,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC1B,uBAAuB,CAwBzB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,EAAE,GAAG,MAAM,CAc9F;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAClC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,GAAG,EAAE,MAAM,GACT,MAAM,GAAG,IAAI,CAUf;AA0CD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CA0CvF;AAED,MAAM,WAAW,kBAAkB;IAClC,8EAA8E;IAC9E,OAAO,EAAE,OAAO,CAAC;IACjB,uCAAuC;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,iGAAiG;IACjG,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,0EAA0E;IAC1E,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB;AAkCD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACxC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,KAAK,EAAE,kBAAkB,GACvB,MAAM,GAAG,IAAI,CAwCf","sourcesContent":["import { existsSync, readFileSync, realpathSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve, sep } from \"node:path\";\nimport { CONTEXT_FILE_CANDIDATES, loadContextFilesFromDir, type ResourceDiagnostic } from \"./resource-loader.js\";\nimport { renderTerminalOutput } from \"./tools/terminal-render.js\";\nimport { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from \"./tools/truncate.js\";\n\n/**\n * Auto-load of nested AGENTS.md/CLAUDE.md context files.\n *\n * Project context files are only loaded at session start by walking *upward* from\n * `cwd`. When the agent (or a subagent) operates in a subdirectory — or in an entirely\n * different repo/project — that directory's context files are never loaded. This module\n * detects the directory a tool is about to operate in, walks up to a sensible ceiling\n * collecting context files, and returns a formatted block for injection into the tool\n * result (which is cache-safe — it does not rebuild the system prompt).\n */\n\n/** A safety bound on how many directories the upward walk will visit. */\nconst MAX_WALK_DEPTH = 64;\n\n/** Tools whose `path` argument identifies the directory being operated on. */\nconst PATH_TOOLS = new Set([\"read\", \"edit\", \"write\", \"grep\", \"find\", \"ls\"]);\n\nexport interface LoadedContextFile {\n\t/** Absolute path of the loaded context file. */\n\tpath: string;\n\t/** File content (HTML comments already stripped by the loader). */\n\tcontent: string;\n}\n\nexport interface NestedContextCollection {\n\t/** Context files newly loaded during this collection pass. */\n\tfiles: LoadedContextFile[];\n\t/** Whether any existing context file failed to read and should be retried later. */\n\thadReadError: boolean;\n}\n\n/**\n * Extract the target of a leading `cd <dir>` from a bash command.\n *\n * Covers the overwhelming majority of directory-changing bash commands (analysis of\n * real session logs: ~75% of bash calls start with `cd`, ~97% of those with an absolute\n * path). Returns the raw, unresolved path string (with a leading `~` preserved) or\n * `null` when the command does not begin with a simple `cd`.\n */\nexport function parseLeadingCd(command: string): string | null {\n\tif (typeof command !== \"string\") return null;\n\n\tconst leadingCd = command.match(/^\\s*cd\\s+/);\n\tif (!leadingCd) return null;\n\n\tlet rest = command.slice(leadingCd[0].length);\n\twhile (true) {\n\t\t// Match either a quoted path or an unquoted token that stops at the first shell\n\t\t// separator (&&, ;, |, newline) or whitespace.\n\t\tconst match = rest.match(/^\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s&;|<>]+))/);\n\t\tif (!match) return null;\n\n\t\tconst target = (match[1] ?? match[2] ?? match[3] ?? \"\").trim();\n\t\tif (!target) return null;\n\n\t\t// `cd -` means \"previous directory\" and cannot be resolved cheaply.\n\t\tif (target === \"-\") return null;\n\n\t\t// Skip leading options (`cd -P /x`, `cd -L /x`). After `--`, the next token is\n\t\t// the path even if it begins with `-`.\n\t\tif (target === \"--\") {\n\t\t\trest = rest.slice(match[0].length);\n\t\t\tconst pathMatch = rest.match(/^\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s&;|<>]+))/);\n\t\t\tif (!pathMatch) return null;\n\t\t\tconst pathTarget = (pathMatch[1] ?? pathMatch[2] ?? pathMatch[3] ?? \"\").trim();\n\t\t\tif (!pathTarget || pathTarget.startsWith(\"$\")) return null;\n\t\t\treturn pathTarget;\n\t\t}\n\t\tif (target.startsWith(\"-\")) {\n\t\t\trest = rest.slice(match[0].length);\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Skip variable-based targets we cannot resolve cheaply.\n\t\tif (target.startsWith(\"$\")) return null;\n\t\treturn target;\n\t}\n}\n\n/**\n * Expand a leading `~` to the home directory and resolve a raw path string to an absolute\n * path against `baseDir`. Shared by every place that turns a user-supplied path token into\n * an absolute path (`resolveTargetDir`, `resolveSelfReadFile`, bash argument resolution).\n */\nfunction expandToAbsolute(rawPath: string, baseDir: string): string {\n\tif (rawPath === \"~\") {\n\t\trawPath = homedir();\n\t} else if (rawPath.startsWith(`~${sep}`) || rawPath.startsWith(\"~/\")) {\n\t\trawPath = join(homedir(), rawPath.slice(2));\n\t}\n\treturn isAbsolute(rawPath) ? rawPath : resolve(baseDir, rawPath);\n}\n\n/**\n * Resolve the absolute directory a tool call is about to operate in, or `null` when the\n * tool/argument shape does not identify a directory we should react to.\n */\nexport function resolveTargetDir(\n\ttoolName: string,\n\targs: Record<string, unknown> | undefined,\n\tcwd: string,\n): string | null {\n\tif (!args) return null;\n\n\tlet rawPath: string | null = null;\n\n\tif (toolName === \"bash\") {\n\t\trawPath = parseLeadingCd(typeof args.command === \"string\" ? args.command : \"\");\n\t} else if (PATH_TOOLS.has(toolName)) {\n\t\tconst p = args.path;\n\t\tif (typeof p === \"string\" && p.trim() !== \"\") {\n\t\t\trawPath = p;\n\t\t}\n\t}\n\n\tif (!rawPath) return null;\n\n\tconst absolute = expandToAbsolute(rawPath, cwd);\n\n\t// For path-bearing tools the argument is usually a file; for bash `cd` it is a\n\t// directory. Resolve to a directory: existing dirs are used as-is, everything else\n\t// (existing files, not-yet-created files) maps to its parent directory.\n\ttry {\n\t\tif (existsSync(absolute) && statSync(absolute).isDirectory()) {\n\t\t\treturn absolute;\n\t\t}\n\t} catch {\n\t\t// Fall through to dirname on permission/stat errors.\n\t}\n\treturn dirname(absolute);\n}\n\n/** Safe realpath that falls back to the input on error. */\nfunction safeRealpath(p: string): string {\n\ttry {\n\t\treturn realpathSync(p);\n\t} catch {\n\t\treturn p;\n\t}\n}\n\nfunction isWithin(parent: string, child: string): boolean {\n\tconst p = safeRealpath(parent);\n\tconst c = safeRealpath(child);\n\treturn c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);\n}\n\n/**\n * Build the ordered list of directories to inspect, from the target directory up to the\n * appropriate ceiling. Ordered outermost-first so the most specific (closest to the\n * target) context appears last, matching session-start precedence.\n *\n * Ceiling priority:\n * 1. `cwd` — when the target is within the cwd subtree (ancestors already loaded at start).\n * 2. The outermost git repo root in the chain (a directory containing `.git`).\n * 3. The outermost directory containing a CLAUDE.md/AGENTS.md.\n * 4. Hard stop at filesystem root, the depth bound, or a permission/stat failure.\n */\nfunction resolveWalkDirs(targetDir: string, cwd: string): string[] {\n\tconst root = resolve(\"/\");\n\n\t// Case 1: target within cwd subtree — never walk above cwd.\n\tif (isWithin(cwd, targetDir)) {\n\t\tconst dirs: string[] = [];\n\t\tlet current = targetDir;\n\t\tconst stop = safeRealpath(cwd);\n\t\tfor (let i = 0; i < MAX_WALK_DEPTH; i++) {\n\t\t\tdirs.push(current);\n\t\t\tif (safeRealpath(current) === stop) break;\n\t\t\tconst parent = resolve(current, \"..\");\n\t\t\tif (parent === current) break;\n\t\t\tcurrent = parent;\n\t\t}\n\t\treturn dirs.reverse();\n\t}\n\n\t// Case 2/3/4: target outside cwd — walk to the hard ceiling, recording git roots and\n\t// directories that hold context files, then bound to the outermost relevant ceiling.\n\tconst chain: string[] = [];\n\tlet highestGitRootIdx = -1;\n\tlet highestContextIdx = -1;\n\tlet current = targetDir;\n\tfor (let i = 0; i < MAX_WALK_DEPTH; i++) {\n\t\t// A permission/stat failure on the directory itself stops the walk.\n\t\ttry {\n\t\t\tstatSync(current);\n\t\t} catch {\n\t\t\tbreak;\n\t\t}\n\t\tchain.push(current);\n\t\tconst idx = chain.length - 1;\n\t\ttry {\n\t\t\tif (existsSync(join(current, \".git\"))) highestGitRootIdx = idx;\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t\tif (dirHasContextFile(current)) highestContextIdx = idx;\n\n\t\tif (current === root) break;\n\t\tconst parent = resolve(current, \"..\");\n\t\tif (parent === current) break;\n\t\tcurrent = parent;\n\t}\n\n\tlet ceilingIdx: number;\n\tif (highestGitRootIdx >= 0) {\n\t\tceilingIdx = highestGitRootIdx;\n\t} else if (highestContextIdx >= 0) {\n\t\tceilingIdx = highestContextIdx;\n\t} else {\n\t\tceilingIdx = chain.length - 1;\n\t}\n\n\treturn chain.slice(0, ceilingIdx + 1).reverse();\n}\n\n/** Cheap check: does this directory hold any candidate context file? */\nfunction dirHasContextFile(dir: string): boolean {\n\tfor (const c of CONTEXT_FILE_CANDIDATES) {\n\t\ttry {\n\t\t\tif (existsSync(join(dir, c))) return true;\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t}\n\treturn false;\n}\n\n/**\n * Predicate deciding whether a collected context file should be *suppressed* from the\n * injected block because the triggering tool call already delivers its content (e.g. a\n * `read` of the file itself, or a `bash` command that prints it). Suppressed files are\n * still marked as loaded so they are never injected later — they are simply not\n * duplicated into the result that already contains them.\n */\nexport type SuppressPredicate = (file: LoadedContextFile) => boolean;\n\n/**\n * Collect nested context files for `targetDir`, walking up to the ceiling described in\n * {@link resolveWalkDirs}. Files whose realpath is already in `alreadyLoaded` are skipped\n * (and not re-reported). Newly collected realpaths are added to `alreadyLoaded` so the\n * caller's per-session set stays authoritative and each file loads at most once. Also\n * reports whether an existing context file failed to read so callers can retry later\n * instead of negatively caching a transient failure.\n *\n * When `suppress` matches a newly-seen file, that file is marked loaded but excluded from\n * the returned `files` — the triggering tool result already contains it, so re-injecting\n * would duplicate the content and waste tokens.\n */\nexport function collectNestedContext(\n\ttargetDir: string,\n\tcwd: string,\n\talreadyLoaded: Set<string>,\n\tsuppress?: SuppressPredicate,\n): NestedContextCollection {\n\tconst dirs = resolveWalkDirs(targetDir, cwd);\n\tconst collected: LoadedContextFile[] = [];\n\tlet hadReadError = false;\n\tfor (const dir of dirs) {\n\t\tconst diagnostics: ResourceDiagnostic[] = [];\n\t\tconst files = loadContextFilesFromDir(dir, diagnostics);\n\t\tfor (const diagnostic of diagnostics) {\n\t\t\tif (diagnostic.type !== \"warning\") continue;\n\t\t\thadReadError = true;\n\t\t\tconsole.warn(\n\t\t\t\t`[nested-context] Nested context file existed but could not be read: ${diagnostic.path ?? dir} — ${diagnostic.message}`,\n\t\t\t);\n\t\t}\n\t\tfor (const file of files) {\n\t\t\tconst real = safeRealpath(file.path);\n\t\t\tif (alreadyLoaded.has(real)) continue;\n\t\t\talreadyLoaded.add(real);\n\t\t\t// Mark loaded but do not inject: the triggering tool already delivers this file.\n\t\t\tif (suppress?.(file)) continue;\n\t\t\tcollected.push(file);\n\t\t}\n\t}\n\treturn { files: collected, hadReadError };\n}\n\n/**\n * Format collected context files into a single text block for injection into a tool\n * result. Leads with *why* the load happened and headers each file with its source path.\n * There is intentionally no size cap — oversized context files are the project's concern.\n */\nexport function formatNestedContextBlock(targetDir: string, files: LoadedContextFile[]): string {\n\tconst header =\n\t\t`[dreb] Auto-loaded project context\\n\\n` +\n\t\t`A tool just operated in \\`${targetDir}\\`, whose project context had not been loaded yet. ` +\n\t\t`The file(s) below were loaded automatically to prevent missing important project context ` +\n\t\t`when working across multiple repos / projects / folders. ` +\n\t\t`(Disable with the \\`context.autoLoadNested\\` setting.)`;\n\n\tconst sections = files.map(\n\t\t(f) =>\n\t\t\t`===== BEGIN project context: ${f.path} =====\\n${f.content.trim()}\\n===== END project context: ${f.path} =====`,\n\t);\n\n\treturn `${header}\\n\\n${sections.join(\"\\n\\n\")}`;\n}\n\n/**\n * Resolve the absolute file path a `read` tool call delivers, or `null` when the tool is\n * not `read` or has no usable `path`. Only `read` returns the *full* file content, so it\n * is the only path-tool whose result fully duplicates an injected context file. (`grep`\n * returns matched lines, `ls`/`edit`/`write` do not echo the whole file — those still\n * benefit from injection.)\n */\nexport function resolveSelfReadFile(\n\ttoolName: string,\n\targs: Record<string, unknown> | undefined,\n\tcwd: string,\n): string | null {\n\tif (!args || toolName !== \"read\") return null;\n\t// A sliced read (`offset`/`limit`) delivers only a fragment of the file — the same\n\t// hazard for which bash partial viewers (`head`/`tail`) are excluded. Treating it as a\n\t// full delivery would suppress (and permanently mark loaded) a file the result only\n\t// partially contains, silently dropping the rest. Fall back to the safe double-load.\n\tif (args.offset !== undefined || args.limit !== undefined) return null;\n\tconst p = args.path;\n\tif (typeof p !== \"string\" || p.trim() === \"\") return null;\n\treturn expandToAbsolute(p, cwd);\n}\n\n/**\n * Bash commands that dump a file's *full* contents to stdout. Deliberately narrow: only\n * commands that emit the whole file qualify. Partial viewers (`head`/`tail`) and\n * interactive pagers (`less`/`more`) are excluded — they may show only a fragment, so\n * treating them as \"delivered\" could silently drop the rest of a context file. The safe\n * failure mode is a harmless double-load (we still inject), never a silent context drop.\n *\n * `bat` is included but is *not* unconditionally a full dump: its `-r`/`--line-range` flag\n * emits only a range (same hazard as `head`/`tail`). Segments carrying that flag are\n * disqualified in {@link resolveBashDeliveredFiles}.\n */\nconst FULL_DUMP_COMMANDS = new Set([\"cat\", \"bat\"]);\n\n/** `bat` flags that limit output to a partial range — disqualify the segment if present. */\nconst BAT_RANGE_FLAGS = [\"-r\", \"--line-range\"];\n\n/**\n * Whether a `bat` token requests a partial line range. Matches the space-separated form\n * (`-r`, `--line-range`), the `=`-attached long form (`--line-range=10:20`), and the\n * attached short form (`-r10:20`) — clap accepts an attached value on a short flag, so a\n * bare `startsWith` check on each known range flag covers every spelling. A partial range\n * is the same hazard as `head`/`tail`: only a fragment is emitted, so the segment must not\n * be treated as a full dump.\n */\nfunction isBatRangeFlag(token: string): boolean {\n\treturn BAT_RANGE_FLAGS.some((flag) => token.startsWith(flag));\n}\n\n/** Strip a single layer of matching surrounding quotes from a shell token. */\nfunction unquoteToken(token: string): string {\n\tif (token.length >= 2) {\n\t\tconst first = token[0];\n\t\tconst last = token[token.length - 1];\n\t\tif ((first === '\"' || first === \"'\") && first === last) {\n\t\t\treturn token.slice(1, -1);\n\t\t}\n\t}\n\treturn token;\n}\n\n/**\n * Resolve the absolute paths of files a bash command fully delivers to stdout via a\n * full-dump command (`cat`/`bat`). Path arguments are resolved against `workingDir` (the\n * command's effective cwd — e.g. a leading `cd` target). Conservative on purpose:\n *\n * - Segments are split on `&&`, `||`, `;`. Segments that are *only* a `cd` produce no\n * stdout and are ignored, but there must be **exactly one** remaining output-producing\n * segment. The bash tool truncates its *combined* command output from the **tail**\n * (keeping the last {@link DEFAULT_MAX_LINES} lines / {@link DEFAULT_MAX_BYTES} bytes and\n * dropping the head), so any *additional* output-producing segment could evict the dumped\n * file from the visible window while {@link deliveredInFull} — which measures the file\n * alone — still reports a full delivery. Bail to the safe double-load in that case.\n * - That sole segment must not contain a pipe (`|`), output redirection (`>`), or input\n * redirection / here-doc / here-string (`<`, `<<`, `<<<`) — its output is filtered /\n * redirected, or its operands are stdin body words rather than dumped files.\n * - Its first token must be `cat`/`bat`; flags (`-…`) are ignored.\n * - A `bat` segment carrying a partial-range flag (`-r`/`--line-range`, any spelling) is\n * skipped — it emits only a fragment, like `head`/`tail`.\n * - It must have **exactly one** file operand. A multi-file dump (`cat A.md B.md`)\n * concatenates several files; under tail truncation an earlier operand can be evicted\n * while still appearing fully sized on disk, so it is not a provable full delivery.\n * - If the command chains more than one `cd`, the effective cwd is ambiguous (we only\n * resolved the *first* `cd`), so operands cannot be resolved reliably — return nothing.\n *\n * Anything we cannot confidently classify as a full delivery is omitted, so the worst case\n * is a double-load rather than a silently dropped context file.\n */\nexport function resolveBashDeliveredFiles(command: string, workingDir: string): string[] {\n\tif (typeof command !== \"string\" || command.trim() === \"\") return [];\n\tconst segments = command.split(/&&|\\|\\||;/);\n\tconst isCdSegment = (s: string) => /^\\s*cd(\\s|$)/.test(s);\n\n\t// More than one `cd` means the effective cwd differs from the first `cd` target we\n\t// resolved as `workingDir`; resolving operands against it would suppress the wrong\n\t// (same-named) file. Bail to the safe double-load.\n\tif (segments.filter(isCdSegment).length > 1) return [];\n\n\t// Segments that are only a `cd` emit no stdout. Everything else produces output, and\n\t// because the bash tool tail-truncates the *combined* output, a context file is only\n\t// provably delivered in full when it is the command's *sole* output-producing segment.\n\tconst outputSegments = segments.filter((s) => s.trim() !== \"\" && !isCdSegment(s));\n\tif (outputSegments.length !== 1) return [];\n\n\tconst segment = outputSegments[0];\n\t// Output piped/redirected, or operands fed via input redirection / here-doc, are not raw\n\t// file dumps to stdout.\n\tif (segment.includes(\"|\") || segment.includes(\">\") || segment.includes(\"<\")) return [];\n\tconst tokens = segment.trim().split(/\\s+/).filter(Boolean);\n\tif (tokens.length === 0) return [];\n\t// Match the command verb case-sensitively: shell PATH lookup is case-sensitive on\n\t// Linux, so `CAT`/`Bat` are command-not-found and emit nothing to stdout. Lowercasing\n\t// would let them match the allowlist and falsely suppress a file they never printed —\n\t// a silent context drop. Exact matching keeps the failure mode a harmless double-load.\n\tconst cmd = tokens[0];\n\tif (!FULL_DUMP_COMMANDS.has(cmd)) return [];\n\t// `bat -r 10:20` / `bat -r10:20` / `bat --line-range=10:20` shows only a range — not a full dump.\n\tif (cmd === \"bat\" && tokens.slice(1).some(isBatRangeFlag)) return [];\n\n\tconst operands: string[] = [];\n\tfor (const token of tokens.slice(1)) {\n\t\tif (token.startsWith(\"-\")) continue; // flag, not a file argument\n\t\tconst arg = unquoteToken(token);\n\t\tif (arg === \"\") continue;\n\t\toperands.push(arg);\n\t}\n\t// A single operand is the only provable full delivery: multi-file dumps concatenate,\n\t// and tail truncation can evict an earlier file while it still looks fully sized.\n\tif (operands.length !== 1) return [];\n\treturn [expandToAbsolute(operands[0], workingDir)];\n}\n\nexport interface NestedContextState {\n\t/** Whether auto-loading is enabled (the `context.autoLoadNested` setting). */\n\tenabled: boolean;\n\t/** The session's working directory. */\n\tcwd: string;\n\t/** Realpaths of context files already loaded this session (seeded at session start). Mutated. */\n\tloaded: Set<string>;\n\t/** Realpaths of directories already scanned (negative cache). Mutated. */\n\tscannedDirs: Set<string>;\n}\n\n/**\n * Whether a tool that delivers `realPath` actually delivers its *full* content. Both `read`\n * and `bash` truncate their output at {@link DEFAULT_MAX_LINES} lines / {@link DEFAULT_MAX_BYTES}\n * bytes, while {@link formatNestedContextBlock} is uncapped. If the file exceeds either limit\n * it is delivered truncated, so suppressing (and permanently marking loaded) would silently\n * drop the remainder. Any stat/read failure also returns `false` — the safe double-load.\n *\n * `rendered` selects which delivery the measure must mirror:\n * - `read` delivers the file's raw content unchanged (`truncateHead` with no transform), so\n * the raw byte/line count is exact.\n * - `bash` delivers `truncateTail(renderTerminalOutput(...))`, and terminal rendering expands\n * tabs to 8-column stops and resolves cursor/ANSI sequences — the rendered output can be\n * *larger* than the file on disk. A tab-dense file just under the budget on disk can render\n * past it and be tail-truncated (its head dropped) while the raw measure still reports a\n * full delivery. Measuring the rendered output keeps the failure mode a harmless double-load\n * rather than a silent context drop.\n */\nfunction deliveredInFull(realPath: string, rendered: boolean): boolean {\n\ttry {\n\t\t// Cheap early-out: the raw on-disk size is a lower bound on the delivered size\n\t\t// (terminal rendering only ever grows the byte count), so a file already over the\n\t\t// byte budget on disk is certainly delivered truncated.\n\t\tif (statSync(realPath).size > DEFAULT_MAX_BYTES) return false;\n\t\tconst raw = readFileSync(realPath, \"utf8\");\n\t\tconst delivered = rendered ? renderTerminalOutput(raw) : raw;\n\t\tif (Buffer.byteLength(delivered, \"utf-8\") > DEFAULT_MAX_BYTES) return false;\n\t\treturn delivered.split(\"\\n\").length <= DEFAULT_MAX_LINES;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * Orchestrate a single nested-context decision for a tool call: gate on the setting,\n * resolve the target directory, skip directories already scanned (negative cache),\n * collect not-yet-loaded context files, and format them. Returns the injection block or\n * `null` when nothing should be injected. Mutates `state.scannedDirs` and `state.loaded`.\n */\nexport function computeNestedContextBlock(\n\ttoolName: string,\n\targs: Record<string, unknown> | undefined,\n\tstate: NestedContextState,\n): string | null {\n\tif (!state.enabled) return null;\n\n\tconst targetDir = resolveTargetDir(toolName, args, state.cwd);\n\tif (!targetDir) return null;\n\n\tconst realTarget = safeRealpath(targetDir);\n\tif (state.scannedDirs.has(realTarget)) return null;\n\n\t// A context file the triggering tool already delivers should be marked loaded but not\n\t// re-injected (the result already contains it). Two cases: a `read` of the file itself,\n\t// or a `bash` command that dumps its full contents (`cat`/`bat`). Both are matched by\n\t// full resolved realpath — never by basename — so printing one file never suppresses a\n\t// same-named sibling/ancestor or a file in a different directory.\n\tconst selfReadFile = resolveSelfReadFile(toolName, args, state.cwd);\n\tconst realSelfReadFile = selfReadFile ? safeRealpath(selfReadFile) : null;\n\tconst bashCommand = toolName === \"bash\" && typeof args?.command === \"string\" ? args.command : null;\n\t// Bash file arguments resolve against the command's effective cwd, which `resolveTargetDir`\n\t// has already computed as `targetDir` (the leading `cd` destination).\n\tconst bashDelivered = bashCommand\n\t\t? new Set(resolveBashDeliveredFiles(bashCommand, targetDir).map(safeRealpath))\n\t\t: null;\n\tconst suppress: SuppressPredicate = (file) => {\n\t\tconst realFile = safeRealpath(file.path);\n\t\t// Only suppress when the file was delivered *in full*: a truncated delivery (oversized\n\t\t// file) would drop the remainder if we marked it fully loaded and skipped injection.\n\t\t// `read` delivers the file's raw content unchanged; `bash` delivers it through terminal\n\t\t// rendering (tab/ANSI expansion can grow it past the truncation budget), so each path\n\t\t// measures fullness against what it actually emits.\n\t\tif (realFile === realSelfReadFile) return deliveredInFull(realFile, false);\n\t\tif (bashDelivered?.has(realFile)) return deliveredInFull(realFile, true);\n\t\treturn false;\n\t};\n\n\tconst collected = collectNestedContext(targetDir, state.cwd, state.loaded, suppress);\n\tif (!collected.hadReadError) {\n\t\tstate.scannedDirs.add(realTarget);\n\t}\n\tif (collected.files.length === 0) return null;\n\treturn formatNestedContextBlock(targetDir, collected.files);\n}\n"]}
@@ -0,0 +1,485 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
4
+ import { CONTEXT_FILE_CANDIDATES, loadContextFilesFromDir } from "./resource-loader.js";
5
+ import { renderTerminalOutput } from "./tools/terminal-render.js";
6
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "./tools/truncate.js";
7
+ /**
8
+ * Auto-load of nested AGENTS.md/CLAUDE.md context files.
9
+ *
10
+ * Project context files are only loaded at session start by walking *upward* from
11
+ * `cwd`. When the agent (or a subagent) operates in a subdirectory — or in an entirely
12
+ * different repo/project — that directory's context files are never loaded. This module
13
+ * detects the directory a tool is about to operate in, walks up to a sensible ceiling
14
+ * collecting context files, and returns a formatted block for injection into the tool
15
+ * result (which is cache-safe — it does not rebuild the system prompt).
16
+ */
17
+ /** A safety bound on how many directories the upward walk will visit. */
18
+ const MAX_WALK_DEPTH = 64;
19
+ /** Tools whose `path` argument identifies the directory being operated on. */
20
+ const PATH_TOOLS = new Set(["read", "edit", "write", "grep", "find", "ls"]);
21
+ /**
22
+ * Extract the target of a leading `cd <dir>` from a bash command.
23
+ *
24
+ * Covers the overwhelming majority of directory-changing bash commands (analysis of
25
+ * real session logs: ~75% of bash calls start with `cd`, ~97% of those with an absolute
26
+ * path). Returns the raw, unresolved path string (with a leading `~` preserved) or
27
+ * `null` when the command does not begin with a simple `cd`.
28
+ */
29
+ export function parseLeadingCd(command) {
30
+ if (typeof command !== "string")
31
+ return null;
32
+ const leadingCd = command.match(/^\s*cd\s+/);
33
+ if (!leadingCd)
34
+ return null;
35
+ let rest = command.slice(leadingCd[0].length);
36
+ while (true) {
37
+ // Match either a quoted path or an unquoted token that stops at the first shell
38
+ // separator (&&, ;, |, newline) or whitespace.
39
+ const match = rest.match(/^\s*(?:"([^"]+)"|'([^']+)'|([^\s&;|<>]+))/);
40
+ if (!match)
41
+ return null;
42
+ const target = (match[1] ?? match[2] ?? match[3] ?? "").trim();
43
+ if (!target)
44
+ return null;
45
+ // `cd -` means "previous directory" and cannot be resolved cheaply.
46
+ if (target === "-")
47
+ return null;
48
+ // Skip leading options (`cd -P /x`, `cd -L /x`). After `--`, the next token is
49
+ // the path even if it begins with `-`.
50
+ if (target === "--") {
51
+ rest = rest.slice(match[0].length);
52
+ const pathMatch = rest.match(/^\s*(?:"([^"]+)"|'([^']+)'|([^\s&;|<>]+))/);
53
+ if (!pathMatch)
54
+ return null;
55
+ const pathTarget = (pathMatch[1] ?? pathMatch[2] ?? pathMatch[3] ?? "").trim();
56
+ if (!pathTarget || pathTarget.startsWith("$"))
57
+ return null;
58
+ return pathTarget;
59
+ }
60
+ if (target.startsWith("-")) {
61
+ rest = rest.slice(match[0].length);
62
+ continue;
63
+ }
64
+ // Skip variable-based targets we cannot resolve cheaply.
65
+ if (target.startsWith("$"))
66
+ return null;
67
+ return target;
68
+ }
69
+ }
70
+ /**
71
+ * Expand a leading `~` to the home directory and resolve a raw path string to an absolute
72
+ * path against `baseDir`. Shared by every place that turns a user-supplied path token into
73
+ * an absolute path (`resolveTargetDir`, `resolveSelfReadFile`, bash argument resolution).
74
+ */
75
+ function expandToAbsolute(rawPath, baseDir) {
76
+ if (rawPath === "~") {
77
+ rawPath = homedir();
78
+ }
79
+ else if (rawPath.startsWith(`~${sep}`) || rawPath.startsWith("~/")) {
80
+ rawPath = join(homedir(), rawPath.slice(2));
81
+ }
82
+ return isAbsolute(rawPath) ? rawPath : resolve(baseDir, rawPath);
83
+ }
84
+ /**
85
+ * Resolve the absolute directory a tool call is about to operate in, or `null` when the
86
+ * tool/argument shape does not identify a directory we should react to.
87
+ */
88
+ export function resolveTargetDir(toolName, args, cwd) {
89
+ if (!args)
90
+ return null;
91
+ let rawPath = null;
92
+ if (toolName === "bash") {
93
+ rawPath = parseLeadingCd(typeof args.command === "string" ? args.command : "");
94
+ }
95
+ else if (PATH_TOOLS.has(toolName)) {
96
+ const p = args.path;
97
+ if (typeof p === "string" && p.trim() !== "") {
98
+ rawPath = p;
99
+ }
100
+ }
101
+ if (!rawPath)
102
+ return null;
103
+ const absolute = expandToAbsolute(rawPath, cwd);
104
+ // For path-bearing tools the argument is usually a file; for bash `cd` it is a
105
+ // directory. Resolve to a directory: existing dirs are used as-is, everything else
106
+ // (existing files, not-yet-created files) maps to its parent directory.
107
+ try {
108
+ if (existsSync(absolute) && statSync(absolute).isDirectory()) {
109
+ return absolute;
110
+ }
111
+ }
112
+ catch {
113
+ // Fall through to dirname on permission/stat errors.
114
+ }
115
+ return dirname(absolute);
116
+ }
117
+ /** Safe realpath that falls back to the input on error. */
118
+ function safeRealpath(p) {
119
+ try {
120
+ return realpathSync(p);
121
+ }
122
+ catch {
123
+ return p;
124
+ }
125
+ }
126
+ function isWithin(parent, child) {
127
+ const p = safeRealpath(parent);
128
+ const c = safeRealpath(child);
129
+ return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);
130
+ }
131
+ /**
132
+ * Build the ordered list of directories to inspect, from the target directory up to the
133
+ * appropriate ceiling. Ordered outermost-first so the most specific (closest to the
134
+ * target) context appears last, matching session-start precedence.
135
+ *
136
+ * Ceiling priority:
137
+ * 1. `cwd` — when the target is within the cwd subtree (ancestors already loaded at start).
138
+ * 2. The outermost git repo root in the chain (a directory containing `.git`).
139
+ * 3. The outermost directory containing a CLAUDE.md/AGENTS.md.
140
+ * 4. Hard stop at filesystem root, the depth bound, or a permission/stat failure.
141
+ */
142
+ function resolveWalkDirs(targetDir, cwd) {
143
+ const root = resolve("/");
144
+ // Case 1: target within cwd subtree — never walk above cwd.
145
+ if (isWithin(cwd, targetDir)) {
146
+ const dirs = [];
147
+ let current = targetDir;
148
+ const stop = safeRealpath(cwd);
149
+ for (let i = 0; i < MAX_WALK_DEPTH; i++) {
150
+ dirs.push(current);
151
+ if (safeRealpath(current) === stop)
152
+ break;
153
+ const parent = resolve(current, "..");
154
+ if (parent === current)
155
+ break;
156
+ current = parent;
157
+ }
158
+ return dirs.reverse();
159
+ }
160
+ // Case 2/3/4: target outside cwd — walk to the hard ceiling, recording git roots and
161
+ // directories that hold context files, then bound to the outermost relevant ceiling.
162
+ const chain = [];
163
+ let highestGitRootIdx = -1;
164
+ let highestContextIdx = -1;
165
+ let current = targetDir;
166
+ for (let i = 0; i < MAX_WALK_DEPTH; i++) {
167
+ // A permission/stat failure on the directory itself stops the walk.
168
+ try {
169
+ statSync(current);
170
+ }
171
+ catch {
172
+ break;
173
+ }
174
+ chain.push(current);
175
+ const idx = chain.length - 1;
176
+ try {
177
+ if (existsSync(join(current, ".git")))
178
+ highestGitRootIdx = idx;
179
+ }
180
+ catch {
181
+ // ignore
182
+ }
183
+ if (dirHasContextFile(current))
184
+ highestContextIdx = idx;
185
+ if (current === root)
186
+ break;
187
+ const parent = resolve(current, "..");
188
+ if (parent === current)
189
+ break;
190
+ current = parent;
191
+ }
192
+ let ceilingIdx;
193
+ if (highestGitRootIdx >= 0) {
194
+ ceilingIdx = highestGitRootIdx;
195
+ }
196
+ else if (highestContextIdx >= 0) {
197
+ ceilingIdx = highestContextIdx;
198
+ }
199
+ else {
200
+ ceilingIdx = chain.length - 1;
201
+ }
202
+ return chain.slice(0, ceilingIdx + 1).reverse();
203
+ }
204
+ /** Cheap check: does this directory hold any candidate context file? */
205
+ function dirHasContextFile(dir) {
206
+ for (const c of CONTEXT_FILE_CANDIDATES) {
207
+ try {
208
+ if (existsSync(join(dir, c)))
209
+ return true;
210
+ }
211
+ catch {
212
+ // ignore
213
+ }
214
+ }
215
+ return false;
216
+ }
217
+ /**
218
+ * Collect nested context files for `targetDir`, walking up to the ceiling described in
219
+ * {@link resolveWalkDirs}. Files whose realpath is already in `alreadyLoaded` are skipped
220
+ * (and not re-reported). Newly collected realpaths are added to `alreadyLoaded` so the
221
+ * caller's per-session set stays authoritative and each file loads at most once. Also
222
+ * reports whether an existing context file failed to read so callers can retry later
223
+ * instead of negatively caching a transient failure.
224
+ *
225
+ * When `suppress` matches a newly-seen file, that file is marked loaded but excluded from
226
+ * the returned `files` — the triggering tool result already contains it, so re-injecting
227
+ * would duplicate the content and waste tokens.
228
+ */
229
+ export function collectNestedContext(targetDir, cwd, alreadyLoaded, suppress) {
230
+ const dirs = resolveWalkDirs(targetDir, cwd);
231
+ const collected = [];
232
+ let hadReadError = false;
233
+ for (const dir of dirs) {
234
+ const diagnostics = [];
235
+ const files = loadContextFilesFromDir(dir, diagnostics);
236
+ for (const diagnostic of diagnostics) {
237
+ if (diagnostic.type !== "warning")
238
+ continue;
239
+ hadReadError = true;
240
+ console.warn(`[nested-context] Nested context file existed but could not be read: ${diagnostic.path ?? dir} — ${diagnostic.message}`);
241
+ }
242
+ for (const file of files) {
243
+ const real = safeRealpath(file.path);
244
+ if (alreadyLoaded.has(real))
245
+ continue;
246
+ alreadyLoaded.add(real);
247
+ // Mark loaded but do not inject: the triggering tool already delivers this file.
248
+ if (suppress?.(file))
249
+ continue;
250
+ collected.push(file);
251
+ }
252
+ }
253
+ return { files: collected, hadReadError };
254
+ }
255
+ /**
256
+ * Format collected context files into a single text block for injection into a tool
257
+ * result. Leads with *why* the load happened and headers each file with its source path.
258
+ * There is intentionally no size cap — oversized context files are the project's concern.
259
+ */
260
+ export function formatNestedContextBlock(targetDir, files) {
261
+ const header = `[dreb] Auto-loaded project context\n\n` +
262
+ `A tool just operated in \`${targetDir}\`, whose project context had not been loaded yet. ` +
263
+ `The file(s) below were loaded automatically to prevent missing important project context ` +
264
+ `when working across multiple repos / projects / folders. ` +
265
+ `(Disable with the \`context.autoLoadNested\` setting.)`;
266
+ const sections = files.map((f) => `===== BEGIN project context: ${f.path} =====\n${f.content.trim()}\n===== END project context: ${f.path} =====`);
267
+ return `${header}\n\n${sections.join("\n\n")}`;
268
+ }
269
+ /**
270
+ * Resolve the absolute file path a `read` tool call delivers, or `null` when the tool is
271
+ * not `read` or has no usable `path`. Only `read` returns the *full* file content, so it
272
+ * is the only path-tool whose result fully duplicates an injected context file. (`grep`
273
+ * returns matched lines, `ls`/`edit`/`write` do not echo the whole file — those still
274
+ * benefit from injection.)
275
+ */
276
+ export function resolveSelfReadFile(toolName, args, cwd) {
277
+ if (!args || toolName !== "read")
278
+ return null;
279
+ // A sliced read (`offset`/`limit`) delivers only a fragment of the file — the same
280
+ // hazard for which bash partial viewers (`head`/`tail`) are excluded. Treating it as a
281
+ // full delivery would suppress (and permanently mark loaded) a file the result only
282
+ // partially contains, silently dropping the rest. Fall back to the safe double-load.
283
+ if (args.offset !== undefined || args.limit !== undefined)
284
+ return null;
285
+ const p = args.path;
286
+ if (typeof p !== "string" || p.trim() === "")
287
+ return null;
288
+ return expandToAbsolute(p, cwd);
289
+ }
290
+ /**
291
+ * Bash commands that dump a file's *full* contents to stdout. Deliberately narrow: only
292
+ * commands that emit the whole file qualify. Partial viewers (`head`/`tail`) and
293
+ * interactive pagers (`less`/`more`) are excluded — they may show only a fragment, so
294
+ * treating them as "delivered" could silently drop the rest of a context file. The safe
295
+ * failure mode is a harmless double-load (we still inject), never a silent context drop.
296
+ *
297
+ * `bat` is included but is *not* unconditionally a full dump: its `-r`/`--line-range` flag
298
+ * emits only a range (same hazard as `head`/`tail`). Segments carrying that flag are
299
+ * disqualified in {@link resolveBashDeliveredFiles}.
300
+ */
301
+ const FULL_DUMP_COMMANDS = new Set(["cat", "bat"]);
302
+ /** `bat` flags that limit output to a partial range — disqualify the segment if present. */
303
+ const BAT_RANGE_FLAGS = ["-r", "--line-range"];
304
+ /**
305
+ * Whether a `bat` token requests a partial line range. Matches the space-separated form
306
+ * (`-r`, `--line-range`), the `=`-attached long form (`--line-range=10:20`), and the
307
+ * attached short form (`-r10:20`) — clap accepts an attached value on a short flag, so a
308
+ * bare `startsWith` check on each known range flag covers every spelling. A partial range
309
+ * is the same hazard as `head`/`tail`: only a fragment is emitted, so the segment must not
310
+ * be treated as a full dump.
311
+ */
312
+ function isBatRangeFlag(token) {
313
+ return BAT_RANGE_FLAGS.some((flag) => token.startsWith(flag));
314
+ }
315
+ /** Strip a single layer of matching surrounding quotes from a shell token. */
316
+ function unquoteToken(token) {
317
+ if (token.length >= 2) {
318
+ const first = token[0];
319
+ const last = token[token.length - 1];
320
+ if ((first === '"' || first === "'") && first === last) {
321
+ return token.slice(1, -1);
322
+ }
323
+ }
324
+ return token;
325
+ }
326
+ /**
327
+ * Resolve the absolute paths of files a bash command fully delivers to stdout via a
328
+ * full-dump command (`cat`/`bat`). Path arguments are resolved against `workingDir` (the
329
+ * command's effective cwd — e.g. a leading `cd` target). Conservative on purpose:
330
+ *
331
+ * - Segments are split on `&&`, `||`, `;`. Segments that are *only* a `cd` produce no
332
+ * stdout and are ignored, but there must be **exactly one** remaining output-producing
333
+ * segment. The bash tool truncates its *combined* command output from the **tail**
334
+ * (keeping the last {@link DEFAULT_MAX_LINES} lines / {@link DEFAULT_MAX_BYTES} bytes and
335
+ * dropping the head), so any *additional* output-producing segment could evict the dumped
336
+ * file from the visible window while {@link deliveredInFull} — which measures the file
337
+ * alone — still reports a full delivery. Bail to the safe double-load in that case.
338
+ * - That sole segment must not contain a pipe (`|`), output redirection (`>`), or input
339
+ * redirection / here-doc / here-string (`<`, `<<`, `<<<`) — its output is filtered /
340
+ * redirected, or its operands are stdin body words rather than dumped files.
341
+ * - Its first token must be `cat`/`bat`; flags (`-…`) are ignored.
342
+ * - A `bat` segment carrying a partial-range flag (`-r`/`--line-range`, any spelling) is
343
+ * skipped — it emits only a fragment, like `head`/`tail`.
344
+ * - It must have **exactly one** file operand. A multi-file dump (`cat A.md B.md`)
345
+ * concatenates several files; under tail truncation an earlier operand can be evicted
346
+ * while still appearing fully sized on disk, so it is not a provable full delivery.
347
+ * - If the command chains more than one `cd`, the effective cwd is ambiguous (we only
348
+ * resolved the *first* `cd`), so operands cannot be resolved reliably — return nothing.
349
+ *
350
+ * Anything we cannot confidently classify as a full delivery is omitted, so the worst case
351
+ * is a double-load rather than a silently dropped context file.
352
+ */
353
+ export function resolveBashDeliveredFiles(command, workingDir) {
354
+ if (typeof command !== "string" || command.trim() === "")
355
+ return [];
356
+ const segments = command.split(/&&|\|\||;/);
357
+ const isCdSegment = (s) => /^\s*cd(\s|$)/.test(s);
358
+ // More than one `cd` means the effective cwd differs from the first `cd` target we
359
+ // resolved as `workingDir`; resolving operands against it would suppress the wrong
360
+ // (same-named) file. Bail to the safe double-load.
361
+ if (segments.filter(isCdSegment).length > 1)
362
+ return [];
363
+ // Segments that are only a `cd` emit no stdout. Everything else produces output, and
364
+ // because the bash tool tail-truncates the *combined* output, a context file is only
365
+ // provably delivered in full when it is the command's *sole* output-producing segment.
366
+ const outputSegments = segments.filter((s) => s.trim() !== "" && !isCdSegment(s));
367
+ if (outputSegments.length !== 1)
368
+ return [];
369
+ const segment = outputSegments[0];
370
+ // Output piped/redirected, or operands fed via input redirection / here-doc, are not raw
371
+ // file dumps to stdout.
372
+ if (segment.includes("|") || segment.includes(">") || segment.includes("<"))
373
+ return [];
374
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
375
+ if (tokens.length === 0)
376
+ return [];
377
+ // Match the command verb case-sensitively: shell PATH lookup is case-sensitive on
378
+ // Linux, so `CAT`/`Bat` are command-not-found and emit nothing to stdout. Lowercasing
379
+ // would let them match the allowlist and falsely suppress a file they never printed —
380
+ // a silent context drop. Exact matching keeps the failure mode a harmless double-load.
381
+ const cmd = tokens[0];
382
+ if (!FULL_DUMP_COMMANDS.has(cmd))
383
+ return [];
384
+ // `bat -r 10:20` / `bat -r10:20` / `bat --line-range=10:20` shows only a range — not a full dump.
385
+ if (cmd === "bat" && tokens.slice(1).some(isBatRangeFlag))
386
+ return [];
387
+ const operands = [];
388
+ for (const token of tokens.slice(1)) {
389
+ if (token.startsWith("-"))
390
+ continue; // flag, not a file argument
391
+ const arg = unquoteToken(token);
392
+ if (arg === "")
393
+ continue;
394
+ operands.push(arg);
395
+ }
396
+ // A single operand is the only provable full delivery: multi-file dumps concatenate,
397
+ // and tail truncation can evict an earlier file while it still looks fully sized.
398
+ if (operands.length !== 1)
399
+ return [];
400
+ return [expandToAbsolute(operands[0], workingDir)];
401
+ }
402
+ /**
403
+ * Whether a tool that delivers `realPath` actually delivers its *full* content. Both `read`
404
+ * and `bash` truncate their output at {@link DEFAULT_MAX_LINES} lines / {@link DEFAULT_MAX_BYTES}
405
+ * bytes, while {@link formatNestedContextBlock} is uncapped. If the file exceeds either limit
406
+ * it is delivered truncated, so suppressing (and permanently marking loaded) would silently
407
+ * drop the remainder. Any stat/read failure also returns `false` — the safe double-load.
408
+ *
409
+ * `rendered` selects which delivery the measure must mirror:
410
+ * - `read` delivers the file's raw content unchanged (`truncateHead` with no transform), so
411
+ * the raw byte/line count is exact.
412
+ * - `bash` delivers `truncateTail(renderTerminalOutput(...))`, and terminal rendering expands
413
+ * tabs to 8-column stops and resolves cursor/ANSI sequences — the rendered output can be
414
+ * *larger* than the file on disk. A tab-dense file just under the budget on disk can render
415
+ * past it and be tail-truncated (its head dropped) while the raw measure still reports a
416
+ * full delivery. Measuring the rendered output keeps the failure mode a harmless double-load
417
+ * rather than a silent context drop.
418
+ */
419
+ function deliveredInFull(realPath, rendered) {
420
+ try {
421
+ // Cheap early-out: the raw on-disk size is a lower bound on the delivered size
422
+ // (terminal rendering only ever grows the byte count), so a file already over the
423
+ // byte budget on disk is certainly delivered truncated.
424
+ if (statSync(realPath).size > DEFAULT_MAX_BYTES)
425
+ return false;
426
+ const raw = readFileSync(realPath, "utf8");
427
+ const delivered = rendered ? renderTerminalOutput(raw) : raw;
428
+ if (Buffer.byteLength(delivered, "utf-8") > DEFAULT_MAX_BYTES)
429
+ return false;
430
+ return delivered.split("\n").length <= DEFAULT_MAX_LINES;
431
+ }
432
+ catch {
433
+ return false;
434
+ }
435
+ }
436
+ /**
437
+ * Orchestrate a single nested-context decision for a tool call: gate on the setting,
438
+ * resolve the target directory, skip directories already scanned (negative cache),
439
+ * collect not-yet-loaded context files, and format them. Returns the injection block or
440
+ * `null` when nothing should be injected. Mutates `state.scannedDirs` and `state.loaded`.
441
+ */
442
+ export function computeNestedContextBlock(toolName, args, state) {
443
+ if (!state.enabled)
444
+ return null;
445
+ const targetDir = resolveTargetDir(toolName, args, state.cwd);
446
+ if (!targetDir)
447
+ return null;
448
+ const realTarget = safeRealpath(targetDir);
449
+ if (state.scannedDirs.has(realTarget))
450
+ return null;
451
+ // A context file the triggering tool already delivers should be marked loaded but not
452
+ // re-injected (the result already contains it). Two cases: a `read` of the file itself,
453
+ // or a `bash` command that dumps its full contents (`cat`/`bat`). Both are matched by
454
+ // full resolved realpath — never by basename — so printing one file never suppresses a
455
+ // same-named sibling/ancestor or a file in a different directory.
456
+ const selfReadFile = resolveSelfReadFile(toolName, args, state.cwd);
457
+ const realSelfReadFile = selfReadFile ? safeRealpath(selfReadFile) : null;
458
+ const bashCommand = toolName === "bash" && typeof args?.command === "string" ? args.command : null;
459
+ // Bash file arguments resolve against the command's effective cwd, which `resolveTargetDir`
460
+ // has already computed as `targetDir` (the leading `cd` destination).
461
+ const bashDelivered = bashCommand
462
+ ? new Set(resolveBashDeliveredFiles(bashCommand, targetDir).map(safeRealpath))
463
+ : null;
464
+ const suppress = (file) => {
465
+ const realFile = safeRealpath(file.path);
466
+ // Only suppress when the file was delivered *in full*: a truncated delivery (oversized
467
+ // file) would drop the remainder if we marked it fully loaded and skipped injection.
468
+ // `read` delivers the file's raw content unchanged; `bash` delivers it through terminal
469
+ // rendering (tab/ANSI expansion can grow it past the truncation budget), so each path
470
+ // measures fullness against what it actually emits.
471
+ if (realFile === realSelfReadFile)
472
+ return deliveredInFull(realFile, false);
473
+ if (bashDelivered?.has(realFile))
474
+ return deliveredInFull(realFile, true);
475
+ return false;
476
+ };
477
+ const collected = collectNestedContext(targetDir, state.cwd, state.loaded, suppress);
478
+ if (!collected.hadReadError) {
479
+ state.scannedDirs.add(realTarget);
480
+ }
481
+ if (collected.files.length === 0)
482
+ return null;
483
+ return formatNestedContextBlock(targetDir, collected.files);
484
+ }
485
+ //# sourceMappingURL=nested-context.js.map