@dreb/coding-agent 2.30.1 → 2.31.0

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,60 @@
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
+ * Collect nested context files for `targetDir`, walking up to the ceiling described in
29
+ * {@link resolveWalkDirs}. Files whose realpath is already in `alreadyLoaded` are skipped
30
+ * (and not re-reported). Newly collected realpaths are added to `alreadyLoaded` so the
31
+ * caller's per-session set stays authoritative and each file loads at most once. Also
32
+ * reports whether an existing context file failed to read so callers can retry later
33
+ * instead of negatively caching a transient failure.
34
+ */
35
+ export declare function collectNestedContext(targetDir: string, cwd: string, alreadyLoaded: Set<string>): NestedContextCollection;
36
+ /**
37
+ * Format collected context files into a single text block for injection into a tool
38
+ * result. Leads with *why* the load happened and headers each file with its source path.
39
+ * There is intentionally no size cap — oversized context files are the project's concern.
40
+ */
41
+ export declare function formatNestedContextBlock(targetDir: string, files: LoadedContextFile[]): string;
42
+ /** Mutable per-session state threaded through {@link computeNestedContextBlock}. */
43
+ export interface NestedContextState {
44
+ /** Whether auto-loading is enabled (the `context.autoLoadNested` setting). */
45
+ enabled: boolean;
46
+ /** The session's working directory. */
47
+ cwd: string;
48
+ /** Realpaths of context files already loaded this session (seeded at session start). Mutated. */
49
+ loaded: Set<string>;
50
+ /** Realpaths of directories already scanned (negative cache). Mutated. */
51
+ scannedDirs: Set<string>;
52
+ }
53
+ /**
54
+ * Orchestrate a single nested-context decision for a tool call: gate on the setting,
55
+ * resolve the target directory, skip directories already scanned (negative cache),
56
+ * collect not-yet-loaded context files, and format them. Returns the injection block or
57
+ * `null` when nothing should be injected. Mutates `state.scannedDirs` and `state.loaded`.
58
+ */
59
+ export declare function computeNestedContextBlock(toolName: string, args: Record<string, unknown> | undefined, state: NestedContextState): string | null;
60
+ //# 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":"AAsBA,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;AAED;;;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,CAoCf;AAkGD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CACnC,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,GACxB,uBAAuB,CAsBzB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,EAAE,GAAG,MAAM,CAc9F;AAED,oFAAoF;AACpF,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;AAED;;;;;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,CAef","sourcesContent":["import { existsSync, 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\";\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 * 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\t// Expand a leading `~` to the home directory.\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\n\tconst absolute = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);\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 * 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 */\nexport function collectNestedContext(\n\ttargetDir: string,\n\tcwd: string,\n\talreadyLoaded: Set<string>,\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\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/** Mutable per-session state threaded through {@link computeNestedContextBlock}. */\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 * 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\tconst collected = collectNestedContext(targetDir, state.cwd, state.loaded);\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,276 @@
1
+ import { existsSync, 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
+ /**
6
+ * Auto-load of nested AGENTS.md/CLAUDE.md context files.
7
+ *
8
+ * Project context files are only loaded at session start by walking *upward* from
9
+ * `cwd`. When the agent (or a subagent) operates in a subdirectory — or in an entirely
10
+ * different repo/project — that directory's context files are never loaded. This module
11
+ * detects the directory a tool is about to operate in, walks up to a sensible ceiling
12
+ * collecting context files, and returns a formatted block for injection into the tool
13
+ * result (which is cache-safe — it does not rebuild the system prompt).
14
+ */
15
+ /** A safety bound on how many directories the upward walk will visit. */
16
+ const MAX_WALK_DEPTH = 64;
17
+ /** Tools whose `path` argument identifies the directory being operated on. */
18
+ const PATH_TOOLS = new Set(["read", "edit", "write", "grep", "find", "ls"]);
19
+ /**
20
+ * Extract the target of a leading `cd <dir>` from a bash command.
21
+ *
22
+ * Covers the overwhelming majority of directory-changing bash commands (analysis of
23
+ * real session logs: ~75% of bash calls start with `cd`, ~97% of those with an absolute
24
+ * path). Returns the raw, unresolved path string (with a leading `~` preserved) or
25
+ * `null` when the command does not begin with a simple `cd`.
26
+ */
27
+ export function parseLeadingCd(command) {
28
+ if (typeof command !== "string")
29
+ return null;
30
+ const leadingCd = command.match(/^\s*cd\s+/);
31
+ if (!leadingCd)
32
+ return null;
33
+ let rest = command.slice(leadingCd[0].length);
34
+ while (true) {
35
+ // Match either a quoted path or an unquoted token that stops at the first shell
36
+ // separator (&&, ;, |, newline) or whitespace.
37
+ const match = rest.match(/^\s*(?:"([^"]+)"|'([^']+)'|([^\s&;|<>]+))/);
38
+ if (!match)
39
+ return null;
40
+ const target = (match[1] ?? match[2] ?? match[3] ?? "").trim();
41
+ if (!target)
42
+ return null;
43
+ // `cd -` means "previous directory" and cannot be resolved cheaply.
44
+ if (target === "-")
45
+ return null;
46
+ // Skip leading options (`cd -P /x`, `cd -L /x`). After `--`, the next token is
47
+ // the path even if it begins with `-`.
48
+ if (target === "--") {
49
+ rest = rest.slice(match[0].length);
50
+ const pathMatch = rest.match(/^\s*(?:"([^"]+)"|'([^']+)'|([^\s&;|<>]+))/);
51
+ if (!pathMatch)
52
+ return null;
53
+ const pathTarget = (pathMatch[1] ?? pathMatch[2] ?? pathMatch[3] ?? "").trim();
54
+ if (!pathTarget || pathTarget.startsWith("$"))
55
+ return null;
56
+ return pathTarget;
57
+ }
58
+ if (target.startsWith("-")) {
59
+ rest = rest.slice(match[0].length);
60
+ continue;
61
+ }
62
+ // Skip variable-based targets we cannot resolve cheaply.
63
+ if (target.startsWith("$"))
64
+ return null;
65
+ return target;
66
+ }
67
+ }
68
+ /**
69
+ * Resolve the absolute directory a tool call is about to operate in, or `null` when the
70
+ * tool/argument shape does not identify a directory we should react to.
71
+ */
72
+ export function resolveTargetDir(toolName, args, cwd) {
73
+ if (!args)
74
+ return null;
75
+ let rawPath = null;
76
+ if (toolName === "bash") {
77
+ rawPath = parseLeadingCd(typeof args.command === "string" ? args.command : "");
78
+ }
79
+ else if (PATH_TOOLS.has(toolName)) {
80
+ const p = args.path;
81
+ if (typeof p === "string" && p.trim() !== "") {
82
+ rawPath = p;
83
+ }
84
+ }
85
+ if (!rawPath)
86
+ return null;
87
+ // Expand a leading `~` to the home directory.
88
+ if (rawPath === "~") {
89
+ rawPath = homedir();
90
+ }
91
+ else if (rawPath.startsWith(`~${sep}`) || rawPath.startsWith("~/")) {
92
+ rawPath = join(homedir(), rawPath.slice(2));
93
+ }
94
+ const absolute = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
95
+ // For path-bearing tools the argument is usually a file; for bash `cd` it is a
96
+ // directory. Resolve to a directory: existing dirs are used as-is, everything else
97
+ // (existing files, not-yet-created files) maps to its parent directory.
98
+ try {
99
+ if (existsSync(absolute) && statSync(absolute).isDirectory()) {
100
+ return absolute;
101
+ }
102
+ }
103
+ catch {
104
+ // Fall through to dirname on permission/stat errors.
105
+ }
106
+ return dirname(absolute);
107
+ }
108
+ /** Safe realpath that falls back to the input on error. */
109
+ function safeRealpath(p) {
110
+ try {
111
+ return realpathSync(p);
112
+ }
113
+ catch {
114
+ return p;
115
+ }
116
+ }
117
+ function isWithin(parent, child) {
118
+ const p = safeRealpath(parent);
119
+ const c = safeRealpath(child);
120
+ return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);
121
+ }
122
+ /**
123
+ * Build the ordered list of directories to inspect, from the target directory up to the
124
+ * appropriate ceiling. Ordered outermost-first so the most specific (closest to the
125
+ * target) context appears last, matching session-start precedence.
126
+ *
127
+ * Ceiling priority:
128
+ * 1. `cwd` — when the target is within the cwd subtree (ancestors already loaded at start).
129
+ * 2. The outermost git repo root in the chain (a directory containing `.git`).
130
+ * 3. The outermost directory containing a CLAUDE.md/AGENTS.md.
131
+ * 4. Hard stop at filesystem root, the depth bound, or a permission/stat failure.
132
+ */
133
+ function resolveWalkDirs(targetDir, cwd) {
134
+ const root = resolve("/");
135
+ // Case 1: target within cwd subtree — never walk above cwd.
136
+ if (isWithin(cwd, targetDir)) {
137
+ const dirs = [];
138
+ let current = targetDir;
139
+ const stop = safeRealpath(cwd);
140
+ for (let i = 0; i < MAX_WALK_DEPTH; i++) {
141
+ dirs.push(current);
142
+ if (safeRealpath(current) === stop)
143
+ break;
144
+ const parent = resolve(current, "..");
145
+ if (parent === current)
146
+ break;
147
+ current = parent;
148
+ }
149
+ return dirs.reverse();
150
+ }
151
+ // Case 2/3/4: target outside cwd — walk to the hard ceiling, recording git roots and
152
+ // directories that hold context files, then bound to the outermost relevant ceiling.
153
+ const chain = [];
154
+ let highestGitRootIdx = -1;
155
+ let highestContextIdx = -1;
156
+ let current = targetDir;
157
+ for (let i = 0; i < MAX_WALK_DEPTH; i++) {
158
+ // A permission/stat failure on the directory itself stops the walk.
159
+ try {
160
+ statSync(current);
161
+ }
162
+ catch {
163
+ break;
164
+ }
165
+ chain.push(current);
166
+ const idx = chain.length - 1;
167
+ try {
168
+ if (existsSync(join(current, ".git")))
169
+ highestGitRootIdx = idx;
170
+ }
171
+ catch {
172
+ // ignore
173
+ }
174
+ if (dirHasContextFile(current))
175
+ highestContextIdx = idx;
176
+ if (current === root)
177
+ break;
178
+ const parent = resolve(current, "..");
179
+ if (parent === current)
180
+ break;
181
+ current = parent;
182
+ }
183
+ let ceilingIdx;
184
+ if (highestGitRootIdx >= 0) {
185
+ ceilingIdx = highestGitRootIdx;
186
+ }
187
+ else if (highestContextIdx >= 0) {
188
+ ceilingIdx = highestContextIdx;
189
+ }
190
+ else {
191
+ ceilingIdx = chain.length - 1;
192
+ }
193
+ return chain.slice(0, ceilingIdx + 1).reverse();
194
+ }
195
+ /** Cheap check: does this directory hold any candidate context file? */
196
+ function dirHasContextFile(dir) {
197
+ for (const c of CONTEXT_FILE_CANDIDATES) {
198
+ try {
199
+ if (existsSync(join(dir, c)))
200
+ return true;
201
+ }
202
+ catch {
203
+ // ignore
204
+ }
205
+ }
206
+ return false;
207
+ }
208
+ /**
209
+ * Collect nested context files for `targetDir`, walking up to the ceiling described in
210
+ * {@link resolveWalkDirs}. Files whose realpath is already in `alreadyLoaded` are skipped
211
+ * (and not re-reported). Newly collected realpaths are added to `alreadyLoaded` so the
212
+ * caller's per-session set stays authoritative and each file loads at most once. Also
213
+ * reports whether an existing context file failed to read so callers can retry later
214
+ * instead of negatively caching a transient failure.
215
+ */
216
+ export function collectNestedContext(targetDir, cwd, alreadyLoaded) {
217
+ const dirs = resolveWalkDirs(targetDir, cwd);
218
+ const collected = [];
219
+ let hadReadError = false;
220
+ for (const dir of dirs) {
221
+ const diagnostics = [];
222
+ const files = loadContextFilesFromDir(dir, diagnostics);
223
+ for (const diagnostic of diagnostics) {
224
+ if (diagnostic.type !== "warning")
225
+ continue;
226
+ hadReadError = true;
227
+ console.warn(`[nested-context] Nested context file existed but could not be read: ${diagnostic.path ?? dir} — ${diagnostic.message}`);
228
+ }
229
+ for (const file of files) {
230
+ const real = safeRealpath(file.path);
231
+ if (alreadyLoaded.has(real))
232
+ continue;
233
+ alreadyLoaded.add(real);
234
+ collected.push(file);
235
+ }
236
+ }
237
+ return { files: collected, hadReadError };
238
+ }
239
+ /**
240
+ * Format collected context files into a single text block for injection into a tool
241
+ * result. Leads with *why* the load happened and headers each file with its source path.
242
+ * There is intentionally no size cap — oversized context files are the project's concern.
243
+ */
244
+ export function formatNestedContextBlock(targetDir, files) {
245
+ const header = `[dreb] Auto-loaded project context\n\n` +
246
+ `A tool just operated in \`${targetDir}\`, whose project context had not been loaded yet. ` +
247
+ `The file(s) below were loaded automatically to prevent missing important project context ` +
248
+ `when working across multiple repos / projects / folders. ` +
249
+ `(Disable with the \`context.autoLoadNested\` setting.)`;
250
+ const sections = files.map((f) => `===== BEGIN project context: ${f.path} =====\n${f.content.trim()}\n===== END project context: ${f.path} =====`);
251
+ return `${header}\n\n${sections.join("\n\n")}`;
252
+ }
253
+ /**
254
+ * Orchestrate a single nested-context decision for a tool call: gate on the setting,
255
+ * resolve the target directory, skip directories already scanned (negative cache),
256
+ * collect not-yet-loaded context files, and format them. Returns the injection block or
257
+ * `null` when nothing should be injected. Mutates `state.scannedDirs` and `state.loaded`.
258
+ */
259
+ export function computeNestedContextBlock(toolName, args, state) {
260
+ if (!state.enabled)
261
+ return null;
262
+ const targetDir = resolveTargetDir(toolName, args, state.cwd);
263
+ if (!targetDir)
264
+ return null;
265
+ const realTarget = safeRealpath(targetDir);
266
+ if (state.scannedDirs.has(realTarget))
267
+ return null;
268
+ const collected = collectNestedContext(targetDir, state.cwd, state.loaded);
269
+ if (!collected.hadReadError) {
270
+ state.scannedDirs.add(realTarget);
271
+ }
272
+ if (collected.files.length === 0)
273
+ return null;
274
+ return formatNestedContextBlock(targetDir, collected.files);
275
+ }
276
+ //# sourceMappingURL=nested-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nested-context.js","sourceRoot":"","sources":["../../src/core/nested-context.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, 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\";\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 * 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\t// Expand a leading `~` to the home directory.\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\n\tconst absolute = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);\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 * 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 */\nexport function collectNestedContext(\n\ttargetDir: string,\n\tcwd: string,\n\talreadyLoaded: Set<string>,\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\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/** Mutable per-session state threaded through {@link computeNestedContextBlock}. */\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 * 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\tconst collected = collectNestedContext(targetDir, state.cwd, state.loaded);\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"]}
@@ -63,6 +63,11 @@ export interface ResourceLoader {
63
63
  extendResources(paths: ResourceExtensionPaths): void;
64
64
  reload(): Promise<void>;
65
65
  }
66
+ export declare const CONTEXT_FILE_CANDIDATES: readonly ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD", string, string, string, string];
67
+ export declare function loadContextFilesFromDir(dir: string, diagnostics?: ResourceDiagnostic[]): Array<{
68
+ path: string;
69
+ content: string;
70
+ }>;
66
71
  /**
67
72
  * Encode an absolute POSIX path the way Claude Code does for ~/.claude/projects/ directories.
68
73
  * Replaces path separators and underscores with hyphens.