@yeaft/webchat-agent 0.1.442 → 0.1.443
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.
- package/package.json +1 -1
- package/unify/tools/agent.js +89 -0
- package/unify/tools/apply-patch.js +176 -0
- package/unify/tools/ask-user.js +62 -0
- package/unify/tools/bash.js +189 -0
- package/unify/tools/close-agent.js +58 -0
- package/unify/tools/file-edit.js +120 -0
- package/unify/tools/file-read.js +125 -0
- package/unify/tools/file-write.js +73 -0
- package/unify/tools/glob.js +143 -0
- package/unify/tools/grep.js +268 -0
- package/unify/tools/history-search.js +69 -0
- package/unify/tools/image-generation.js +97 -0
- package/unify/tools/index.js +92 -0
- package/unify/tools/js-repl.js +122 -0
- package/unify/tools/list-agents.js +56 -0
- package/unify/tools/list-dir.js +106 -0
- package/unify/tools/memory-read.js +91 -0
- package/unify/tools/memory-search.js +101 -0
- package/unify/tools/memory-write.js +114 -0
- package/unify/tools/notebook-edit.js +132 -0
- package/unify/tools/request-permissions.js +60 -0
- package/unify/tools/send-message.js +62 -0
- package/unify/tools/task-tools.js +358 -0
- package/unify/tools/tool-search.js +97 -0
- package/unify/tools/view-image.js +117 -0
- package/unify/tools/wait-agent.js +84 -0
- package/unify/tools/web-fetch.js +131 -0
- package/unify/tools/web-search.js +80 -0
- package/unify/tools/write-stdin.js +54 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-read.js — Read file contents with line numbers.
|
|
3
|
+
*
|
|
4
|
+
* Reads text files with `cat -n` style line numbering, supports
|
|
5
|
+
* offset/limit for large files, and handles binary file detection.
|
|
6
|
+
*
|
|
7
|
+
* Modeled after Claude Code's Read tool.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineTool } from './types.js';
|
|
11
|
+
import { readFile, stat } from 'fs/promises';
|
|
12
|
+
import { existsSync } from 'fs';
|
|
13
|
+
import { resolve, extname } from 'path';
|
|
14
|
+
|
|
15
|
+
/** Binary file extensions that shouldn't be read as text. */
|
|
16
|
+
const BINARY_EXTS = new Set([
|
|
17
|
+
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.svg',
|
|
18
|
+
'.mp3', '.mp4', '.avi', '.mov', '.wav', '.flac',
|
|
19
|
+
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
|
|
20
|
+
'.exe', '.dll', '.so', '.dylib', '.o',
|
|
21
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
22
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
23
|
+
'.sqlite', '.db',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Max file size to read (10 MB). */
|
|
27
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Default number of lines to read. */
|
|
30
|
+
const DEFAULT_LIMIT = 2000;
|
|
31
|
+
|
|
32
|
+
export default defineTool({
|
|
33
|
+
name: 'FileRead',
|
|
34
|
+
description: `Read a file from the filesystem with line numbers.
|
|
35
|
+
|
|
36
|
+
Returns file contents with line numbers (like \`cat -n\`).
|
|
37
|
+
Supports offset and limit for reading specific portions of large files.
|
|
38
|
+
|
|
39
|
+
Guidelines:
|
|
40
|
+
- Use absolute paths when possible
|
|
41
|
+
- For large files, use offset and limit to read specific sections
|
|
42
|
+
- Binary files are detected by extension and rejected
|
|
43
|
+
- Maximum file size: 10MB
|
|
44
|
+
- Default limit: 2000 lines`,
|
|
45
|
+
parameters: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
file_path: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'Path to the file to read (absolute or relative to cwd)',
|
|
51
|
+
},
|
|
52
|
+
offset: {
|
|
53
|
+
type: 'number',
|
|
54
|
+
description: 'Line number to start reading from (0-based, default: 0)',
|
|
55
|
+
},
|
|
56
|
+
limit: {
|
|
57
|
+
type: 'number',
|
|
58
|
+
description: `Maximum number of lines to read (default: ${DEFAULT_LIMIT})`,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
required: ['file_path'],
|
|
62
|
+
},
|
|
63
|
+
modes: ['work'],
|
|
64
|
+
isConcurrencySafe: () => true,
|
|
65
|
+
isReadOnly: () => true,
|
|
66
|
+
async execute(input, ctx) {
|
|
67
|
+
const { file_path, offset = 0, limit = DEFAULT_LIMIT } = input;
|
|
68
|
+
if (!file_path) return JSON.stringify({ error: 'file_path is required' });
|
|
69
|
+
|
|
70
|
+
const cwd = ctx?.cwd || process.cwd();
|
|
71
|
+
const absPath = resolve(cwd, file_path);
|
|
72
|
+
|
|
73
|
+
// Check existence
|
|
74
|
+
if (!existsSync(absPath)) {
|
|
75
|
+
return JSON.stringify({ error: `File not found: ${absPath}` });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check binary
|
|
79
|
+
const ext = extname(absPath).toLowerCase();
|
|
80
|
+
if (BINARY_EXTS.has(ext)) {
|
|
81
|
+
return JSON.stringify({
|
|
82
|
+
error: `Cannot read binary file: ${absPath}`,
|
|
83
|
+
hint: 'Use a specialized tool for binary files',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Check file size
|
|
89
|
+
const fileStat = await stat(absPath);
|
|
90
|
+
if (fileStat.isDirectory()) {
|
|
91
|
+
return JSON.stringify({ error: `Path is a directory: ${absPath}. Use ListDir instead.` });
|
|
92
|
+
}
|
|
93
|
+
if (fileStat.size > MAX_FILE_SIZE) {
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
error: `File too large: ${(fileStat.size / 1024 / 1024).toFixed(1)}MB (max: ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
|
|
96
|
+
hint: 'Use offset and limit to read specific sections',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const content = await readFile(absPath, 'utf-8');
|
|
101
|
+
const allLines = content.split('\n');
|
|
102
|
+
const totalLines = allLines.length;
|
|
103
|
+
|
|
104
|
+
// Apply offset and limit
|
|
105
|
+
const startLine = Math.max(0, Math.min(offset, totalLines));
|
|
106
|
+
const endLine = Math.min(startLine + limit, totalLines);
|
|
107
|
+
const lines = allLines.slice(startLine, endLine);
|
|
108
|
+
|
|
109
|
+
// Format with line numbers (1-based like cat -n)
|
|
110
|
+
const numbered = lines.map((line, i) => {
|
|
111
|
+
const lineNum = startLine + i + 1;
|
|
112
|
+
return `${lineNum}\t${line}`;
|
|
113
|
+
}).join('\n');
|
|
114
|
+
|
|
115
|
+
// Add metadata if partial
|
|
116
|
+
if (startLine > 0 || endLine < totalLines) {
|
|
117
|
+
return `${numbered}\n\n[Showing lines ${startLine + 1}-${endLine} of ${totalLines} total]`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return numbered;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return JSON.stringify({ error: `Failed to read file: ${err.message}` });
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-write.js — Write or create files.
|
|
3
|
+
*
|
|
4
|
+
* Creates new files or overwrites existing ones. Creates parent
|
|
5
|
+
* directories as needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineTool } from './types.js';
|
|
9
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
10
|
+
import { resolve, dirname } from 'path';
|
|
11
|
+
|
|
12
|
+
export default defineTool({
|
|
13
|
+
name: 'FileWrite',
|
|
14
|
+
description: `Write content to a file, creating it if it doesn't exist.
|
|
15
|
+
|
|
16
|
+
Creates parent directories automatically. Overwrites existing files.
|
|
17
|
+
|
|
18
|
+
Guidelines:
|
|
19
|
+
- Use absolute paths when possible
|
|
20
|
+
- For modifying existing files, prefer FileEdit (surgical edits) over FileWrite (full overwrite)
|
|
21
|
+
- Parent directories are created automatically
|
|
22
|
+
- Content should be the complete file content`,
|
|
23
|
+
parameters: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
file_path: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Path to the file to write (absolute or relative to cwd)',
|
|
29
|
+
},
|
|
30
|
+
content: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'The complete file content to write',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ['file_path', 'content'],
|
|
36
|
+
},
|
|
37
|
+
modes: ['work'],
|
|
38
|
+
isConcurrencySafe: () => false,
|
|
39
|
+
isReadOnly: () => false,
|
|
40
|
+
isDestructive: () => false,
|
|
41
|
+
async execute(input, ctx) {
|
|
42
|
+
const { file_path, content } = input;
|
|
43
|
+
if (!file_path) return JSON.stringify({ error: 'file_path is required' });
|
|
44
|
+
if (content === undefined || content === null) {
|
|
45
|
+
return JSON.stringify({ error: 'content is required' });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const cwd = ctx?.cwd || process.cwd();
|
|
49
|
+
const absPath = resolve(cwd, file_path);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Ensure parent directory exists
|
|
53
|
+
const dir = dirname(absPath);
|
|
54
|
+
await mkdir(dir, { recursive: true });
|
|
55
|
+
|
|
56
|
+
// Write the file
|
|
57
|
+
await writeFile(absPath, content, 'utf-8');
|
|
58
|
+
|
|
59
|
+
const lines = content.split('\n').length;
|
|
60
|
+
const bytes = Buffer.byteLength(content, 'utf-8');
|
|
61
|
+
|
|
62
|
+
return JSON.stringify({
|
|
63
|
+
success: true,
|
|
64
|
+
path: absPath,
|
|
65
|
+
lines,
|
|
66
|
+
bytes,
|
|
67
|
+
message: `Wrote ${lines} lines (${bytes} bytes) to ${absPath}`,
|
|
68
|
+
});
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return JSON.stringify({ error: `Failed to write file: ${err.message}` });
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* glob.js — Find files by pattern matching.
|
|
3
|
+
*
|
|
4
|
+
* Uses Node.js glob patterns to find files matching a pattern.
|
|
5
|
+
* Results are sorted by modification time (newest first).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineTool } from './types.js';
|
|
9
|
+
import { readdir, stat } from 'fs/promises';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
import { resolve, join, relative } from 'path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Simple glob pattern matcher (supports * and **).
|
|
15
|
+
* @param {string} pattern
|
|
16
|
+
* @param {string} str
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function matchGlob(pattern, str) {
|
|
20
|
+
// Convert glob pattern to regex
|
|
21
|
+
// IMPORTANT: escape dots FIRST before replacing glob chars to avoid
|
|
22
|
+
// corrupting regex tokens like [^/]* and .*
|
|
23
|
+
let regex = pattern
|
|
24
|
+
.replace(/\\/g, '/')
|
|
25
|
+
.replace(/\./g, '\\.') // Escape dots first (before glob replacements)
|
|
26
|
+
.replace(/\*\*/g, '<<<GLOBSTAR>>>')
|
|
27
|
+
.replace(/\*/g, '[^/]*')
|
|
28
|
+
.replace(/<<<GLOBSTAR>>>/g, '.*')
|
|
29
|
+
.replace(/\?/g, '[^/]');
|
|
30
|
+
regex = '^' + regex + '$';
|
|
31
|
+
return new RegExp(regex).test(str.replace(/\\/g, '/'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Recursively walk a directory, yielding relative paths.
|
|
36
|
+
*/
|
|
37
|
+
async function* walkDir(dir, baseDir, maxDepth = 10, depth = 0) {
|
|
38
|
+
if (depth > maxDepth) return;
|
|
39
|
+
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Skip common large/irrelevant directories
|
|
48
|
+
const SKIP = new Set([
|
|
49
|
+
'node_modules', '.git', '__pycache__', '.next', '.nuxt',
|
|
50
|
+
'dist', 'build', '.cache', '.venv', 'venv', '.tox',
|
|
51
|
+
'vendor', 'target', '.gradle', '.idea', '.vscode',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const fullPath = join(dir, entry.name);
|
|
56
|
+
const relPath = relative(baseDir, fullPath);
|
|
57
|
+
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (SKIP.has(entry.name)) continue;
|
|
60
|
+
yield { path: relPath, isDir: true };
|
|
61
|
+
yield* walkDir(fullPath, baseDir, maxDepth, depth + 1);
|
|
62
|
+
} else {
|
|
63
|
+
yield { path: relPath, isDir: false };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default defineTool({
|
|
69
|
+
name: 'Glob',
|
|
70
|
+
description: `Find files matching a glob pattern.
|
|
71
|
+
|
|
72
|
+
Supports glob patterns like "**/*.js", "src/**/*.ts", "*.md".
|
|
73
|
+
Results are sorted by modification time (newest first).
|
|
74
|
+
|
|
75
|
+
Guidelines:
|
|
76
|
+
- Use "**/" for recursive directory matching
|
|
77
|
+
- Common directories (node_modules, .git, etc.) are skipped
|
|
78
|
+
- Returns file paths relative to the search directory
|
|
79
|
+
- Limited to 500 results by default`,
|
|
80
|
+
parameters: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
pattern: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
description: 'Glob pattern to match files (e.g. "**/*.js")',
|
|
86
|
+
},
|
|
87
|
+
path: {
|
|
88
|
+
type: 'string',
|
|
89
|
+
description: 'Directory to search in (default: cwd)',
|
|
90
|
+
},
|
|
91
|
+
limit: {
|
|
92
|
+
type: 'number',
|
|
93
|
+
description: 'Maximum number of results (default: 500)',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
required: ['pattern'],
|
|
97
|
+
},
|
|
98
|
+
modes: ['work'],
|
|
99
|
+
isConcurrencySafe: () => true,
|
|
100
|
+
isReadOnly: () => true,
|
|
101
|
+
async execute(input, ctx) {
|
|
102
|
+
const { pattern, path: searchPath, limit = 500 } = input;
|
|
103
|
+
if (!pattern) return JSON.stringify({ error: 'pattern is required' });
|
|
104
|
+
|
|
105
|
+
const cwd = ctx?.cwd || process.cwd();
|
|
106
|
+
const baseDir = searchPath ? resolve(cwd, searchPath) : cwd;
|
|
107
|
+
|
|
108
|
+
if (!existsSync(baseDir)) {
|
|
109
|
+
return JSON.stringify({ error: `Directory not found: ${baseDir}` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const matches = [];
|
|
114
|
+
|
|
115
|
+
for await (const entry of walkDir(baseDir, baseDir)) {
|
|
116
|
+
if (matches.length >= limit * 2) break; // over-fetch for sorting
|
|
117
|
+
|
|
118
|
+
if (!entry.isDir && matchGlob(pattern, entry.path)) {
|
|
119
|
+
// Get mtime for sorting
|
|
120
|
+
try {
|
|
121
|
+
const fileStat = await stat(join(baseDir, entry.path));
|
|
122
|
+
matches.push({
|
|
123
|
+
path: entry.path,
|
|
124
|
+
mtime: fileStat.mtimeMs,
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
matches.push({ path: entry.path, mtime: 0 });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Sort by mtime (newest first)
|
|
133
|
+
matches.sort((a, b) => b.mtime - a.mtime);
|
|
134
|
+
|
|
135
|
+
// Trim to limit
|
|
136
|
+
const trimmed = matches.slice(0, limit);
|
|
137
|
+
|
|
138
|
+
return trimmed.map(m => m.path).join('\n') || '(no matches)';
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return JSON.stringify({ error: `Glob search failed: ${err.message}` });
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grep.js — Search file contents for patterns.
|
|
3
|
+
*
|
|
4
|
+
* Searches for regex patterns in files. Tries to use ripgrep (rg) if
|
|
5
|
+
* available for performance, falls back to a Node.js implementation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineTool } from './types.js';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import { readdir, readFile, stat } from 'fs/promises';
|
|
11
|
+
import { existsSync } from 'fs';
|
|
12
|
+
import { resolve, join, relative, extname } from 'path';
|
|
13
|
+
|
|
14
|
+
/** Max output lines. */
|
|
15
|
+
const MAX_LINES = 250;
|
|
16
|
+
|
|
17
|
+
/** Binary extensions to skip. */
|
|
18
|
+
const BINARY_EXTS = new Set([
|
|
19
|
+
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp',
|
|
20
|
+
'.mp3', '.mp4', '.avi', '.mov', '.wav',
|
|
21
|
+
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
|
|
22
|
+
'.exe', '.dll', '.so', '.dylib', '.o',
|
|
23
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
|
24
|
+
'.woff', '.woff2', '.ttf', '.otf',
|
|
25
|
+
'.sqlite', '.db',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if ripgrep is available.
|
|
30
|
+
*/
|
|
31
|
+
function hasRipgrep() {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const proc = spawn('rg', ['--version'], { stdio: 'pipe' });
|
|
34
|
+
proc.on('close', (code) => resolve(code === 0));
|
|
35
|
+
proc.on('error', () => resolve(false));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run ripgrep and return results.
|
|
41
|
+
*/
|
|
42
|
+
function runRipgrep(pattern, searchPath, options) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const args = [
|
|
45
|
+
pattern,
|
|
46
|
+
searchPath,
|
|
47
|
+
'--no-heading',
|
|
48
|
+
'--line-number',
|
|
49
|
+
'--color', 'never',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
if (options.caseInsensitive) args.push('-i');
|
|
53
|
+
if (options.glob) args.push('--glob', options.glob);
|
|
54
|
+
if (options.type) args.push('--type', options.type);
|
|
55
|
+
if (options.filesOnly) args.push('-l');
|
|
56
|
+
if (options.count) args.push('-c');
|
|
57
|
+
if (options.context) args.push('-C', String(options.context));
|
|
58
|
+
if (options.before) args.push('-B', String(options.before));
|
|
59
|
+
if (options.after) args.push('-A', String(options.after));
|
|
60
|
+
if (options.multiline) args.push('-U', '--multiline-dotall');
|
|
61
|
+
args.push('--max-count', String(options.maxResults || 500));
|
|
62
|
+
|
|
63
|
+
const proc = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
64
|
+
let stdout = '';
|
|
65
|
+
let stderr = '';
|
|
66
|
+
|
|
67
|
+
proc.stdout.on('data', (chunk) => {
|
|
68
|
+
stdout += chunk.toString();
|
|
69
|
+
// Truncate early if way too large
|
|
70
|
+
if (stdout.length > 512 * 1024) {
|
|
71
|
+
try { proc.kill(); } catch {}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
75
|
+
proc.on('close', (code) => {
|
|
76
|
+
if (code === 0 || code === 1) {
|
|
77
|
+
resolve(stdout);
|
|
78
|
+
} else {
|
|
79
|
+
reject(new Error(stderr || `rg exited with code ${code}`));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
proc.on('error', reject);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fallback: Node.js grep implementation.
|
|
88
|
+
*/
|
|
89
|
+
async function nodeGrep(pattern, searchPath, options) {
|
|
90
|
+
const regex = new RegExp(pattern, options.caseInsensitive ? 'gi' : 'g');
|
|
91
|
+
const results = [];
|
|
92
|
+
const SKIP = new Set(['node_modules', '.git', '__pycache__', '.next', 'dist', 'build', '.cache']);
|
|
93
|
+
|
|
94
|
+
async function searchDir(dir) {
|
|
95
|
+
if (results.length >= (options.maxResults || 500)) return;
|
|
96
|
+
let entries;
|
|
97
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
98
|
+
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (results.length >= (options.maxResults || 500)) return;
|
|
101
|
+
const fullPath = join(dir, entry.name);
|
|
102
|
+
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
if (SKIP.has(entry.name)) continue;
|
|
105
|
+
await searchDir(fullPath);
|
|
106
|
+
} else {
|
|
107
|
+
const ext = extname(entry.name).toLowerCase();
|
|
108
|
+
if (BINARY_EXTS.has(ext)) continue;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const fileStat = await stat(fullPath);
|
|
112
|
+
if (fileStat.size > 1024 * 1024) continue; // skip files > 1MB
|
|
113
|
+
|
|
114
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
115
|
+
const relPath = relative(searchPath, fullPath);
|
|
116
|
+
|
|
117
|
+
if (options.filesOnly) {
|
|
118
|
+
if (regex.test(content)) results.push(relPath);
|
|
119
|
+
regex.lastIndex = 0;
|
|
120
|
+
} else if (options.count) {
|
|
121
|
+
const matches = content.match(regex);
|
|
122
|
+
if (matches) results.push(`${relPath}:${matches.length}`);
|
|
123
|
+
} else {
|
|
124
|
+
const lines = content.split('\n');
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
if (regex.test(lines[i])) {
|
|
127
|
+
results.push(`${relPath}:${i + 1}:${lines[i]}`);
|
|
128
|
+
}
|
|
129
|
+
regex.lastIndex = 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Skip unreadable files
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await searchDir(searchPath);
|
|
140
|
+
return results.join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export default defineTool({
|
|
144
|
+
name: 'Grep',
|
|
145
|
+
description: `Search file contents for a regex pattern.
|
|
146
|
+
|
|
147
|
+
Uses ripgrep (rg) when available for fast searching, with a Node.js fallback.
|
|
148
|
+
|
|
149
|
+
Output modes:
|
|
150
|
+
- "content" — show matching lines with file path and line numbers
|
|
151
|
+
- "files_with_matches" — show only file paths that match (default)
|
|
152
|
+
- "count" — show match count per file
|
|
153
|
+
|
|
154
|
+
Guidelines:
|
|
155
|
+
- Uses regex syntax (escape special chars: \\., \\{, etc.)
|
|
156
|
+
- Use glob or type filters to narrow the search
|
|
157
|
+
- Skips binary files and common large directories (node_modules, .git)
|
|
158
|
+
- Results are limited to 500 matches by default`,
|
|
159
|
+
parameters: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
properties: {
|
|
162
|
+
pattern: {
|
|
163
|
+
type: 'string',
|
|
164
|
+
description: 'Regex pattern to search for',
|
|
165
|
+
},
|
|
166
|
+
path: {
|
|
167
|
+
type: 'string',
|
|
168
|
+
description: 'File or directory to search (default: cwd)',
|
|
169
|
+
},
|
|
170
|
+
output_mode: {
|
|
171
|
+
type: 'string',
|
|
172
|
+
enum: ['content', 'files_with_matches', 'count'],
|
|
173
|
+
description: 'Output format (default: "files_with_matches")',
|
|
174
|
+
},
|
|
175
|
+
glob: {
|
|
176
|
+
type: 'string',
|
|
177
|
+
description: 'Glob filter for file names (e.g. "*.js", "*.{ts,tsx}")',
|
|
178
|
+
},
|
|
179
|
+
type: {
|
|
180
|
+
type: 'string',
|
|
181
|
+
description: 'File type filter (e.g. "js", "py", "rust")',
|
|
182
|
+
},
|
|
183
|
+
case_insensitive: {
|
|
184
|
+
type: 'boolean',
|
|
185
|
+
description: 'Case-insensitive search (default: false)',
|
|
186
|
+
},
|
|
187
|
+
context: {
|
|
188
|
+
type: 'number',
|
|
189
|
+
description: 'Lines of context around matches (for "content" mode)',
|
|
190
|
+
},
|
|
191
|
+
before: {
|
|
192
|
+
type: 'number',
|
|
193
|
+
description: 'Lines before each match',
|
|
194
|
+
},
|
|
195
|
+
after: {
|
|
196
|
+
type: 'number',
|
|
197
|
+
description: 'Lines after each match',
|
|
198
|
+
},
|
|
199
|
+
multiline: {
|
|
200
|
+
type: 'boolean',
|
|
201
|
+
description: 'Enable multiline matching',
|
|
202
|
+
},
|
|
203
|
+
head_limit: {
|
|
204
|
+
type: 'number',
|
|
205
|
+
description: 'Limit output to first N results (default: 250)',
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
required: ['pattern'],
|
|
209
|
+
},
|
|
210
|
+
modes: ['work'],
|
|
211
|
+
isConcurrencySafe: () => true,
|
|
212
|
+
isReadOnly: () => true,
|
|
213
|
+
async execute(input, ctx) {
|
|
214
|
+
const {
|
|
215
|
+
pattern, path: searchPath, output_mode = 'files_with_matches',
|
|
216
|
+
glob: globFilter, type, case_insensitive = false,
|
|
217
|
+
context, before, after, multiline = false,
|
|
218
|
+
head_limit = MAX_LINES,
|
|
219
|
+
} = input;
|
|
220
|
+
|
|
221
|
+
if (!pattern) return JSON.stringify({ error: 'pattern is required' });
|
|
222
|
+
|
|
223
|
+
const cwd = ctx?.cwd || process.cwd();
|
|
224
|
+
const absPath = searchPath ? resolve(cwd, searchPath) : cwd;
|
|
225
|
+
|
|
226
|
+
if (!existsSync(absPath)) {
|
|
227
|
+
return JSON.stringify({ error: `Path not found: ${absPath}` });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const options = {
|
|
231
|
+
caseInsensitive: case_insensitive,
|
|
232
|
+
glob: globFilter,
|
|
233
|
+
type,
|
|
234
|
+
filesOnly: output_mode === 'files_with_matches',
|
|
235
|
+
count: output_mode === 'count',
|
|
236
|
+
context,
|
|
237
|
+
before,
|
|
238
|
+
after,
|
|
239
|
+
multiline,
|
|
240
|
+
maxResults: head_limit * 2,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
let result;
|
|
245
|
+
const rgAvailable = await hasRipgrep();
|
|
246
|
+
|
|
247
|
+
if (rgAvailable) {
|
|
248
|
+
result = await runRipgrep(pattern, absPath, options);
|
|
249
|
+
} else {
|
|
250
|
+
result = await nodeGrep(pattern, absPath, options);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!result || !result.trim()) {
|
|
254
|
+
return '(no matches)';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Limit output lines
|
|
258
|
+
const lines = result.trim().split('\n');
|
|
259
|
+
if (lines.length > head_limit) {
|
|
260
|
+
return lines.slice(0, head_limit).join('\n') + `\n\n... (${lines.length - head_limit} more results)`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return result.trim();
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return JSON.stringify({ error: `Grep failed: ${err.message}` });
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* history-search.js — Search conversation history.
|
|
3
|
+
*
|
|
4
|
+
* Searches through persisted conversation messages for keywords.
|
|
5
|
+
* Uses the ConversationStore's search functionality.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineTool } from './types.js';
|
|
9
|
+
import { searchMessages } from '../conversation/search.js';
|
|
10
|
+
|
|
11
|
+
export default defineTool({
|
|
12
|
+
name: 'HistorySearch',
|
|
13
|
+
description: `Search through past conversation history.
|
|
14
|
+
|
|
15
|
+
Searches for keywords in previously persisted conversation messages.
|
|
16
|
+
Useful for finding previous discussions, decisions, or code snippets.
|
|
17
|
+
|
|
18
|
+
Results are returned newest-first with message role and content.`,
|
|
19
|
+
parameters: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
keyword: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Search keyword (case-insensitive)',
|
|
25
|
+
},
|
|
26
|
+
limit: {
|
|
27
|
+
type: 'number',
|
|
28
|
+
description: 'Maximum number of results (default: 20)',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
required: ['keyword'],
|
|
32
|
+
},
|
|
33
|
+
modes: ['chat', 'work'],
|
|
34
|
+
isConcurrencySafe: () => true,
|
|
35
|
+
isReadOnly: () => true,
|
|
36
|
+
async execute(input, ctx) {
|
|
37
|
+
const { keyword, limit = 20 } = input;
|
|
38
|
+
if (!keyword) return JSON.stringify({ error: 'keyword is required' });
|
|
39
|
+
|
|
40
|
+
const yeaftDir = ctx?.yeaftDir;
|
|
41
|
+
if (!yeaftDir) {
|
|
42
|
+
return JSON.stringify({ error: 'Yeaft directory not configured — no conversation history available' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const results = searchMessages(yeaftDir, keyword, limit);
|
|
47
|
+
|
|
48
|
+
if (results.length === 0) {
|
|
49
|
+
return JSON.stringify({
|
|
50
|
+
results: [],
|
|
51
|
+
message: `No matches found for "${keyword}"`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return JSON.stringify({
|
|
56
|
+
results: results.map(msg => ({
|
|
57
|
+
role: msg.role,
|
|
58
|
+
content: msg.content?.slice(0, 2000) + (msg.content?.length > 2000 ? '...' : ''),
|
|
59
|
+
mode: msg.mode,
|
|
60
|
+
timestamp: msg.timestamp,
|
|
61
|
+
})),
|
|
62
|
+
totalResults: results.length,
|
|
63
|
+
keyword,
|
|
64
|
+
}, null, 2);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return JSON.stringify({ error: `History search failed: ${err.message}` });
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|