@kolisachint/hoocode-agent 0.4.139 → 0.4.140

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,255 @@
1
+ /**
2
+ * Native (pure-JS) fallbacks for the `find` and `grep` tools.
3
+ *
4
+ * The tools normally shell out to `fd` / `rg`, which are downloaded on demand
5
+ * (see tools-manager.ts). In restricted environments those binaries may be
6
+ * neither on PATH nor downloadable, and `HOOCODE_NATIVE_SEARCH=1` can also force
7
+ * this path. Rather than failing the tool and asking the model to fall back to
8
+ * `bash`, these functions reproduce the essential behaviour in JS so search
9
+ * degrades automatically:
10
+ *
11
+ * - hierarchical `.gitignore` handling (each `.gitignore` is scoped to its own
12
+ * subtree, matching fd's `--no-require-git` behaviour and issue #3303),
13
+ * - hidden files included (like `fd --hidden` / `rg --hidden`),
14
+ * - `.git` always skipped; `node_modules` skipped for `find` (mirrors the
15
+ * tool's built-in excludes) but left to `.gitignore` for `grep` (like rg).
16
+ *
17
+ * These are best-effort approximations, not byte-for-byte fd/rg parity: globs
18
+ * are matched with `minimatch` and patterns with JS `RegExp`, and only
19
+ * `.gitignore` files are honoured (not `.ignore` or global excludes).
20
+ */
21
+ import { readdirSync, readFileSync, statSync } from "fs";
22
+ import ignore from "ignore";
23
+ import { minimatch } from "minimatch";
24
+ import path from "path";
25
+ import { toPosixPath } from "./fd-utils.js";
26
+ /** Hard cap on entries enumerated during a single walk, so a pathological tree
27
+ * can never hang the fallback. Well above any tool's own result limit. */
28
+ const MAX_ENTRIES = 200_000;
29
+ /** Files larger than this are skipped by the grep fallback (rg streams; we read
30
+ * whole files, so we guard against loading huge blobs into memory). */
31
+ const MAX_GREP_FILE_BYTES = 20 * 1024 * 1024;
32
+ function loadGitignore(dir) {
33
+ let content;
34
+ try {
35
+ content = readFileSync(path.join(dir, ".gitignore"), "utf-8");
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ return ignore().add(content);
41
+ }
42
+ /**
43
+ * Whether `absPath` is ignored by any applicable `.gitignore`. Each matcher only
44
+ * applies to paths inside its `baseDir`, and the path is tested relative to that
45
+ * base — so `a/.gitignore` scopes to `a/` and its descendants but never `b/`.
46
+ */
47
+ function isGitIgnored(absPath, isDir, matchers) {
48
+ for (const m of matchers) {
49
+ const rel = path.relative(m.baseDir, absPath);
50
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
51
+ continue;
52
+ const relPosix = toPosixPath(rel) + (isDir ? "/" : "");
53
+ if (m.ig.ignores(relPosix))
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+ /**
59
+ * Walk `root` depth-first, returning every entry not excluded by `.gitignore`
60
+ * or an always-skip directory. Symlinks are reported but never followed (avoids
61
+ * cycles). Enumeration stops at {@link MAX_ENTRIES}.
62
+ */
63
+ export function collectEntries(root, opts = {}) {
64
+ const out = [];
65
+ const alwaysSkip = opts.alwaysSkipDirs ?? new Set([".git"]);
66
+ const rootMatchers = [];
67
+ const rootIg = loadGitignore(root);
68
+ if (rootIg)
69
+ rootMatchers.push({ baseDir: root, ig: rootIg });
70
+ // `depth` is the depth of `dir`; its direct children are at depth + 1.
71
+ const stack = [
72
+ { dir: root, depth: 0, matchers: rootMatchers },
73
+ ];
74
+ while (stack.length > 0) {
75
+ if (out.length >= MAX_ENTRIES || opts.signal?.aborted)
76
+ break;
77
+ const { dir, depth, matchers } = stack.pop();
78
+ let dirents;
79
+ try {
80
+ dirents = readdirSync(dir, { withFileTypes: true });
81
+ }
82
+ catch {
83
+ continue; // unreadable directory — skip rather than abort the whole walk
84
+ }
85
+ const entryDepth = depth + 1;
86
+ for (const dirent of dirents) {
87
+ if (out.length >= MAX_ENTRIES)
88
+ break;
89
+ const name = dirent.name;
90
+ const abs = path.join(dir, name);
91
+ let type;
92
+ let isDir = false;
93
+ if (dirent.isSymbolicLink()) {
94
+ type = "l";
95
+ }
96
+ else if (dirent.isDirectory()) {
97
+ type = "d";
98
+ isDir = true;
99
+ }
100
+ else if (dirent.isFile()) {
101
+ type = "f";
102
+ }
103
+ else {
104
+ continue; // sockets, fifos, block devices, …
105
+ }
106
+ if (isDir && alwaysSkip.has(name))
107
+ continue;
108
+ if (isGitIgnored(abs, isDir, matchers))
109
+ continue;
110
+ if (opts.maxDepth === undefined || entryDepth <= opts.maxDepth) {
111
+ out.push({ abs, rel: toPosixPath(path.relative(root, abs)), type });
112
+ }
113
+ // Descend only into real directories, and only if their children can
114
+ // still be within the depth budget.
115
+ if (isDir && (opts.maxDepth === undefined || entryDepth < opts.maxDepth)) {
116
+ const childIg = loadGitignore(abs);
117
+ const nextMatchers = childIg ? [...matchers, { baseDir: abs, ig: childIg }] : matchers;
118
+ stack.push({ dir: abs, depth: entryDepth, matchers: nextMatchers });
119
+ }
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+ /**
125
+ * Match a `find` glob against a POSIX relative path, mirroring fd's semantics:
126
+ * a slashless pattern matches the basename at any depth; a pattern containing a
127
+ * slash matches the full path and is anchored anywhere in the tree.
128
+ */
129
+ function matchesFindPattern(relPosix, pattern) {
130
+ if (pattern.includes("/")) {
131
+ let p = pattern;
132
+ if (p.startsWith("/")) {
133
+ p = p.slice(1); // leading slash anchors to root; rel paths have no leading slash
134
+ }
135
+ else if (!p.startsWith("**/") && p !== "**") {
136
+ p = `**/${p}`;
137
+ }
138
+ return minimatch(relPosix, p, { dot: true });
139
+ }
140
+ return minimatch(relPosix, pattern, { dot: true, matchBase: true });
141
+ }
142
+ /**
143
+ * Native replacement for the fd-backed search. Returns POSIX paths relative to
144
+ * `root`, with a trailing slash on directories (like fd), unsorted/undeduped —
145
+ * the caller applies its own dedupe/sort/limit.
146
+ */
147
+ export function nativeFind(root, opts) {
148
+ const entries = collectEntries(root, {
149
+ maxDepth: opts.maxDepth,
150
+ signal: opts.signal,
151
+ alwaysSkipDirs: opts.alwaysSkipDirs,
152
+ });
153
+ const results = [];
154
+ for (const entry of entries) {
155
+ if (entry.type !== opts.type)
156
+ continue;
157
+ if (opts.excludeGlobs.some((g) => minimatch(entry.rel, g, { dot: true })))
158
+ continue;
159
+ if (!opts.patterns.some((pat) => matchesFindPattern(entry.rel, pat)))
160
+ continue;
161
+ results.push(entry.type === "d" ? `${entry.rel}/` : entry.rel);
162
+ }
163
+ return results;
164
+ }
165
+ function escapeRegExp(value) {
166
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
167
+ }
168
+ /** Cheap binary-file heuristic: a NUL byte in the first chunk. rg skips these. */
169
+ function looksBinary(content) {
170
+ const sampleLength = Math.min(content.length, 8192);
171
+ for (let i = 0; i < sampleLength; i++) {
172
+ if (content.charCodeAt(i) === 0)
173
+ return true;
174
+ }
175
+ return false;
176
+ }
177
+ /**
178
+ * Native replacement for the rg-backed content search. Collects up to `limit`
179
+ * matches as `{ filePath, lineNumber, lineText }`, the same shape the tool's
180
+ * formatter already consumes from rg's JSON output.
181
+ *
182
+ * Throws an Error tagged `invalidRegex` when a non-literal pattern is not a
183
+ * valid JS regex, so the caller can surface the same "pass literal: true" hint.
184
+ */
185
+ export async function nativeGrep(root, opts) {
186
+ let regex;
187
+ const flags = opts.ignoreCase ? "i" : "";
188
+ if (opts.literal) {
189
+ regex = new RegExp(escapeRegExp(opts.pattern), flags);
190
+ }
191
+ else {
192
+ try {
193
+ regex = new RegExp(opts.pattern, flags);
194
+ }
195
+ catch (e) {
196
+ const error = new Error(e instanceof Error ? e.message : String(e));
197
+ error.invalidRegex = true;
198
+ throw error;
199
+ }
200
+ }
201
+ let files;
202
+ if (!opts.isDirectory) {
203
+ files = [root];
204
+ }
205
+ else {
206
+ const entries = collectEntries(root, { signal: opts.signal });
207
+ files = entries
208
+ .filter((e) => e.type === "f")
209
+ .filter((e) => {
210
+ if (!opts.glob)
211
+ return true;
212
+ return minimatch(e.rel, opts.glob, { dot: true, matchBase: !opts.glob.includes("/") });
213
+ })
214
+ .map((e) => e.abs);
215
+ }
216
+ const matches = [];
217
+ let matchLimitReached = false;
218
+ for (const filePath of files) {
219
+ if (opts.signal?.aborted || matches.length >= opts.limit)
220
+ break;
221
+ try {
222
+ if (statSync(filePath).size > MAX_GREP_FILE_BYTES)
223
+ continue;
224
+ }
225
+ catch {
226
+ continue;
227
+ }
228
+ let content;
229
+ try {
230
+ content = await opts.readFile(filePath);
231
+ }
232
+ catch {
233
+ continue;
234
+ }
235
+ if (looksBinary(content))
236
+ continue;
237
+ const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
238
+ for (let i = 0; i < lines.length; i++) {
239
+ // New RegExp per file with no /g flag → test() is stateless across lines.
240
+ if (regex.test(lines[i])) {
241
+ matches.push({ filePath, lineNumber: i + 1, lineText: lines[i] });
242
+ if (matches.length >= opts.limit) {
243
+ matchLimitReached = true;
244
+ break;
245
+ }
246
+ }
247
+ }
248
+ }
249
+ return { matches, matchLimitReached };
250
+ }
251
+ /** Whether the native search path is forced regardless of fd/rg availability. */
252
+ export function isNativeSearchForced() {
253
+ return process.env.HOOCODE_NATIVE_SEARCH === "1";
254
+ }
255
+ //# sourceMappingURL=native-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-search.js","sourceRoot":"","sources":["../../../src/core/tools/native-search.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Native (pure-JS) fallbacks for the `find` and `grep` tools.\n *\n * The tools normally shell out to `fd` / `rg`, which are downloaded on demand\n * (see tools-manager.ts). In restricted environments those binaries may be\n * neither on PATH nor downloadable, and `HOOCODE_NATIVE_SEARCH=1` can also force\n * this path. Rather than failing the tool and asking the model to fall back to\n * `bash`, these functions reproduce the essential behaviour in JS so search\n * degrades automatically:\n *\n * - hierarchical `.gitignore` handling (each `.gitignore` is scoped to its own\n * subtree, matching fd's `--no-require-git` behaviour and issue #3303),\n * - hidden files included (like `fd --hidden` / `rg --hidden`),\n * - `.git` always skipped; `node_modules` skipped for `find` (mirrors the\n * tool's built-in excludes) but left to `.gitignore` for `grep` (like rg).\n *\n * These are best-effort approximations, not byte-for-byte fd/rg parity: globs\n * are matched with `minimatch` and patterns with JS `RegExp`, and only\n * `.gitignore` files are honoured (not `.ignore` or global excludes).\n */\n\nimport { readdirSync, readFileSync, statSync } from \"fs\";\nimport ignore, { type Ignore } from \"ignore\";\nimport { minimatch } from \"minimatch\";\nimport path from \"path\";\nimport { toPosixPath } from \"./fd-utils.js\";\n\nexport type EntryType = \"f\" | \"d\" | \"l\";\n\n/** Hard cap on entries enumerated during a single walk, so a pathological tree\n * can never hang the fallback. Well above any tool's own result limit. */\nconst MAX_ENTRIES = 200_000;\n\n/** Files larger than this are skipped by the grep fallback (rg streams; we read\n * whole files, so we guard against loading huge blobs into memory). */\nconst MAX_GREP_FILE_BYTES = 20 * 1024 * 1024;\n\nexport interface CollectedEntry {\n\t/** Absolute path. */\n\tabs: string;\n\t/** POSIX path relative to the walk root. */\n\trel: string;\n\ttype: EntryType;\n}\n\n/** A `.gitignore` matcher scoped to the subtree rooted at `baseDir`. */\ninterface GitignoreMatcher {\n\tbaseDir: string;\n\tig: Ignore;\n}\n\nfunction loadGitignore(dir: string): Ignore | undefined {\n\tlet content: string;\n\ttry {\n\t\tcontent = readFileSync(path.join(dir, \".gitignore\"), \"utf-8\");\n\t} catch {\n\t\treturn undefined;\n\t}\n\treturn ignore().add(content);\n}\n\n/**\n * Whether `absPath` is ignored by any applicable `.gitignore`. Each matcher only\n * applies to paths inside its `baseDir`, and the path is tested relative to that\n * base — so `a/.gitignore` scopes to `a/` and its descendants but never `b/`.\n */\nfunction isGitIgnored(absPath: string, isDir: boolean, matchers: GitignoreMatcher[]): boolean {\n\tfor (const m of matchers) {\n\t\tconst rel = path.relative(m.baseDir, absPath);\n\t\tif (rel === \"\" || rel.startsWith(\"..\") || path.isAbsolute(rel)) continue;\n\t\tconst relPosix = toPosixPath(rel) + (isDir ? \"/\" : \"\");\n\t\tif (m.ig.ignores(relPosix)) return true;\n\t}\n\treturn false;\n}\n\nexport interface WalkOptions {\n\t/** Max entry depth relative to root; direct children are depth 1. */\n\tmaxDepth?: number;\n\tsignal?: AbortSignal;\n\t/** Directory names to never descend into. Defaults to `.git`. */\n\talwaysSkipDirs?: Set<string>;\n}\n\n/**\n * Walk `root` depth-first, returning every entry not excluded by `.gitignore`\n * or an always-skip directory. Symlinks are reported but never followed (avoids\n * cycles). Enumeration stops at {@link MAX_ENTRIES}.\n */\nexport function collectEntries(root: string, opts: WalkOptions = {}): CollectedEntry[] {\n\tconst out: CollectedEntry[] = [];\n\tconst alwaysSkip = opts.alwaysSkipDirs ?? new Set([\".git\"]);\n\n\tconst rootMatchers: GitignoreMatcher[] = [];\n\tconst rootIg = loadGitignore(root);\n\tif (rootIg) rootMatchers.push({ baseDir: root, ig: rootIg });\n\n\t// `depth` is the depth of `dir`; its direct children are at depth + 1.\n\tconst stack: Array<{ dir: string; depth: number; matchers: GitignoreMatcher[] }> = [\n\t\t{ dir: root, depth: 0, matchers: rootMatchers },\n\t];\n\n\twhile (stack.length > 0) {\n\t\tif (out.length >= MAX_ENTRIES || opts.signal?.aborted) break;\n\t\tconst { dir, depth, matchers } = stack.pop()!;\n\n\t\tlet dirents: import(\"fs\").Dirent[];\n\t\ttry {\n\t\t\tdirents = readdirSync(dir, { withFileTypes: true });\n\t\t} catch {\n\t\t\tcontinue; // unreadable directory — skip rather than abort the whole walk\n\t\t}\n\n\t\tconst entryDepth = depth + 1;\n\t\tfor (const dirent of dirents) {\n\t\t\tif (out.length >= MAX_ENTRIES) break;\n\n\t\t\tconst name = dirent.name;\n\t\t\tconst abs = path.join(dir, name);\n\n\t\t\tlet type: EntryType;\n\t\t\tlet isDir = false;\n\t\t\tif (dirent.isSymbolicLink()) {\n\t\t\t\ttype = \"l\";\n\t\t\t} else if (dirent.isDirectory()) {\n\t\t\t\ttype = \"d\";\n\t\t\t\tisDir = true;\n\t\t\t} else if (dirent.isFile()) {\n\t\t\t\ttype = \"f\";\n\t\t\t} else {\n\t\t\t\tcontinue; // sockets, fifos, block devices, …\n\t\t\t}\n\n\t\t\tif (isDir && alwaysSkip.has(name)) continue;\n\t\t\tif (isGitIgnored(abs, isDir, matchers)) continue;\n\n\t\t\tif (opts.maxDepth === undefined || entryDepth <= opts.maxDepth) {\n\t\t\t\tout.push({ abs, rel: toPosixPath(path.relative(root, abs)), type });\n\t\t\t}\n\n\t\t\t// Descend only into real directories, and only if their children can\n\t\t\t// still be within the depth budget.\n\t\t\tif (isDir && (opts.maxDepth === undefined || entryDepth < opts.maxDepth)) {\n\t\t\t\tconst childIg = loadGitignore(abs);\n\t\t\t\tconst nextMatchers = childIg ? [...matchers, { baseDir: abs, ig: childIg }] : matchers;\n\t\t\t\tstack.push({ dir: abs, depth: entryDepth, matchers: nextMatchers });\n\t\t\t}\n\t\t}\n\t}\n\n\treturn out;\n}\n\n/**\n * Match a `find` glob against a POSIX relative path, mirroring fd's semantics:\n * a slashless pattern matches the basename at any depth; a pattern containing a\n * slash matches the full path and is anchored anywhere in the tree.\n */\nfunction matchesFindPattern(relPosix: string, pattern: string): boolean {\n\tif (pattern.includes(\"/\")) {\n\t\tlet p = pattern;\n\t\tif (p.startsWith(\"/\")) {\n\t\t\tp = p.slice(1); // leading slash anchors to root; rel paths have no leading slash\n\t\t} else if (!p.startsWith(\"**/\") && p !== \"**\") {\n\t\t\tp = `**/${p}`;\n\t\t}\n\t\treturn minimatch(relPosix, p, { dot: true });\n\t}\n\treturn minimatch(relPosix, pattern, { dot: true, matchBase: true });\n}\n\nexport interface NativeFindOptions {\n\tpatterns: string[];\n\ttype: EntryType;\n\t/** Extra exclusion globs (already includes node_modules/.git for find). */\n\texcludeGlobs: string[];\n\tmaxDepth?: number;\n\talwaysSkipDirs?: Set<string>;\n\tsignal?: AbortSignal;\n}\n\n/**\n * Native replacement for the fd-backed search. Returns POSIX paths relative to\n * `root`, with a trailing slash on directories (like fd), unsorted/undeduped —\n * the caller applies its own dedupe/sort/limit.\n */\nexport function nativeFind(root: string, opts: NativeFindOptions): string[] {\n\tconst entries = collectEntries(root, {\n\t\tmaxDepth: opts.maxDepth,\n\t\tsignal: opts.signal,\n\t\talwaysSkipDirs: opts.alwaysSkipDirs,\n\t});\n\n\tconst results: string[] = [];\n\tfor (const entry of entries) {\n\t\tif (entry.type !== opts.type) continue;\n\t\tif (opts.excludeGlobs.some((g) => minimatch(entry.rel, g, { dot: true }))) continue;\n\t\tif (!opts.patterns.some((pat) => matchesFindPattern(entry.rel, pat))) continue;\n\t\tresults.push(entry.type === \"d\" ? `${entry.rel}/` : entry.rel);\n\t}\n\treturn results;\n}\n\nexport interface NativeGrepMatch {\n\tfilePath: string;\n\tlineNumber: number;\n\tlineText: string;\n}\n\nexport interface NativeGrepOptions {\n\tpattern: string;\n\t/** True when `root` is a directory; false when it is a single file. */\n\tisDirectory: boolean;\n\tignoreCase?: boolean;\n\tliteral?: boolean;\n\t/** Optional glob filter applied to file paths (like rg --glob). */\n\tglob?: string;\n\t/** Stop after this many matches. */\n\tlimit: number;\n\tsignal?: AbortSignal;\n\treadFile: (absolutePath: string) => Promise<string> | string;\n}\n\nexport interface NativeGrepResult {\n\tmatches: NativeGrepMatch[];\n\tmatchLimitReached: boolean;\n}\n\nfunction escapeRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/** Cheap binary-file heuristic: a NUL byte in the first chunk. rg skips these. */\nfunction looksBinary(content: string): boolean {\n\tconst sampleLength = Math.min(content.length, 8192);\n\tfor (let i = 0; i < sampleLength; i++) {\n\t\tif (content.charCodeAt(i) === 0) return true;\n\t}\n\treturn false;\n}\n\n/**\n * Native replacement for the rg-backed content search. Collects up to `limit`\n * matches as `{ filePath, lineNumber, lineText }`, the same shape the tool's\n * formatter already consumes from rg's JSON output.\n *\n * Throws an Error tagged `invalidRegex` when a non-literal pattern is not a\n * valid JS regex, so the caller can surface the same \"pass literal: true\" hint.\n */\nexport async function nativeGrep(root: string, opts: NativeGrepOptions): Promise<NativeGrepResult> {\n\tlet regex: RegExp;\n\tconst flags = opts.ignoreCase ? \"i\" : \"\";\n\tif (opts.literal) {\n\t\tregex = new RegExp(escapeRegExp(opts.pattern), flags);\n\t} else {\n\t\ttry {\n\t\t\tregex = new RegExp(opts.pattern, flags);\n\t\t} catch (e) {\n\t\t\tconst error = new Error(e instanceof Error ? e.message : String(e)) as Error & { invalidRegex?: boolean };\n\t\t\terror.invalidRegex = true;\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tlet files: string[];\n\tif (!opts.isDirectory) {\n\t\tfiles = [root];\n\t} else {\n\t\tconst entries = collectEntries(root, { signal: opts.signal });\n\t\tfiles = entries\n\t\t\t.filter((e) => e.type === \"f\")\n\t\t\t.filter((e) => {\n\t\t\t\tif (!opts.glob) return true;\n\t\t\t\treturn minimatch(e.rel, opts.glob, { dot: true, matchBase: !opts.glob.includes(\"/\") });\n\t\t\t})\n\t\t\t.map((e) => e.abs);\n\t}\n\n\tconst matches: NativeGrepMatch[] = [];\n\tlet matchLimitReached = false;\n\n\tfor (const filePath of files) {\n\t\tif (opts.signal?.aborted || matches.length >= opts.limit) break;\n\n\t\ttry {\n\t\t\tif (statSync(filePath).size > MAX_GREP_FILE_BYTES) continue;\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet content: string;\n\t\ttry {\n\t\t\tcontent = await opts.readFile(filePath);\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tif (looksBinary(content)) continue;\n\n\t\tconst lines = content.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\");\n\t\tfor (let i = 0; i < lines.length; i++) {\n\t\t\t// New RegExp per file with no /g flag → test() is stateless across lines.\n\t\t\tif (regex.test(lines[i])) {\n\t\t\t\tmatches.push({ filePath, lineNumber: i + 1, lineText: lines[i] });\n\t\t\t\tif (matches.length >= opts.limit) {\n\t\t\t\t\tmatchLimitReached = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { matches, matchLimitReached };\n}\n\n/** Whether the native search path is forced regardless of fd/rg availability. */\nexport function isNativeSearchForced(): boolean {\n\treturn process.env.HOOCODE_NATIVE_SEARCH === \"1\";\n}\n"]}
@@ -46,7 +46,20 @@ export interface ThinkingEscalationConfig {
46
46
  /** Number of subsequent turns to stay escalated after an error. Default: 1. */
47
47
  cooldown_turns?: number;
48
48
  }
49
+ /** LLM defaults seeded in hoo-config.json and honoured during model selection. */
50
+ export interface HooLlmConfig {
51
+ /** Preferred provider when the pi-layer settings.json has no saved default. */
52
+ default_provider?: string;
53
+ /** Preferred model id for `default_provider` (otherwise the provider's built-in default). */
54
+ default_model?: string;
55
+ /** Informational map of provider → env var holding its API key. */
56
+ providers?: Record<string, {
57
+ api_key_env?: string;
58
+ }>;
59
+ }
49
60
  export interface HooConfig {
61
+ /** LLM default provider/model preferences. */
62
+ llm?: HooLlmConfig;
50
63
  /** Manually-pinned active mode (overrides default "build") */
51
64
  active_mode?: string;
52
65
  /** Per-mode configuration keyed by mode name */
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/config.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAMrE,MAAM,WAAW,UAAU;IAC1B,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,8EAA8E;IAC9E,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,8FAA8F;IAC9F,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,sGAAsG;IACtG,qBAAqB,CAAC,EAAE,MAAM,EAAE,CAAC;IACjC,0FAA0F;IAC1F,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,wBAAwB;IACxC,8DAA8D;IAC9D,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACzB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACnC,4FAA4F;IAC5F,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,8GAA8G;IAC9G,mBAAmB,CAAC,EAAE,wBAAwB,CAAC;CAC/C;AAED,wBAAgB,UAAU,IAAI,SAAS,CAMtC;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAGnD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,GAAG,SAAS,CAyC7E;AAcD,wBAAgB,gBAAgB,CAAC,GAAG,OAAO,EAAE,CAAC,MAAM,EAAE,GAAG,SAAS,CAAC,EAAE,GAAG,MAAM,EAAE,CAO/E;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAUvD","sourcesContent":["/**\n * hoo-config.json — types, I/O, and merge rules shared by the hoo-core extensions.\n *\n * Config merge order (lowest → highest priority):\n * 1. ~/.hoocode/hoo-config.json (global defaults)\n * 2. ./.hoocode/hoo-config.json (project overrides — scalars win; arrays union)\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { ThinkingLevel } from \"@kolisachint/hoocode-agent-core\";\nimport { getHooCodeDir } from \"../../config.js\";\n\nconst HOOCODE_DIR = getHooCodeDir();\nconst GLOBAL_CONFIG_PATH = join(HOOCODE_DIR, \"hoo-config.json\");\n\nexport interface ModeConfig {\n\t/** Tool names that bypass the permission gate in this mode */\n\tauto_allow?: string[];\n\t/** Tool names available in this mode (if set, only these tools are active) */\n\tenabled_tools?: string[];\n\t/** Tool names explicitly blocked in this mode regardless of enabled_tools */\n\tdenied_tools?: string[];\n\t/** Allowed write paths in this mode (glob patterns, only applies if write/edit is enabled) */\n\tallowed_write_paths?: string[];\n\t/** Regex patterns for allowed bash commands. If set, a command must match at least one to execute. */\n\tallowed_bash_commands?: string[];\n\t/** Regex patterns for denied bash commands. A command matching any pattern is blocked. */\n\tdenied_bash_commands?: string[];\n}\n\n/**\n * Tool-outcome-driven thinking escalation (on by default).\n *\n * The default (fast) path keeps thinking low so mechanical tool turns — reads,\n * greps, successful edits — don't pay the extended-thinking prefill tax. When a\n * tool *fails*, the next turn(s) escalate to a higher thinking level so the model\n * reasons through the failure, then the level is restored. This buys low latency\n * on the happy path while preserving deep reasoning exactly when something breaks.\n *\n * Enabled by default; set `thinking_escalation.enabled` to `false` to turn it off.\n *\n * Note: escalation uses the same setter as manual thinking control, so the\n * escalated level is briefly written to settings and restored when the window\n * ends. If a run is interrupted mid-window, the escalated level may persist until\n * the next change.\n */\nexport interface ThinkingEscalationConfig {\n\t/** Master switch. Default: true (set to false to disable). */\n\tenabled?: boolean;\n\t/** Level to escalate to after a tool error. Default: \"high\". */\n\ton_error?: ThinkingLevel;\n\t/** Restrict escalation to errors from these tool names. Default: any tool. */\n\ttools?: string[];\n\t/** Number of subsequent turns to stay escalated after an error. Default: 1. */\n\tcooldown_turns?: number;\n}\n\nexport interface HooConfig {\n\t/** Manually-pinned active mode (overrides default \"build\") */\n\tactive_mode?: string;\n\t/** Per-mode configuration keyed by mode name */\n\tmodes?: Record<string, ModeConfig>;\n\t/** Extra directories to search for `{name}/system.md` mode files (after project + user). */\n\tmode_paths?: string[];\n\t/** Raise thinking after tool failures, restore on success. On by default; set `enabled: false` to disable. */\n\tthinking_escalation?: ThinkingEscalationConfig;\n}\n\nexport function readConfig(): HooConfig {\n\ttry {\n\t\treturn JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, \"utf8\")) as HooConfig;\n\t} catch {\n\t\treturn {};\n\t}\n}\n\nexport function writeConfig(config: HooConfig): void {\n\tif (!existsSync(HOOCODE_DIR)) mkdirSync(HOOCODE_DIR, { recursive: true });\n\twriteFileSync(GLOBAL_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\\n`, \"utf8\");\n}\n\n/**\n * Deep-merges a project-local config on top of the global config.\n *\n * Merge rules:\n * - active_mode: project wins if set\n * - modes[x].auto_allow: union of global + project arrays\n * - modes[x].allowed_write_paths: union of global + project arrays\n * - modes[x].enabled_tools: project wins if set, else falls back to global\n * - mode_paths: project list is prepended so project paths are searched first\n * - thinking_escalation: project wins as a whole if set, else inherit global\n */\nexport function mergeConfigs(global: HooConfig, project: HooConfig): HooConfig {\n\tconst merged: HooConfig = { ...global };\n\n\tif (project.active_mode !== undefined) merged.active_mode = project.active_mode;\n\n\t// thinking_escalation: project wins as a whole if set, else inherit global.\n\tif (project.thinking_escalation !== undefined) merged.thinking_escalation = project.thinking_escalation;\n\n\tif (project.modes) {\n\t\tmerged.modes = { ...(global.modes ?? {}) };\n\t\tfor (const [mode, projectCfg] of Object.entries(project.modes)) {\n\t\t\tconst globalCfg = global.modes?.[mode] ?? {};\n\t\t\tmerged.modes[mode] = {\n\t\t\t\t...globalCfg,\n\t\t\t\t...projectCfg,\n\t\t\t\t// Union both auto_allow lists so project can extend, not just replace\n\t\t\t\tauto_allow: Array.from(new Set([...(globalCfg.auto_allow ?? []), ...(projectCfg.auto_allow ?? [])])),\n\t\t\t\t// Union allowed_write_paths so project can extend\n\t\t\t\tallowed_write_paths: Array.from(\n\t\t\t\t\tnew Set([...(globalCfg.allowed_write_paths ?? []), ...(projectCfg.allowed_write_paths ?? [])]),\n\t\t\t\t),\n\t\t\t\t// enabled_tools: project wins if set, else falls back to global\n\t\t\t\tenabled_tools: projectCfg.enabled_tools ?? globalCfg.enabled_tools,\n\t\t\t\t// denied_tools: union so project can add more denied tools on top of global\n\t\t\t\tdenied_tools: Array.from(new Set([...(globalCfg.denied_tools ?? []), ...(projectCfg.denied_tools ?? [])])),\n\t\t\t\t// allowed_bash_commands: project wins if set, else falls back to global\n\t\t\t\tallowed_bash_commands: projectCfg.allowed_bash_commands ?? globalCfg.allowed_bash_commands,\n\t\t\t\t// denied_bash_commands: union so project can add more denied patterns on top of global\n\t\t\t\tdenied_bash_commands: Array.from(\n\t\t\t\t\tnew Set([...(globalCfg.denied_bash_commands ?? []), ...(projectCfg.denied_bash_commands ?? [])]),\n\t\t\t\t),\n\t\t\t};\n\t\t}\n\t}\n\n\tif (project.mode_paths || global.mode_paths) {\n\t\t// Project paths first so they're searched before global paths\n\t\tmerged.mode_paths = dedupePaths([...(project.mode_paths ?? []), ...(global.mode_paths ?? [])]);\n\t}\n\n\treturn merged;\n}\n\nfunction dedupePaths(paths: string[]): string[] {\n\tconst seen = new Set<string>();\n\tconst out: string[] = [];\n\tfor (const p of paths) {\n\t\tif (!seen.has(p)) {\n\t\t\tseen.add(p);\n\t\t\tout.push(p);\n\t\t}\n\t}\n\treturn out;\n}\n\nexport function mergeSearchPaths(...sources: (string[] | undefined)[]): string[] {\n\tconst merged: string[] = [];\n\tfor (const source of sources) {\n\t\tif (!source) continue;\n\t\tmerged.push(...source);\n\t}\n\treturn dedupePaths(merged);\n}\n\n/**\n * Reads the global config and optionally overlays the project-local config at\n * `./.hoocode/hoo-config.json`. Project values win on all scalar fields; arrays are\n * unioned (see mergeConfigs for full rules).\n */\nexport function readMergedConfig(cwd: string): HooConfig {\n\tconst global = readConfig();\n\tconst projectPath = join(cwd, \".hoocode\", \"hoo-config.json\");\n\tif (!existsSync(projectPath)) return global;\n\ttry {\n\t\tconst project = JSON.parse(readFileSync(projectPath, \"utf8\")) as HooConfig;\n\t\treturn mergeConfigs(global, project);\n\t} catch {\n\t\treturn global;\n\t}\n}\n"]}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/config.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAMrE,MAAM,WAAW,UAAU;IAC1B,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,8EAA8E;IAC9E,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,8FAA8F;IAC9F,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,sGAAsG;IACtG,qBAAqB,CAAC,EAAE,MAAM,EAAE,CAAC;IACjC,0FAA0F;IAC1F,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,wBAAwB;IACxC,8DAA8D;IAC9D,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,kFAAkF;AAClF,MAAM,WAAW,YAAY;IAC5B,+EAA+E;IAC/E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6FAA6F;IAC7F,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,qEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD;AAED,MAAM,WAAW,SAAS;IACzB,8CAA8C;IAC9C,GAAG,CAAC,EAAE,YAAY,CAAC;IACnB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACnC,4FAA4F;IAC5F,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,8GAA8G;IAC9G,mBAAmB,CAAC,EAAE,wBAAwB,CAAC;CAC/C;AAED,wBAAgB,UAAU,IAAI,SAAS,CAMtC;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAGnD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,GAAG,SAAS,CAyC7E;AAcD,wBAAgB,gBAAgB,CAAC,GAAG,OAAO,EAAE,CAAC,MAAM,EAAE,GAAG,SAAS,CAAC,EAAE,GAAG,MAAM,EAAE,CAO/E;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAUvD","sourcesContent":["/**\n * hoo-config.json — types, I/O, and merge rules shared by the hoo-core extensions.\n *\n * Config merge order (lowest → highest priority):\n * 1. ~/.hoocode/hoo-config.json (global defaults)\n * 2. ./.hoocode/hoo-config.json (project overrides — scalars win; arrays union)\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { ThinkingLevel } from \"@kolisachint/hoocode-agent-core\";\nimport { getHooCodeDir } from \"../../config.js\";\n\nconst HOOCODE_DIR = getHooCodeDir();\nconst GLOBAL_CONFIG_PATH = join(HOOCODE_DIR, \"hoo-config.json\");\n\nexport interface ModeConfig {\n\t/** Tool names that bypass the permission gate in this mode */\n\tauto_allow?: string[];\n\t/** Tool names available in this mode (if set, only these tools are active) */\n\tenabled_tools?: string[];\n\t/** Tool names explicitly blocked in this mode regardless of enabled_tools */\n\tdenied_tools?: string[];\n\t/** Allowed write paths in this mode (glob patterns, only applies if write/edit is enabled) */\n\tallowed_write_paths?: string[];\n\t/** Regex patterns for allowed bash commands. If set, a command must match at least one to execute. */\n\tallowed_bash_commands?: string[];\n\t/** Regex patterns for denied bash commands. A command matching any pattern is blocked. */\n\tdenied_bash_commands?: string[];\n}\n\n/**\n * Tool-outcome-driven thinking escalation (on by default).\n *\n * The default (fast) path keeps thinking low so mechanical tool turns — reads,\n * greps, successful edits — don't pay the extended-thinking prefill tax. When a\n * tool *fails*, the next turn(s) escalate to a higher thinking level so the model\n * reasons through the failure, then the level is restored. This buys low latency\n * on the happy path while preserving deep reasoning exactly when something breaks.\n *\n * Enabled by default; set `thinking_escalation.enabled` to `false` to turn it off.\n *\n * Note: escalation uses the same setter as manual thinking control, so the\n * escalated level is briefly written to settings and restored when the window\n * ends. If a run is interrupted mid-window, the escalated level may persist until\n * the next change.\n */\nexport interface ThinkingEscalationConfig {\n\t/** Master switch. Default: true (set to false to disable). */\n\tenabled?: boolean;\n\t/** Level to escalate to after a tool error. Default: \"high\". */\n\ton_error?: ThinkingLevel;\n\t/** Restrict escalation to errors from these tool names. Default: any tool. */\n\ttools?: string[];\n\t/** Number of subsequent turns to stay escalated after an error. Default: 1. */\n\tcooldown_turns?: number;\n}\n\n/** LLM defaults seeded in hoo-config.json and honoured during model selection. */\nexport interface HooLlmConfig {\n\t/** Preferred provider when the pi-layer settings.json has no saved default. */\n\tdefault_provider?: string;\n\t/** Preferred model id for `default_provider` (otherwise the provider's built-in default). */\n\tdefault_model?: string;\n\t/** Informational map of provider → env var holding its API key. */\n\tproviders?: Record<string, { api_key_env?: string }>;\n}\n\nexport interface HooConfig {\n\t/** LLM default provider/model preferences. */\n\tllm?: HooLlmConfig;\n\t/** Manually-pinned active mode (overrides default \"build\") */\n\tactive_mode?: string;\n\t/** Per-mode configuration keyed by mode name */\n\tmodes?: Record<string, ModeConfig>;\n\t/** Extra directories to search for `{name}/system.md` mode files (after project + user). */\n\tmode_paths?: string[];\n\t/** Raise thinking after tool failures, restore on success. On by default; set `enabled: false` to disable. */\n\tthinking_escalation?: ThinkingEscalationConfig;\n}\n\nexport function readConfig(): HooConfig {\n\ttry {\n\t\treturn JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, \"utf8\")) as HooConfig;\n\t} catch {\n\t\treturn {};\n\t}\n}\n\nexport function writeConfig(config: HooConfig): void {\n\tif (!existsSync(HOOCODE_DIR)) mkdirSync(HOOCODE_DIR, { recursive: true });\n\twriteFileSync(GLOBAL_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\\n`, \"utf8\");\n}\n\n/**\n * Deep-merges a project-local config on top of the global config.\n *\n * Merge rules:\n * - active_mode: project wins if set\n * - modes[x].auto_allow: union of global + project arrays\n * - modes[x].allowed_write_paths: union of global + project arrays\n * - modes[x].enabled_tools: project wins if set, else falls back to global\n * - mode_paths: project list is prepended so project paths are searched first\n * - thinking_escalation: project wins as a whole if set, else inherit global\n */\nexport function mergeConfigs(global: HooConfig, project: HooConfig): HooConfig {\n\tconst merged: HooConfig = { ...global };\n\n\tif (project.active_mode !== undefined) merged.active_mode = project.active_mode;\n\n\t// thinking_escalation: project wins as a whole if set, else inherit global.\n\tif (project.thinking_escalation !== undefined) merged.thinking_escalation = project.thinking_escalation;\n\n\tif (project.modes) {\n\t\tmerged.modes = { ...(global.modes ?? {}) };\n\t\tfor (const [mode, projectCfg] of Object.entries(project.modes)) {\n\t\t\tconst globalCfg = global.modes?.[mode] ?? {};\n\t\t\tmerged.modes[mode] = {\n\t\t\t\t...globalCfg,\n\t\t\t\t...projectCfg,\n\t\t\t\t// Union both auto_allow lists so project can extend, not just replace\n\t\t\t\tauto_allow: Array.from(new Set([...(globalCfg.auto_allow ?? []), ...(projectCfg.auto_allow ?? [])])),\n\t\t\t\t// Union allowed_write_paths so project can extend\n\t\t\t\tallowed_write_paths: Array.from(\n\t\t\t\t\tnew Set([...(globalCfg.allowed_write_paths ?? []), ...(projectCfg.allowed_write_paths ?? [])]),\n\t\t\t\t),\n\t\t\t\t// enabled_tools: project wins if set, else falls back to global\n\t\t\t\tenabled_tools: projectCfg.enabled_tools ?? globalCfg.enabled_tools,\n\t\t\t\t// denied_tools: union so project can add more denied tools on top of global\n\t\t\t\tdenied_tools: Array.from(new Set([...(globalCfg.denied_tools ?? []), ...(projectCfg.denied_tools ?? [])])),\n\t\t\t\t// allowed_bash_commands: project wins if set, else falls back to global\n\t\t\t\tallowed_bash_commands: projectCfg.allowed_bash_commands ?? globalCfg.allowed_bash_commands,\n\t\t\t\t// denied_bash_commands: union so project can add more denied patterns on top of global\n\t\t\t\tdenied_bash_commands: Array.from(\n\t\t\t\t\tnew Set([...(globalCfg.denied_bash_commands ?? []), ...(projectCfg.denied_bash_commands ?? [])]),\n\t\t\t\t),\n\t\t\t};\n\t\t}\n\t}\n\n\tif (project.mode_paths || global.mode_paths) {\n\t\t// Project paths first so they're searched before global paths\n\t\tmerged.mode_paths = dedupePaths([...(project.mode_paths ?? []), ...(global.mode_paths ?? [])]);\n\t}\n\n\treturn merged;\n}\n\nfunction dedupePaths(paths: string[]): string[] {\n\tconst seen = new Set<string>();\n\tconst out: string[] = [];\n\tfor (const p of paths) {\n\t\tif (!seen.has(p)) {\n\t\t\tseen.add(p);\n\t\t\tout.push(p);\n\t\t}\n\t}\n\treturn out;\n}\n\nexport function mergeSearchPaths(...sources: (string[] | undefined)[]): string[] {\n\tconst merged: string[] = [];\n\tfor (const source of sources) {\n\t\tif (!source) continue;\n\t\tmerged.push(...source);\n\t}\n\treturn dedupePaths(merged);\n}\n\n/**\n * Reads the global config and optionally overlays the project-local config at\n * `./.hoocode/hoo-config.json`. Project values win on all scalar fields; arrays are\n * unioned (see mergeConfigs for full rules).\n */\nexport function readMergedConfig(cwd: string): HooConfig {\n\tconst global = readConfig();\n\tconst projectPath = join(cwd, \".hoocode\", \"hoo-config.json\");\n\tif (!existsSync(projectPath)) return global;\n\ttry {\n\t\tconst project = JSON.parse(readFileSync(projectPath, \"utf8\")) as HooConfig;\n\t\treturn mergeConfigs(global, project);\n\t} catch {\n\t\treturn global;\n\t}\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/extensions/core/config.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * hoo-config.json — types, I/O, and merge rules shared by the hoo-core extensions.\n *\n * Config merge order (lowest → highest priority):\n * 1. ~/.hoocode/hoo-config.json (global defaults)\n * 2. ./.hoocode/hoo-config.json (project overrides — scalars win; arrays union)\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { ThinkingLevel } from \"@kolisachint/hoocode-agent-core\";\nimport { getHooCodeDir } from \"../../config.js\";\n\nconst HOOCODE_DIR = getHooCodeDir();\nconst GLOBAL_CONFIG_PATH = join(HOOCODE_DIR, \"hoo-config.json\");\n\nexport interface ModeConfig {\n\t/** Tool names that bypass the permission gate in this mode */\n\tauto_allow?: string[];\n\t/** Tool names available in this mode (if set, only these tools are active) */\n\tenabled_tools?: string[];\n\t/** Tool names explicitly blocked in this mode regardless of enabled_tools */\n\tdenied_tools?: string[];\n\t/** Allowed write paths in this mode (glob patterns, only applies if write/edit is enabled) */\n\tallowed_write_paths?: string[];\n\t/** Regex patterns for allowed bash commands. If set, a command must match at least one to execute. */\n\tallowed_bash_commands?: string[];\n\t/** Regex patterns for denied bash commands. A command matching any pattern is blocked. */\n\tdenied_bash_commands?: string[];\n}\n\n/**\n * Tool-outcome-driven thinking escalation (on by default).\n *\n * The default (fast) path keeps thinking low so mechanical tool turns — reads,\n * greps, successful edits — don't pay the extended-thinking prefill tax. When a\n * tool *fails*, the next turn(s) escalate to a higher thinking level so the model\n * reasons through the failure, then the level is restored. This buys low latency\n * on the happy path while preserving deep reasoning exactly when something breaks.\n *\n * Enabled by default; set `thinking_escalation.enabled` to `false` to turn it off.\n *\n * Note: escalation uses the same setter as manual thinking control, so the\n * escalated level is briefly written to settings and restored when the window\n * ends. If a run is interrupted mid-window, the escalated level may persist until\n * the next change.\n */\nexport interface ThinkingEscalationConfig {\n\t/** Master switch. Default: true (set to false to disable). */\n\tenabled?: boolean;\n\t/** Level to escalate to after a tool error. Default: \"high\". */\n\ton_error?: ThinkingLevel;\n\t/** Restrict escalation to errors from these tool names. Default: any tool. */\n\ttools?: string[];\n\t/** Number of subsequent turns to stay escalated after an error. Default: 1. */\n\tcooldown_turns?: number;\n}\n\nexport interface HooConfig {\n\t/** Manually-pinned active mode (overrides default \"build\") */\n\tactive_mode?: string;\n\t/** Per-mode configuration keyed by mode name */\n\tmodes?: Record<string, ModeConfig>;\n\t/** Extra directories to search for `{name}/system.md` mode files (after project + user). */\n\tmode_paths?: string[];\n\t/** Raise thinking after tool failures, restore on success. On by default; set `enabled: false` to disable. */\n\tthinking_escalation?: ThinkingEscalationConfig;\n}\n\nexport function readConfig(): HooConfig {\n\ttry {\n\t\treturn JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, \"utf8\")) as HooConfig;\n\t} catch {\n\t\treturn {};\n\t}\n}\n\nexport function writeConfig(config: HooConfig): void {\n\tif (!existsSync(HOOCODE_DIR)) mkdirSync(HOOCODE_DIR, { recursive: true });\n\twriteFileSync(GLOBAL_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\\n`, \"utf8\");\n}\n\n/**\n * Deep-merges a project-local config on top of the global config.\n *\n * Merge rules:\n * - active_mode: project wins if set\n * - modes[x].auto_allow: union of global + project arrays\n * - modes[x].allowed_write_paths: union of global + project arrays\n * - modes[x].enabled_tools: project wins if set, else falls back to global\n * - mode_paths: project list is prepended so project paths are searched first\n * - thinking_escalation: project wins as a whole if set, else inherit global\n */\nexport function mergeConfigs(global: HooConfig, project: HooConfig): HooConfig {\n\tconst merged: HooConfig = { ...global };\n\n\tif (project.active_mode !== undefined) merged.active_mode = project.active_mode;\n\n\t// thinking_escalation: project wins as a whole if set, else inherit global.\n\tif (project.thinking_escalation !== undefined) merged.thinking_escalation = project.thinking_escalation;\n\n\tif (project.modes) {\n\t\tmerged.modes = { ...(global.modes ?? {}) };\n\t\tfor (const [mode, projectCfg] of Object.entries(project.modes)) {\n\t\t\tconst globalCfg = global.modes?.[mode] ?? {};\n\t\t\tmerged.modes[mode] = {\n\t\t\t\t...globalCfg,\n\t\t\t\t...projectCfg,\n\t\t\t\t// Union both auto_allow lists so project can extend, not just replace\n\t\t\t\tauto_allow: Array.from(new Set([...(globalCfg.auto_allow ?? []), ...(projectCfg.auto_allow ?? [])])),\n\t\t\t\t// Union allowed_write_paths so project can extend\n\t\t\t\tallowed_write_paths: Array.from(\n\t\t\t\t\tnew Set([...(globalCfg.allowed_write_paths ?? []), ...(projectCfg.allowed_write_paths ?? [])]),\n\t\t\t\t),\n\t\t\t\t// enabled_tools: project wins if set, else falls back to global\n\t\t\t\tenabled_tools: projectCfg.enabled_tools ?? globalCfg.enabled_tools,\n\t\t\t\t// denied_tools: union so project can add more denied tools on top of global\n\t\t\t\tdenied_tools: Array.from(new Set([...(globalCfg.denied_tools ?? []), ...(projectCfg.denied_tools ?? [])])),\n\t\t\t\t// allowed_bash_commands: project wins if set, else falls back to global\n\t\t\t\tallowed_bash_commands: projectCfg.allowed_bash_commands ?? globalCfg.allowed_bash_commands,\n\t\t\t\t// denied_bash_commands: union so project can add more denied patterns on top of global\n\t\t\t\tdenied_bash_commands: Array.from(\n\t\t\t\t\tnew Set([...(globalCfg.denied_bash_commands ?? []), ...(projectCfg.denied_bash_commands ?? [])]),\n\t\t\t\t),\n\t\t\t};\n\t\t}\n\t}\n\n\tif (project.mode_paths || global.mode_paths) {\n\t\t// Project paths first so they're searched before global paths\n\t\tmerged.mode_paths = dedupePaths([...(project.mode_paths ?? []), ...(global.mode_paths ?? [])]);\n\t}\n\n\treturn merged;\n}\n\nfunction dedupePaths(paths: string[]): string[] {\n\tconst seen = new Set<string>();\n\tconst out: string[] = [];\n\tfor (const p of paths) {\n\t\tif (!seen.has(p)) {\n\t\t\tseen.add(p);\n\t\t\tout.push(p);\n\t\t}\n\t}\n\treturn out;\n}\n\nexport function mergeSearchPaths(...sources: (string[] | undefined)[]): string[] {\n\tconst merged: string[] = [];\n\tfor (const source of sources) {\n\t\tif (!source) continue;\n\t\tmerged.push(...source);\n\t}\n\treturn dedupePaths(merged);\n}\n\n/**\n * Reads the global config and optionally overlays the project-local config at\n * `./.hoocode/hoo-config.json`. Project values win on all scalar fields; arrays are\n * unioned (see mergeConfigs for full rules).\n */\nexport function readMergedConfig(cwd: string): HooConfig {\n\tconst global = readConfig();\n\tconst projectPath = join(cwd, \".hoocode\", \"hoo-config.json\");\n\tif (!existsSync(projectPath)) return global;\n\ttry {\n\t\tconst project = JSON.parse(readFileSync(projectPath, \"utf8\")) as HooConfig;\n\t\treturn mergeConfigs(global, project);\n\t} catch {\n\t\treturn global;\n\t}\n}\n"]}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/extensions/core/config.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * hoo-config.json — types, I/O, and merge rules shared by the hoo-core extensions.\n *\n * Config merge order (lowest → highest priority):\n * 1. ~/.hoocode/hoo-config.json (global defaults)\n * 2. ./.hoocode/hoo-config.json (project overrides — scalars win; arrays union)\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { ThinkingLevel } from \"@kolisachint/hoocode-agent-core\";\nimport { getHooCodeDir } from \"../../config.js\";\n\nconst HOOCODE_DIR = getHooCodeDir();\nconst GLOBAL_CONFIG_PATH = join(HOOCODE_DIR, \"hoo-config.json\");\n\nexport interface ModeConfig {\n\t/** Tool names that bypass the permission gate in this mode */\n\tauto_allow?: string[];\n\t/** Tool names available in this mode (if set, only these tools are active) */\n\tenabled_tools?: string[];\n\t/** Tool names explicitly blocked in this mode regardless of enabled_tools */\n\tdenied_tools?: string[];\n\t/** Allowed write paths in this mode (glob patterns, only applies if write/edit is enabled) */\n\tallowed_write_paths?: string[];\n\t/** Regex patterns for allowed bash commands. If set, a command must match at least one to execute. */\n\tallowed_bash_commands?: string[];\n\t/** Regex patterns for denied bash commands. A command matching any pattern is blocked. */\n\tdenied_bash_commands?: string[];\n}\n\n/**\n * Tool-outcome-driven thinking escalation (on by default).\n *\n * The default (fast) path keeps thinking low so mechanical tool turns — reads,\n * greps, successful edits — don't pay the extended-thinking prefill tax. When a\n * tool *fails*, the next turn(s) escalate to a higher thinking level so the model\n * reasons through the failure, then the level is restored. This buys low latency\n * on the happy path while preserving deep reasoning exactly when something breaks.\n *\n * Enabled by default; set `thinking_escalation.enabled` to `false` to turn it off.\n *\n * Note: escalation uses the same setter as manual thinking control, so the\n * escalated level is briefly written to settings and restored when the window\n * ends. If a run is interrupted mid-window, the escalated level may persist until\n * the next change.\n */\nexport interface ThinkingEscalationConfig {\n\t/** Master switch. Default: true (set to false to disable). */\n\tenabled?: boolean;\n\t/** Level to escalate to after a tool error. Default: \"high\". */\n\ton_error?: ThinkingLevel;\n\t/** Restrict escalation to errors from these tool names. Default: any tool. */\n\ttools?: string[];\n\t/** Number of subsequent turns to stay escalated after an error. Default: 1. */\n\tcooldown_turns?: number;\n}\n\n/** LLM defaults seeded in hoo-config.json and honoured during model selection. */\nexport interface HooLlmConfig {\n\t/** Preferred provider when the pi-layer settings.json has no saved default. */\n\tdefault_provider?: string;\n\t/** Preferred model id for `default_provider` (otherwise the provider's built-in default). */\n\tdefault_model?: string;\n\t/** Informational map of provider → env var holding its API key. */\n\tproviders?: Record<string, { api_key_env?: string }>;\n}\n\nexport interface HooConfig {\n\t/** LLM default provider/model preferences. */\n\tllm?: HooLlmConfig;\n\t/** Manually-pinned active mode (overrides default \"build\") */\n\tactive_mode?: string;\n\t/** Per-mode configuration keyed by mode name */\n\tmodes?: Record<string, ModeConfig>;\n\t/** Extra directories to search for `{name}/system.md` mode files (after project + user). */\n\tmode_paths?: string[];\n\t/** Raise thinking after tool failures, restore on success. On by default; set `enabled: false` to disable. */\n\tthinking_escalation?: ThinkingEscalationConfig;\n}\n\nexport function readConfig(): HooConfig {\n\ttry {\n\t\treturn JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, \"utf8\")) as HooConfig;\n\t} catch {\n\t\treturn {};\n\t}\n}\n\nexport function writeConfig(config: HooConfig): void {\n\tif (!existsSync(HOOCODE_DIR)) mkdirSync(HOOCODE_DIR, { recursive: true });\n\twriteFileSync(GLOBAL_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\\n`, \"utf8\");\n}\n\n/**\n * Deep-merges a project-local config on top of the global config.\n *\n * Merge rules:\n * - active_mode: project wins if set\n * - modes[x].auto_allow: union of global + project arrays\n * - modes[x].allowed_write_paths: union of global + project arrays\n * - modes[x].enabled_tools: project wins if set, else falls back to global\n * - mode_paths: project list is prepended so project paths are searched first\n * - thinking_escalation: project wins as a whole if set, else inherit global\n */\nexport function mergeConfigs(global: HooConfig, project: HooConfig): HooConfig {\n\tconst merged: HooConfig = { ...global };\n\n\tif (project.active_mode !== undefined) merged.active_mode = project.active_mode;\n\n\t// thinking_escalation: project wins as a whole if set, else inherit global.\n\tif (project.thinking_escalation !== undefined) merged.thinking_escalation = project.thinking_escalation;\n\n\tif (project.modes) {\n\t\tmerged.modes = { ...(global.modes ?? {}) };\n\t\tfor (const [mode, projectCfg] of Object.entries(project.modes)) {\n\t\t\tconst globalCfg = global.modes?.[mode] ?? {};\n\t\t\tmerged.modes[mode] = {\n\t\t\t\t...globalCfg,\n\t\t\t\t...projectCfg,\n\t\t\t\t// Union both auto_allow lists so project can extend, not just replace\n\t\t\t\tauto_allow: Array.from(new Set([...(globalCfg.auto_allow ?? []), ...(projectCfg.auto_allow ?? [])])),\n\t\t\t\t// Union allowed_write_paths so project can extend\n\t\t\t\tallowed_write_paths: Array.from(\n\t\t\t\t\tnew Set([...(globalCfg.allowed_write_paths ?? []), ...(projectCfg.allowed_write_paths ?? [])]),\n\t\t\t\t),\n\t\t\t\t// enabled_tools: project wins if set, else falls back to global\n\t\t\t\tenabled_tools: projectCfg.enabled_tools ?? globalCfg.enabled_tools,\n\t\t\t\t// denied_tools: union so project can add more denied tools on top of global\n\t\t\t\tdenied_tools: Array.from(new Set([...(globalCfg.denied_tools ?? []), ...(projectCfg.denied_tools ?? [])])),\n\t\t\t\t// allowed_bash_commands: project wins if set, else falls back to global\n\t\t\t\tallowed_bash_commands: projectCfg.allowed_bash_commands ?? globalCfg.allowed_bash_commands,\n\t\t\t\t// denied_bash_commands: union so project can add more denied patterns on top of global\n\t\t\t\tdenied_bash_commands: Array.from(\n\t\t\t\t\tnew Set([...(globalCfg.denied_bash_commands ?? []), ...(projectCfg.denied_bash_commands ?? [])]),\n\t\t\t\t),\n\t\t\t};\n\t\t}\n\t}\n\n\tif (project.mode_paths || global.mode_paths) {\n\t\t// Project paths first so they're searched before global paths\n\t\tmerged.mode_paths = dedupePaths([...(project.mode_paths ?? []), ...(global.mode_paths ?? [])]);\n\t}\n\n\treturn merged;\n}\n\nfunction dedupePaths(paths: string[]): string[] {\n\tconst seen = new Set<string>();\n\tconst out: string[] = [];\n\tfor (const p of paths) {\n\t\tif (!seen.has(p)) {\n\t\t\tseen.add(p);\n\t\t\tout.push(p);\n\t\t}\n\t}\n\treturn out;\n}\n\nexport function mergeSearchPaths(...sources: (string[] | undefined)[]): string[] {\n\tconst merged: string[] = [];\n\tfor (const source of sources) {\n\t\tif (!source) continue;\n\t\tmerged.push(...source);\n\t}\n\treturn dedupePaths(merged);\n}\n\n/**\n * Reads the global config and optionally overlays the project-local config at\n * `./.hoocode/hoo-config.json`. Project values win on all scalar fields; arrays are\n * unioned (see mergeConfigs for full rules).\n */\nexport function readMergedConfig(cwd: string): HooConfig {\n\tconst global = readConfig();\n\tconst projectPath = join(cwd, \".hoocode\", \"hoo-config.json\");\n\tif (!existsSync(projectPath)) return global;\n\ttry {\n\t\tconst project = JSON.parse(readFileSync(projectPath, \"utf8\")) as HooConfig;\n\t\treturn mergeConfigs(global, project);\n\t} catch {\n\t\treturn global;\n\t}\n}\n"]}