skyloom 1.17.0 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/main.js +9 -0
  3. package/dist/cli/main.js.map +1 -1
  4. package/dist/core/agent.d.ts.map +1 -1
  5. package/dist/core/agent.js +8 -5
  6. package/dist/core/agent.js.map +1 -1
  7. package/dist/core/bus.d.ts.map +1 -1
  8. package/dist/core/bus.js +5 -3
  9. package/dist/core/bus.js.map +1 -1
  10. package/dist/core/logger.d.ts +15 -0
  11. package/dist/core/logger.d.ts.map +1 -1
  12. package/dist/core/logger.js +72 -2
  13. package/dist/core/logger.js.map +1 -1
  14. package/dist/core/patch.d.ts +59 -0
  15. package/dist/core/patch.d.ts.map +1 -0
  16. package/dist/core/patch.js +220 -0
  17. package/dist/core/patch.js.map +1 -0
  18. package/dist/core/protocol.d.ts +11 -0
  19. package/dist/core/protocol.d.ts.map +1 -0
  20. package/dist/core/protocol.js +39 -0
  21. package/dist/core/protocol.js.map +1 -0
  22. package/dist/core/search.d.ts +41 -0
  23. package/dist/core/search.d.ts.map +1 -0
  24. package/dist/core/search.js +156 -0
  25. package/dist/core/search.js.map +1 -0
  26. package/dist/core/security.d.ts.map +1 -1
  27. package/dist/core/security.js +2 -1
  28. package/dist/core/security.js.map +1 -1
  29. package/dist/core/tool.d.ts +8 -0
  30. package/dist/core/tool.d.ts.map +1 -1
  31. package/dist/core/tool.js +7 -0
  32. package/dist/core/tool.js.map +1 -1
  33. package/dist/tools/builtin.d.ts.map +1 -1
  34. package/dist/tools/builtin.js +68 -4
  35. package/dist/tools/builtin.js.map +1 -1
  36. package/dist/tools/websearch.d.ts +7 -1
  37. package/dist/tools/websearch.d.ts.map +1 -1
  38. package/dist/tools/websearch.js +19 -4
  39. package/dist/tools/websearch.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/main.ts +7 -0
  42. package/src/core/agent.ts +7 -5
  43. package/src/core/bus.ts +6 -3
  44. package/src/core/logger.ts +40 -2
  45. package/src/core/patch.ts +176 -0
  46. package/src/core/protocol.ts +36 -0
  47. package/src/core/search.ts +138 -0
  48. package/src/core/security.ts +2 -1
  49. package/src/core/tool.ts +15 -0
  50. package/src/tools/builtin.ts +63 -4
  51. package/src/tools/websearch.ts +22 -6
  52. package/tests/logger.test.ts +44 -0
  53. package/tests/patch.test.ts +128 -0
  54. package/tests/protocol.test.ts +27 -0
  55. package/tests/search.test.ts +87 -0
  56. package/tests/tool.test.ts +44 -0
  57. package/tests/websearch.test.ts +24 -0
@@ -6,6 +6,10 @@
6
6
  * log.info("chat_request", { userMessage: "hello", agent: "fog" });
7
7
  */
8
8
 
9
+ import * as fs from "fs";
10
+ import * as path from "path";
11
+ import * as os from "os";
12
+
9
13
  export enum LogLevel {
10
14
  DEBUG = 0,
11
15
  INFO = 1,
@@ -13,6 +17,39 @@ export enum LogLevel {
13
17
  ERROR = 3,
14
18
  }
15
19
 
20
+ /**
21
+ * Where log lines go. Defaults to stderr. In a full-screen TUI, stderr writes
22
+ * paint over the rendered frame (the "乱码" garbling), so the interactive UIs
23
+ * redirect logs to a file via setLogFile() instead.
24
+ */
25
+ export type LogSink = (line: string) => void;
26
+ let logSink: LogSink = (line) => { try { process.stderr.write(line); } catch { /* ignore */ } };
27
+
28
+ /** Send all logs to `fn` instead of stderr. */
29
+ export function setLogSink(fn: LogSink): void { logSink = fn; }
30
+
31
+ /** Drop all log output (e.g. piped/headless contexts that want a clean stream). */
32
+ export function silenceLogs(): void { logSink = () => { /* discard */ }; }
33
+
34
+ /**
35
+ * Route logs to a file (appended), keeping them off the terminal so they can't
36
+ * corrupt an interactive TUI. Falls back to silencing if the file can't open.
37
+ */
38
+ export function setLogFile(filePath?: string): string | null {
39
+ const target = filePath
40
+ ? (filePath.startsWith("~") ? path.join(os.homedir(), filePath.slice(1)) : filePath)
41
+ : path.join(os.homedir(), ".skyloom", "skyloom.log");
42
+ try {
43
+ fs.mkdirSync(path.dirname(target), { recursive: true });
44
+ const fd = fs.openSync(target, "a");
45
+ logSink = (line) => { try { fs.writeSync(fd, line); } catch { /* ignore */ } };
46
+ return target;
47
+ } catch {
48
+ silenceLogs();
49
+ return null;
50
+ }
51
+ }
52
+
16
53
  export interface LogEntry {
17
54
  ts: string;
18
55
  level: string;
@@ -69,8 +106,9 @@ export class Logger {
69
106
  return value;
70
107
  });
71
108
 
72
- // Write all logs to stderr to keep stdout clean for chat/TUI
73
- process.stderr.write(line + "\n");
109
+ // Route through the configured sink (stderr by default; a file in TUI mode
110
+ // so log lines never paint over the rendered frame).
111
+ logSink(line + "\n");
74
112
  }
