{"version":3,"file":"context-files.d.ts","sourceRoot":"","sources":["../../src/core/context-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAOH;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,IAAI,CAAC,EAAE,OAAO,GAAG,WAAW,CAAC;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAerG;AAkDD,MAAM,WAAW,8BAA8B;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,8BAA8B,GAAG;IACjF,WAAW,EAAE,WAAW,EAAE,CAAC;IAC3B,QAAQ,EAAE,MAAM,EAAE,CAAC;CACnB,CA8CA","sourcesContent":["/**\n * Context-file and prompt-input loading for the resource loader.\n *\n * Reads AGENTS.md / CLAUDE.md context files from the user scopes and the cwd\n * ancestor chain (warning/truncating oversized ones, since they are injected\n * into the system prompt every turn), and resolves a system-prompt input that\n * may be either an inline string or a file path. Extracted from resource-loader.ts.\n *\n * Two user scopes are read, least specific first: `~/.agents/AGENTS.md` (the\n * cross-vendor convention, so rules written for one tool are seen by hoocode)\n * and `~/.hoocode/AGENTS.md` (the native home, which therefore wins on\n * conflict). Both are additive — neither shadows the other, and no migration\n * is needed for users who already have the native file.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport chalk from \"chalk\";\nimport { getUserAgentsDir } from \"../config.js\";\n\n/**\n * A loaded context file plus the recurring-cost metadata the UI surfaces.\n * `tokens`/`size` are optional so callers that synthesize context files (SDK\n * overrides, tests) can keep passing plain `{ path, content }`.\n */\nexport interface ContextFile {\n\tpath: string;\n\tcontent: string;\n\t/** Rough token estimate (bytes / 4) of the content as loaded. */\n\ttokens?: number;\n\t/** Set only past the soft limit; \"truncated\" means the hard limit clipped it. */\n\tsize?: \"large\" | \"truncated\";\n}\n\n/**\n * Resolve a prompt input that is either an inline string or a path to a file.\n * If the input names an existing file, its contents are returned; otherwise the\n * input is treated as the prompt text itself.\n */\nexport function resolvePromptInput(input: string | undefined, description: string): string | undefined {\n\tif (!input) {\n\t\treturn undefined;\n\t}\n\n\tif (existsSync(input)) {\n\t\ttry {\n\t\t\treturn readFileSync(input, \"utf-8\");\n\t\t} catch (error) {\n\t\t\tconsole.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));\n\t\t\treturn input;\n\t\t}\n\t}\n\n\treturn input;\n}\n\n// Context files (AGENTS.md / CLAUDE.md) are injected into the system prompt on\n// every turn, so their size has a recurring cost on every provider. Warn the\n// user past a soft limit (~2k tokens) and truncate at a hard limit (~10k tokens)\n// so a pasted spec can't silently bloat every request forever.\nconst CONTEXT_FILE_WARN_BYTES = 8 * 1024;\nconst CONTEXT_FILE_MAX_BYTES = 40 * 1024;\n\n// The per-file limits above cap one file; they say nothing about the total,\n// and the set can now stack two user scopes on top of a whole ancestor chain.\n// So the aggregate gets its own budget, deliberately warn-first: past the soft\n// cap (~6k tokens) the user is told what it costs, and only past the hard cap\n// (~16k tokens) is anything trimmed. Trimming should be the branch that never\n// runs in practice.\nconst CONTEXT_TOTAL_WARN_BYTES = 24 * 1024;\nconst CONTEXT_TOTAL_MAX_BYTES = 64 * 1024;\n// Below this, a trimmed file has no room left to say anything useful, so it is\n// replaced by the notice alone rather than a few words of severed prose.\nconst CONTEXT_TRIM_MIN_BYTES = 512;\n\nfunction loadContextFileFromDir(dir: string): { file: ContextFile | null; warnings: string[] } {\n\tconst warnings: string[] = [];\n\tconst candidates = [\"AGENTS.md\", \"AGENTS.MD\", \"CLAUDE.md\", \"CLAUDE.MD\"];\n\tfor (const filename of candidates) {\n\t\tconst filePath = join(dir, filename);\n\t\tif (existsSync(filePath)) {\n\t\t\ttry {\n\t\t\t\tlet content = readFileSync(filePath, \"utf-8\");\n\t\t\t\tconst bytes = Buffer.byteLength(content, \"utf-8\");\n\t\t\t\t// Size is reported structurally (not as a warning string) so the UI can\n\t\t\t\t// annotate the file where it is already listed instead of repeating it.\n\t\t\t\tlet size: ContextFile[\"size\"];\n\t\t\t\tif (bytes > CONTEXT_FILE_MAX_BYTES) {\n\t\t\t\t\tcontent =\n\t\t\t\t\t\tcontent.slice(0, CONTEXT_FILE_MAX_BYTES) +\n\t\t\t\t\t\t`\\n\\n[truncated: file exceeded ${CONTEXT_FILE_MAX_BYTES} bytes (~10k tokens); keep context files brief — large specs belong in linked files, not in the system prompt]`;\n\t\t\t\t\tsize = \"truncated\";\n\t\t\t\t} else if (bytes > CONTEXT_FILE_WARN_BYTES) {\n\t\t\t\t\tsize = \"large\";\n\t\t\t\t}\n\t\t\t\treturn { file: { path: filePath, content, tokens: Math.round(bytes / 4), size }, warnings };\n\t\t\t} catch (error) {\n\t\t\t\twarnings.push(`Could not read ${filePath}: ${error}`);\n\t\t\t}\n\t\t}\n\t}\n\treturn { file: null, warnings };\n}\n\nexport interface LoadProjectContextFilesOptions {\n\tcwd: string;\n\t/** hoocode's native home, e.g. `~/.hoocode`. */\n\tagentDir: string;\n\t/**\n\t * The cross-vendor user scope, e.g. `~/.agents`. Defaults to `~/.agents`;\n\t * injectable so tests do not read the real home directory.\n\t */\n\tuserAgentsDir?: string;\n}\n\nexport function loadProjectContextFiles(options: LoadProjectContextFilesOptions): {\n\tagentsFiles: ContextFile[];\n\twarnings: string[];\n} {\n\tconst resolvedCwd = options.cwd;\n\tconst resolvedAgentDir = options.agentDir;\n\tconst resolvedUserAgentsDir = options.userAgentsDir ?? getUserAgentsDir();\n\n\tconst contextFiles: ContextFile[] = [];\n\tconst warnings: string[] = [];\n\tconst seenPaths = new Set<string>();\n\n\t// Least specific first: the cross-vendor scope, then the native home. A file\n\t// already seen is never added twice, so pointing both at one directory is a\n\t// no-op rather than a doubled prompt.\n\tfor (const dir of [resolvedUserAgentsDir, resolvedAgentDir]) {\n\t\tconst result = loadContextFileFromDir(dir);\n\t\tif (result.file && !seenPaths.has(result.file.path)) {\n\t\t\tcontextFiles.push(result.file);\n\t\t\tseenPaths.add(result.file.path);\n\t\t}\n\t\twarnings.push(...result.warnings);\n\t}\n\n\tconst ancestorContextFiles: ContextFile[] = [];\n\n\tlet currentDir = resolvedCwd;\n\tconst root = resolve(\"/\");\n\n\twhile (true) {\n\t\tconst result = loadContextFileFromDir(currentDir);\n\t\tif (result.file && !seenPaths.has(result.file.path)) {\n\t\t\tancestorContextFiles.unshift(result.file);\n\t\t\tseenPaths.add(result.file.path);\n\t\t}\n\t\twarnings.push(...result.warnings);\n\n\t\tif (currentDir === root) break;\n\n\t\tconst parentDir = resolve(currentDir, \"..\");\n\t\tif (parentDir === currentDir) break;\n\t\tcurrentDir = parentDir;\n\t}\n\n\tcontextFiles.push(...ancestorContextFiles);\n\n\twarnings.push(...enforceTotalBudget(contextFiles));\n\n\treturn { agentsFiles: contextFiles, warnings };\n}\n\n/**\n * Apply the aggregate budget to an already-ordered context-file list.\n *\n * Mutates entries in place and returns any warnings. The list is ordered least\n * specific first, and specificity is what decides who pays: the walk runs from\n * the end so the file nearest the work keeps its budget, and a user-scope file\n * is trimmed before a repo one. Files are trimmed rather than dropped, so a\n * file that stops fitting still says so in the prompt instead of vanishing.\n */\nfunction enforceTotalBudget(files: ContextFile[]): string[] {\n\tconst warnings: string[] = [];\n\tconst total = files.reduce((sum, file) => sum + Buffer.byteLength(file.content, \"utf-8\"), 0);\n\tif (total <= CONTEXT_TOTAL_WARN_BYTES) {\n\t\treturn warnings;\n\t}\n\n\tif (total <= CONTEXT_TOTAL_MAX_BYTES) {\n\t\t// A single oversized file is already priced by the per-file rule, which\n\t\t// flags it `large` and annotates it where it is listed. Repeating that as\n\t\t// an aggregate warning says nothing new. The aggregate exists to catch\n\t\t// what per-file checks structurally cannot see: the sum across scopes.\n\t\tif (files.length < 2) return warnings;\n\t\twarnings.push(\n\t\t\t`Context files total ~${Math.round(total / 4 / 100) / 10}k tokens across ${files.length} file(s), ` +\n\t\t\t\t`re-sent on every request. Keep rules to one line each, and move long or conditional guidance into a skill ` +\n\t\t\t\t`(loaded on demand) rather than a context file (loaded always).`,\n\t\t);\n\t\treturn warnings;\n\t}\n\n\tlet remaining = CONTEXT_TOTAL_MAX_BYTES;\n\tfor (let i = files.length - 1; i >= 0; i--) {\n\t\tconst file = files[i]!;\n\t\tconst bytes = Buffer.byteLength(file.content, \"utf-8\");\n\t\tif (bytes <= remaining) {\n\t\t\tremaining -= bytes;\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst notice =\n\t\t\t`\\n\\n[trimmed: context files exceeded ${CONTEXT_TOTAL_MAX_BYTES} bytes (~16k tokens) in total; ` +\n\t\t\t`least-specific scopes are trimmed first — move long guidance into a skill]`;\n\t\tfile.content =\n\t\t\tremaining >= CONTEXT_TRIM_MIN_BYTES ? file.content.slice(0, remaining) + notice : notice.trimStart();\n\t\tfile.size = \"truncated\";\n\t\tfile.tokens = Math.round(Buffer.byteLength(file.content, \"utf-8\") / 4);\n\t\tremaining = 0;\n\t\twarnings.push(`Trimmed ${file.path} — context files exceeded the total budget.`);\n\t}\n\n\treturn warnings;\n}\n"]}