75
113
 
76
114
  debug(msg: string, extra?: Record<string, unknown>) {
@@ -0,0 +1,176 @@
1
+ /**
2
+ * apply_patch — atomic, multi-file edits for larger refactors.
3
+ *
4
+ * Uses exact search/replace blocks (not line-number/context hunks), so it can't
5
+ * misapply against a drifted file: every SEARCH must match the current file
6
+ * content exactly and uniquely. The whole patch is validated first; disk is
7
+ * only touched once every operation is known to apply — so a bad block aborts
8
+ * the patch without leaving a half-applied tree.
9
+ *
10
+ * Format:
11
+ * *** Update File: path
12
+ * <<<<<<< SEARCH
13
+ * exact old text
14
+ * =======
15
+ * new text
16
+ * >>>>>>> REPLACE
17
+ * (one or more blocks per file)
18
+ *
19
+ * *** Add File: path
20
+ * full file content
21
+ *
22
+ * *** Delete File: path
23
+ *
24
+ * An optional `*** Begin Patch` / `*** End Patch` envelope is tolerated.
25
+ */
26
+
27
+ import * as fs from 'fs';
28
+ import * as path from 'path';
29
+ import { countOccurrences, unifiedDiff } from './diff';
30
+
31
+ export interface PatchBlock { search: string; replace: string; }
32
+ export type PatchOp =
33
+ | { op: 'update'; path: string; blocks: PatchBlock[] }
34
+ | { op: 'add'; path: string; content: string }
35
+ | { op: 'delete'; path: string };
36
+
37
+ const HDR_UPDATE = /^\*\*\* Update File:\s*(.+?)\s*$/;
38
+ const HDR_ADD = /^\*\*\* Add File:\s*(.+?)\s*$/;
39
+ const HDR_DELETE = /^\*\*\* Delete File:\s*(.+?)\s*$/;
40
+ const MARK_SEARCH = '<<<<<<< SEARCH';
41
+ const MARK_SEP = '=======';
42
+ const MARK_REPLACE = '>>>>>>> REPLACE';
43
+
44
+ export function parsePatch(text: string): { ops: PatchOp[] } | { error: string } {
45
+ const lines = text.split(/\r?\n/);
46
+ const ops: PatchOp[] = [];
47
+ let i = 0;
48
+
49
+ const isHeader = (l: string) => l.startsWith('*** ');
50
+
51
+ while (i < lines.length) {
52
+ const line = lines[i];
53
+ if (line.startsWith('*** Begin Patch') || line.startsWith('*** End Patch')) { i++; continue; }
54
+ if (line.trim() === '') { i++; continue; }
55
+
56
+ let m: RegExpMatchArray | null;
57
+ if ((m = line.match(HDR_UPDATE))) {
58
+ const filePath = m[1];
59
+ i++;
60
+ const blocks: PatchBlock[] = [];
61
+ while (i < lines.length && !isHeader(lines[i])) {
62
+ if (lines[i].trim() === '') { i++; continue; }
63
+ if (lines[i] !== MARK_SEARCH) {
64
+ return { error: `Malformed Update block for '${filePath}': expected '${MARK_SEARCH}', got ${JSON.stringify(lines[i].slice(0, 40))}` };
65
+ }
66
+ i++;
67
+ const search: string[] = [];
68
+ while (i < lines.length && lines[i] !== MARK_SEP) search.push(lines[i++]);
69
+ if (i >= lines.length) return { error: `Unterminated SEARCH (missing '${MARK_SEP}') for '${filePath}'` };
70
+ i++; // consume separator
71
+ const replace: string[] = [];
72
+ while (i < lines.length && lines[i] !== MARK_REPLACE) replace.push(lines[i++]);
73
+ if (i >= lines.length) return { error: `Unterminated REPLACE (missing '${MARK_REPLACE}') for '${filePath}'` };
74
+ i++; // consume replace marker
75
+ blocks.push({ search: search.join('\n'), replace: replace.join('\n') });
76
+ }
77
+ if (blocks.length === 0) return { error: `Update File '${filePath}' has no SEARCH/REPLACE blocks` };
78
+ ops.push({ op: 'update', path: filePath, blocks });
79
+ } else if ((m = line.match(HDR_ADD))) {
80
+ const filePath = m[1];
81
+ i++;
82
+ const content: string[] = [];
83
+ while (i < lines.length && !isHeader(lines[i])) content.push(lines[i++]);
84
+ while (content.length && content[content.length - 1] === '') content.pop();
85
+ ops.push({ op: 'add', path: filePath, content: content.length ? content.join('\n') + '\n' : '' });
86
+ } else if ((m = line.match(HDR_DELETE))) {
87
+ ops.push({ op: 'delete', path: m[1] });
88
+ i++;
89
+ } else {
90
+ return { error: `Unexpected line outside any file section: ${JSON.stringify(line.slice(0, 60))}` };
91
+ }
92
+ }
93
+
94
+ if (ops.length === 0) return { error: 'Patch contains no operations.' };
95
+ return { ops };
96
+ }
97
+
98
+ export interface ApplyOptions {
99
+ cwd?: string;
100
+ /** Optional workspace-fence check; return a non-null string to abort. */
101
+ fenceCheck?: (abs: string) => string | null;
102
+ /** Optional pre-write snapshot hook (for /rewind). */
103
+ snapshot?: (abs: string) => void;
104
+ }
105
+
106
+ interface PlannedChange {
107
+ op: 'update' | 'add' | 'delete';
108
+ path: string;
109
+ abs: string;
110
+ oldContent?: string;
111
+ newContent?: string;
112
+ }
113
+
114
+ /**
115
+ * Validate every operation, then apply them all. Returns a human summary on
116
+ * success or an `Error: …` string on the first validation failure (no writes).
117
+ */
118
+ export function applyPatch(text: string, opts: ApplyOptions = {}): string {
119
+ const cwd = opts.cwd || process.cwd();
120
+ const parsed = parsePatch(text);
121
+ if ('error' in parsed) return `Error: ${parsed.error}`;
122
+
123
+ const plan: PlannedChange[] = [];
124
+
125
+ // ── Validate everything first (no disk mutation) ──
126
+ for (const op of parsed.ops) {
127
+ const abs = path.resolve(cwd, op.path);
128
+ if (opts.fenceCheck) { const f = opts.fenceCheck(abs); if (f) return f; }
129
+
130
+ if (op.op === 'update') {
131
+ if (!fs.existsSync(abs)) return `Error: Update target not found: ${op.path}`;
132
+ let content: string;
133
+ try { content = fs.readFileSync(abs, 'utf8'); } catch (e) { return `Error: cannot read ${op.path}: ${e}`; }
134
+ const orig = content;
135
+ for (const block of op.blocks) {
136
+ if (block.search === block.replace) return `Error: a SEARCH/REPLACE block for ${op.path} is a no-op (identical).`;
137
+ const n = countOccurrences(content, block.search);
138
+ if (n === 0) return `Error: SEARCH block not found in ${op.path}: ${JSON.stringify(block.search.slice(0, 80))}`;
139
+ if (n > 1) return `Error: SEARCH block is ambiguous in ${op.path} (appears ${n} times) — add more context to make it unique.`;
140
+ content = content.replace(block.search, () => block.replace); // literal replacement
141
+ }
142
+ plan.push({ op: 'update', path: op.path, abs, oldContent: orig, newContent: content });
143
+ } else if (op.op === 'add') {
144
+ if (fs.existsSync(abs)) return `Error: Add target already exists: ${op.path} (use Update File to modify it)`;
145
+ plan.push({ op: 'add', path: op.path, abs, newContent: op.content });
146
+ } else {
147
+ if (!fs.existsSync(abs)) return `Error: Delete target not found: ${op.path}`;
148
+ plan.push({ op: 'delete', path: op.path, abs });
149
+ }
150
+ }
151
+
152
+ // ── Apply (validation passed for all ops) ──
153
+ const summary: string[] = [];
154
+ for (const p of plan) {
155
+ try {
156
+ if (p.op === 'update') {
157
+ opts.snapshot?.(p.abs);
158
+ fs.writeFileSync(p.abs, p.newContent!, 'utf8');
159
+ const d = unifiedDiff(p.oldContent!, p.newContent!, { context: 0 });
160
+ summary.push(`~ ${p.path} (+${d.stat.added} -${d.stat.removed})`);
161
+ } else if (p.op === 'add') {
162
+ fs.mkdirSync(path.dirname(p.abs), { recursive: true });
163
+ fs.writeFileSync(p.abs, p.newContent!, 'utf8');
164
+ summary.push(`+ ${p.path} (new)`);
165
+ } else {
166
+ opts.snapshot?.(p.abs);
167
+ fs.unlinkSync(p.abs);
168
+ summary.push(`- ${p.path} (deleted)`);
169
+ }
170
+ } catch (e) {
171
+ return `Error: patch validated but failed while writing ${p.path}: ${e}\nPartial summary:\n${summary.join('\n')}`;
172
+ }
173
+ }
174
+
175
+ return `Applied patch — ${plan.length} file${plan.length !== 1 ? 's' : ''}:\n${summary.join('\n')}`;
176
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Engineering protocol — the working discipline injected into every agent's
3
+ * system prompt so it operates like a senior engineer, not just a code typist.
4
+ *
5
+ * Kept as a pure function (no `this`) so it can be unit-tested and evolved in
6
+ * one place. It deliberately names the project's own capabilities
7
+ * (code_search / get_diagnostics / run_bash) so the model actually uses the
8
+ * read→edit→verify loop instead of guessing.
9
+ */
10
+
11
+ export function engineeringProtocol(lang: string = 'zh'): string {
12
+ if (lang === 'en') {
13
+ return [
14
+ '## Engineering Standard (work like a senior engineer)',
15
+ '- Understand before changing: read the target code and its tests/callers (code_search → read_file) before editing. Match the surrounding style, naming, and existing patterns.',
16
+ '- Reuse first: prefer utilities/libraries already in the project over inventing new ones; check how similar things are done here.',
17
+ '- Root cause, not symptom: reproduce the failure, find why it happens, fix the cause — never paper over it or hardcode around a test.',
18
+ '- Minimal, surgical diffs: change only what the task needs. No drive-by reformatting or unrelated edits.',
19
+ '- Verify your work: after editing code, run get_diagnostics on the changed files and run the project tests/build (run_bash). Do not claim done until it is green; report the real result.',
20
+ '- Be honest about uncertainty: never fabricate APIs, file paths, or results. If unsure, say so and check.',
21
+ '- Security & performance: validate inputs, handle errors for real, avoid obvious injection / DoS / performance traps.',
22
+ 'You may read and modify Skyloom\'s own source.',
23
+ ].join('\n');
24
+ }
25
+ return [
26
+ '## 工程标准(像资深工程师一样工作)',
27
+ '- 改之前先理解:动手前先读目标代码及其测试/调用方(code_search → read_file),沿用周围的风格、命名与既有模式。',
28
+ '- 优先复用:优先用项目里已有的工具/库,而不是另造轮子;先看相似功能此处怎么做。',
29
+ '- 治根因不治症状:先复现失败,定位根本原因再修;绝不糊弄,绝不为了通过测试而硬编码。',
30
+ '- 最小手术式改动:只改任务所需,不顺手重排格式、不夹带无关修改。',
31
+ '- 改完必验证:改代码后对改动文件跑 get_diagnostics,并跑项目测试/构建(run_bash);未变绿不算完成,如实汇报真实结果。',
32
+ '- 诚实面对不确定:绝不编造 API、文件路径或结果;不确定就说明并去核实。',
33
+ '- 安全与性能:校验输入、做真实的错误处理,避开明显的注入/DoS/性能陷阱。',
34
+ '你可以阅读和修改 Skyloom 自身源码。',
35
+ ].join('\n');
36
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Code search — a dependency-light, cross-platform engine for "find where X is
3
+ * used and read it in context". Backs the code_search tool and is the fallback
4
+ * for grep when ripgrep/grep aren't installed (common on Windows), so search
5
+ * never silently returns nothing just because a binary is missing.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { globSync } from 'glob';
11
+
12
+ export interface SearchOptions {
13
+ pattern: string;
14
+ /** Root directory to search (default: cwd). */
15
+ root?: string;
16
+ /** Glob to restrict files (e.g. "**\/*.ts"). Default: all files. */
17
+ glob?: string;
18
+ /** Case-insensitive match (default false). */
19
+ ignoreCase?: boolean;
20
+ /** Treat pattern as a regular expression (default true). */
21
+ regex?: boolean;
22
+ /** Lines of context around each match (default 0). */
23
+ context?: number;
24
+ /** Cap on total matches returned (default 200). */
25
+ maxResults?: number;
26
+ /** Skip files larger than this many bytes (default 2 MiB). */
27
+ maxFileBytes?: number;
28
+ }
29
+
30
+ export interface SearchMatch {
31
+ file: string; // relative to root
32
+ line: number; // 1-based
33
+ text: string;
34
+ before?: string[];
35
+ after?: string[];
36
+ }
37
+
38
+ export interface SearchResult {
39
+ matches: SearchMatch[];
40
+ filesScanned: number;
41
+ truncated: boolean;
42
+ error?: string;
43
+ }
44
+
45
+ /** Directories never worth searching — vendored, generated, or VCS internals. */
46
+ const DEFAULT_IGNORES = [
47
+ '**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**',
48
+ '**/coverage/**', '**/.next/**', '**/out/**', '**/.cache/**',
49
+ '**/vendor/**', '**/.venv/**', '**/__pycache__/**',
50
+ ];
51
+
52
+ function looksBinary(buf: Buffer): boolean {
53
+ const n = Math.min(buf.length, 8000);
54
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true; // NUL byte ⇒ binary
55
+ return false;
56
+ }
57
+
58
+ /** Pure-JS recursive code search. No external process required. */
59
+ export function searchCode(opts: SearchOptions): SearchResult {
60
+ const root = path.resolve(opts.root || process.cwd());
61
+ const maxResults = opts.maxResults ?? 200;
62
+ const maxFileBytes = opts.maxFileBytes ?? 2 * 1024 * 1024;
63
+ const context = Math.max(0, opts.context ?? 0);
64
+
65
+ let matcher: (line: string) => boolean;
66
+ if (opts.regex === false) {
67
+ const needle = opts.ignoreCase ? opts.pattern.toLowerCase() : opts.pattern;
68
+ matcher = (line) => (opts.ignoreCase ? line.toLowerCase() : line).includes(needle);
69
+ } else {
70
+ let re: RegExp;
71
+ try {
72
+ re = new RegExp(opts.pattern, opts.ignoreCase ? 'i' : '');
73
+ } catch (e) {
74
+ return { matches: [], filesScanned: 0, truncated: false, error: `invalid regex: ${e}` };
75
+ }
76
+ matcher = (line) => re.test(line);
77
+ }
78
+
79
+ let files: string[];
80
+ try {
81
+ files = globSync(opts.glob || '**/*', {
82
+ cwd: root, nodir: true, dot: false, ignore: DEFAULT_IGNORES,
83
+ });
84
+ } catch (e) {
85
+ return { matches: [], filesScanned: 0, truncated: false, error: `glob failed: ${e}` };
86
+ }
87
+
88
+ const matches: SearchMatch[] = [];
89
+ let filesScanned = 0;
90
+ let truncated = false;
91
+
92
+ for (const rel of files) {
93
+ if (matches.length >= maxResults) { truncated = true; break; }
94
+ const abs = path.join(root, rel);
95
+ let buf: Buffer;
96
+ try {
97
+ const stat = fs.statSync(abs);
98
+ if (stat.size > maxFileBytes) continue;
99
+ buf = fs.readFileSync(abs);
100
+ } catch { continue; }
101
+ if (looksBinary(buf)) continue;
102
+
103
+ filesScanned++;
104
+ const lines = buf.toString('utf8').split('\n');
105
+ for (let i = 0; i < lines.length; i++) {
106
+ if (!matcher(lines[i])) continue;
107
+ const m: SearchMatch = { file: rel.split(path.sep).join('/'), line: i + 1, text: lines[i] };
108
+ if (context > 0) {
109
+ m.before = lines.slice(Math.max(0, i - context), i);
110
+ m.after = lines.slice(i + 1, i + 1 + context);
111
+ }
112
+ matches.push(m);
113
+ if (matches.length >= maxResults) { truncated = true; break; }
114
+ }
115
+ }
116
+
117
+ return { matches, filesScanned, truncated };
118
+ }
119
+
120
+ /** Render a SearchResult as ripgrep-style `file:line:text` (with context). */
121
+ export function formatSearchResult(res: SearchResult): string {
122
+ if (res.error) return `Search error: ${res.error}`;
123
+ if (res.matches.length === 0) return 'No matches found.';
124
+ const out: string[] = [];
125
+ let lastFile = '';
126
+ for (const m of res.matches) {
127
+ if (m.file !== lastFile) { if (out.length) out.push(''); lastFile = m.file; }
128
+ for (let k = 0; k < (m.before?.length || 0); k++) {
129
+ out.push(`${m.file}:${m.line - (m.before!.length - k)}- ${m.before![k]}`);
130
+ }
131
+ out.push(`${m.file}:${m.line}: ${m.text}`);
132
+ for (let k = 0; k < (m.after?.length || 0); k++) {
133
+ out.push(`${m.file}:${m.line + k + 1}- ${m.after![k]}`);
134
+ }
135
+ }
136
+ if (res.truncated) out.push(`\n…[results truncated at ${res.matches.length} matches — narrow the pattern or glob]`);
137
+ return out.join('\n');
138
+ }
@@ -77,6 +77,7 @@ const TOOL_DANGER_MAP: Record<string, DangerLevel> = {
77
77
 
78
78
  write_file: DangerLevel.LOW,
79
79
  edit_file: DangerLevel.LOW,
80
+ apply_patch: DangerLevel.LOW,
80
81
  copy_file: DangerLevel.LOW,
81
82
  move_file: DangerLevel.LOW,
82
83
  make_directory: DangerLevel.LOW,
@@ -164,7 +165,7 @@ export const PERMISSION_MODE_ALIASES: Record<string, ApprovalMode> = {
164
165
  };
165
166
 
166
167
  /** Tools that mutate the filesystem — the ones acceptEdits waves through. */
167
- const EDIT_TOOL_RE = /^(write_|edit_|append_|replace_|create_|make_|copy_|move_|delete_)/;
168
+ const EDIT_TOOL_RE = /^(write_|edit_|append_|replace_|create_|make_|copy_|move_|delete_)|^apply_patch$/;
168
169
  export function isEditTool(toolName: string): boolean { return EDIT_TOOL_RE.test(toolName); }
169
170
 
170
171
  /**
package/src/core/tool.ts CHANGED
@@ -46,6 +46,14 @@ export interface ToolDefinition {
46
46
  */
47
47
  idempotent?: boolean;
48
48
  timeout?: number;
49
+ /**
50
+ * Optional output guard: inspect the handler's result and return an error
51
+ * message if it's not valid (else null/undefined). A non-null return makes
52
+ * the call fail — routed through the same retry + circuit-breaker path as a
53
+ * thrown error — so a tool/plugin can reject malformed output instead of
54
+ * passing garbage back to the model as "success".
55
+ */
56
+ validateOutput?: (result: string, params: Record<string, unknown>) => string | null | undefined;
49
57
  }
50
58
 
51
59
  /** Order-stable JSON key so {a,b} and {b,a} hash to the same cache/dedup key. */
@@ -430,6 +438,13 @@ export class ToolRegistry extends EventEmitter {
430
438
  if (timer) clearTimeout(timer);
431
439
  }
432
440
 
441
+ // Output guard: a non-null message means the result is invalid. Throw so
442
+ // it flows through the same retry + breaker path as any other failure.
443
+ if (tool.validateOutput) {
444
+ const outErr = tool.validateOutput(result, params);
445
+ if (outErr) throw new Error(`invalid tool output: ${outErr}`);
446
+ }
447
+
433
448
  const duration = Date.now() - startTime;
434
449
 
435
450
  // Cache result
@@ -12,6 +12,8 @@ import { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck } from './guards
12
12
  import { webSearch, formatSearchResults, readPage } from './websearch';
13
13
  import { countOccurrences, unifiedDiff } from '../core/diff';
14
14
  import { getDiagnostics, formatDiagnostics } from '../core/diagnostics';
15
+ import { searchCode, formatSearchResult } from '../core/search';
16
+ import { applyPatch } from '../core/patch';
15
17
 
16
18
  // Re-exported so existing importers/tests keep resolving these from builtin.
17
19
  export { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck };
@@ -142,6 +144,30 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
142
144
  },
143
145
  });
144
146
 
147
+ registry.register({
148
+ name: 'apply_patch',
149
+ description: 'Apply an atomic, multi-file edit in one call — ideal for larger refactors touching several places/files. The whole patch is validated before anything is written, so a bad block aborts cleanly with no half-applied changes. Each SEARCH must match the file exactly and uniquely. Format:\n*** Update File: path\n<<<<<<< SEARCH\nold exact text\n=======\nnew text\n>>>>>>> REPLACE\n(repeat blocks; also *** Add File: path / full content, and *** Delete File: path)',
150
+ parameters: [
151
+ { name: 'patch', type: 'string', description: 'The patch text in the *** Update/Add/Delete File + SEARCH/REPLACE format', required: true },
152
+ ],
153
+ handler: async (params) => {
154
+ let snapshot: ((abs: string) => void) | undefined;
155
+ try {
156
+ const { getFileCheckpoints } = require('../core/file_checkpoint');
157
+ const cp = getFileCheckpoints();
158
+ snapshot = (abs: string) => { try { cp.snapshot(abs); } catch { /* best-effort */ } };
159
+ } catch { /* checkpointing optional */ }
160
+ try {
161
+ return applyPatch(String(params.patch || ''), {
162
+ fenceCheck: (abs: string) => fenceCheck(abs),
163
+ snapshot,
164
+ });
165
+ } catch (e) {
166
+ return `Error applying patch: ${e}`;
167
+ }
168
+ },
169
+ });
170
+
145
171
  registry.register({
146
172
  name: 'delete_file',
147
173
  description: 'Delete a file at the given path.',
@@ -334,6 +360,9 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
334
360
  { name: 'engine', type: 'string', description: 'Optional provider: tavily|brave|serper|searxng|jina|duckduckgo|bing|baidu|sogou. Default: auto (uses a configured API key if present, else the keyless Jina endpoint, else scraping).', required: false },
335
361
  { name: 'max_results', type: 'number', description: 'Max results to return (default 8, capped at 20)', required: false },
336
362
  ],
363
+ // Larger than webSearch's internal budget (~22s) so the waterfall returns a
364
+ // clear "no results" message rather than being cut off as a generic timeout.
365
+ timeout: 45000,
337
366
  handler: async (params) => {
338
367
  const query = String(params.query || '').trim();
339
368
  if (!query) return 'Error: query is required';
@@ -470,10 +499,39 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
470
499
 
471
500
  // ── Utility Tools ──
472
501
 
502
+ registry.register({
503
+ name: 'code_search',
504
+ idempotent: true,
505
+ description: 'Search source code for a regex pattern across files. Returns file:line matches with optional surrounding context. Use this to find where a symbol/string is defined or used. Restrict scope with glob (e.g. "**/*.ts") and add context lines to read around hits.',
506
+ parameters: [
507
+ { name: 'pattern', type: 'string', description: 'Regex (or literal if regex=false) to search for', required: true },
508
+ { name: 'path', type: 'string', description: 'Root directory to search (default: cwd)', required: false },
509
+ { name: 'glob', type: 'string', description: 'Restrict to files matching this glob, e.g. "**/*.ts"', required: false },
510
+ { name: 'context', type: 'number', description: 'Lines of context around each match (default 0)', required: false },
511
+ { name: 'ignore_case', type: 'boolean', description: 'Case-insensitive match (default false)', required: false },
512
+ { name: 'regex', type: 'boolean', description: 'Treat pattern as regex (default true; false = literal substring)', required: false },
513
+ { name: 'max_results', type: 'number', description: 'Max matches to return (default 200)', required: false },
514
+ ],
515
+ handler: async (params) => {
516
+ const root = params.path ? path.resolve(params.path as string) : process.cwd();
517
+ const fenced = fenceCheck(root); if (fenced) return fenced;
518
+ const res = searchCode({
519
+ pattern: String(params.pattern || ''),
520
+ root,
521
+ glob: params.glob ? String(params.glob) : undefined,
522
+ context: params.context != null ? Number(params.context) : 0,
523
+ ignoreCase: params.ignore_case === true,
524
+ regex: params.regex !== false,
525
+ maxResults: params.max_results != null ? Number(params.max_results) : 200,
526
+ });
527
+ return formatSearchResult(res);
528
+ },
529
+ });
530
+
473
531
  registry.register({
474
532
  name: 'grep',
475
533
  idempotent: true,
476
- description: 'Search for a pattern in files using ripgrep or grep.',
534
+ description: 'Search for a regex pattern in files using ripgrep/grep, with a built-in fallback when neither is installed. For richer control (glob, context, ignore-case) prefer code_search.',
477
535
  parameters: [
478
536
  { name: 'pattern', type: 'string', description: 'Regex pattern to search for', required: true },
479
537
  { name: 'path', type: 'string', description: 'Directory to search in', required: false },
@@ -496,12 +554,13 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
496
554
  const out = execFileSync(bin, args, { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
497
555
  return out || 'No matches found.';
498
556
  } catch (e: any) {
499
- // exit status 1 = ran successfully, zero matches; anything else
500
- // (e.g. binary not installed) falls through to the next variant.
557
+ // exit status 1 = ran successfully, zero matches. Any other failure
558
+ // (e.g. binary not installed) falls through to the next variant, then
559
+ // to the pure-JS engine so search works even with no rg/grep.
501
560
  if (e?.status === 1) return 'No matches found.';
502
561
  }
503
562
  }
504
- return 'No matches found.';
563
+ return formatSearchResult(searchCode({ pattern: pat, root: searchDir, maxResults: 200 }));
505
564
  },
506
565
  });
507
566
 
@@ -44,7 +44,9 @@ export interface WebHttp {
44
44
  }
45
45
 
46
46
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
47
- const DEFAULT_TIMEOUT = 15000;
47
+ // Per-request timeout. Kept short so a dead/slow provider fails fast and the
48
+ // waterfall moves on quickly rather than burning 15s each across five providers.
49
+ const DEFAULT_TIMEOUT = 8000;
48
50
 
49
51
  /** Default HTTP client backed by axios. */
50
52
  export const defaultHttp: WebHttp = {
@@ -294,40 +296,54 @@ export interface WebSearchOptions {
294
296
  env?: EnvMap; // defaults to process.env
295
297
  http?: WebHttp; // defaults to axios-backed client
296
298
  onProviderError?: (provider: string, error: string) => void;
299
+ /** Total wall-clock budget for the waterfall; stop trying once exceeded. */
300
+ budgetMs?: number;
297
301
  }
298
302
 
299
303
  /**
300
304
  * Run a web search through the provider waterfall. Returns the first provider
301
305
  * that yields results, or a response with an empty result set + the list of
302
- * providers that were tried.
306
+ * providers that were tried. A total time budget caps the waterfall so the tool
307
+ * returns a clear "no results" message instead of being killed by an outer
308
+ * timeout (which would surface as an opaque "Tool execution timeout").
303
309
  */
304
- export async function webSearch(query: string, opts: WebSearchOptions = {}): Promise<SearchResponse & { tried: string[] }> {
310
+ export async function webSearch(query: string, opts: WebSearchOptions = {}): Promise<SearchResponse & { tried: string[]; errors?: number }> {
305
311
  const q = (query || '').trim();
306
312
  if (!q) throw new Error('query is required');
307
313
  const max = Math.max(1, Math.min(20, Math.floor(opts.max ?? 8)));
308
314
  const env = opts.env ?? (process.env as EnvMap);
309
315
  const http = opts.http ?? defaultHttp;
310
316
  const pinned = (opts.engine || env.SKYLOOM_SEARCH_ENGINE || '').trim();
317
+ const budgetMs = opts.budgetMs ?? 22000;
318
+ const start = Date.now();
311
319
 
312
320
  const providers = resolveProviders(env, pinned);
313
321
  const tried: string[] = [];
322
+ let errors = 0;
314
323
  for (const provider of providers) {
324
+ // Out of time: stop the waterfall and return what we have (a clear empty
325
+ // result), rather than letting a slow tail provider blow the tool timeout.
326
+ if (tried.length > 0 && Date.now() - start > budgetMs) break;
315
327
  tried.push(provider.id);
316
328
  try {
317
329
  const res = await provider.run(http, env, q, max);
318
330
  if (res.results.length > 0 || res.answer) return { ...res, tried };
319
331
  } catch (e: any) {
332
+ errors++;
320
333
  opts.onProviderError?.(provider.id, String(e?.message || e));
321
334
  }
322
335
  }
323
- return { provider: 'none', results: [], tried };
336
+ return { provider: 'none', results: [], tried, errors };
324
337
  }
325
338
 
326
339
  /** Format a SearchResponse as compact text for an LLM tool result. */
327
- export function formatSearchResults(res: SearchResponse & { tried?: string[] }): string {
340
+ export function formatSearchResults(res: SearchResponse & { tried?: string[]; errors?: number }): string {
328
341
  if (!res.results.length && !res.answer) {
329
342
  const tried = res.tried?.length ? ` (tried: ${res.tried.join(', ')})` : '';
330
- return `No search results found${tried}. Try a simpler query, or set a search API key (TAVILY_API_KEY / BRAVE_API_KEY / SERPER_API_KEY) for more reliable results.`;
343
+ const note = res.errors && res.errors > 0
344
+ ? ' Every provider errored or timed out — likely no/blocked network connectivity or rate limiting in this environment.'
345
+ : '';
346
+ return `No search results found${tried}.${note} Try a simpler query, or set a search API key (TAVILY_API_KEY / BRAVE_API_KEY / SERPER_API_KEY) for more reliable results.`;
331
347
  }
332
348
  const parts: string[] = [];
333
349
  if (res.answer) parts.push(`Answer: ${res.answer}\n`);