@yeaft/webchat-agent 1.0.299 → 1.0.301

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,243 @@
1
+ import { lstat, readdir, stat } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, join, relative, resolve } from 'node:path';
4
+ import { defineTool } from './types.js';
5
+ import { managedCliToolReady, resolveManagedCliCommand } from '../managed-cli.js';
6
+ import { runProcess } from './process-runner.js';
7
+ import { isAbortError, throwIfAborted, waitForAbortable } from './search-paths.js';
8
+
9
+ const FALLBACK_CONCURRENCY = 16;
10
+ const MAX_LIMIT = 200;
11
+ const MAX_DEPTH = 10;
12
+ const MAX_OUTPUT_BYTES = 512 * 1024;
13
+
14
+ function formatSize(bytes) {
15
+ if (bytes < 1024) return `${bytes}B`;
16
+ if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)}KB`;
17
+ if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)}MB`;
18
+ return `${(bytes / 1024 ** 3).toFixed(1)}GB`;
19
+ }
20
+
21
+ function parseDustBytes(value) {
22
+ const match = String(value || '').match(/^(\d+)B$/);
23
+ return match ? Number(match[1]) : 0;
24
+ }
25
+
26
+ function flattenDustTree(root, baseDir, depth, limit) {
27
+ const rows = [];
28
+ function visit(node, level) {
29
+ if (!node || level > depth) return;
30
+ rows.push({
31
+ path: level === 0 ? '.' : relative(baseDir, node.name) || basename(node.name),
32
+ size: parseDustBytes(node.size),
33
+ level,
34
+ });
35
+ for (const child of node.children || []) visit(child, level + 1);
36
+ }
37
+ visit(root, 0);
38
+ const total = rows.shift();
39
+ rows.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
40
+ return [total, ...rows.slice(0, Math.max(0, limit - 1))].filter(Boolean);
41
+ }
42
+
43
+ async function runDust(command, baseDir, { depth, limit, signal }) {
44
+ const result = await runProcess(command, [
45
+ '--output-json',
46
+ '--depth', String(depth),
47
+ '--apparent-size',
48
+ '--only-dir',
49
+ '--no-progress',
50
+ '--output-format', 'b',
51
+ baseDir,
52
+ ], {
53
+ cwd: baseDir,
54
+ signal,
55
+ timeoutMs: 120_000,
56
+ maxBytes: MAX_OUTPUT_BYTES,
57
+ env: { ...process.env, NO_COLOR: '1' },
58
+ });
59
+ if (result.timedOut) throw new Error('dust timed out');
60
+ if (result.truncated) throw new Error('dust output exceeded the tool limit');
61
+ if (result.code !== 0) throw new Error(result.stderr.trim() || `dust exited with code ${result.code}`);
62
+ return flattenDustTree(JSON.parse(result.stdout), baseDir, depth, limit);
63
+ }
64
+
65
+ async function validateDiskUsageRoot(path, signal, fsOps = { lstat, stat }) {
66
+ throwIfAborted(signal);
67
+ const rootStat = await fsOps.lstat(path);
68
+ throwIfAborted(signal);
69
+ if (rootStat.isDirectory()) return rootStat;
70
+ if (rootStat.isSymbolicLink()) {
71
+ const targetStat = await fsOps.stat(path);
72
+ throwIfAborted(signal);
73
+ if (targetStat.isDirectory()) return rootStat;
74
+ }
75
+ throw new Error('path must be a directory or a directory symlink');
76
+ }
77
+
78
+ export async function nodeDiskUsage(
79
+ baseDir,
80
+ depth,
81
+ limit,
82
+ signal,
83
+ fsOps = { lstat, readdir, stat },
84
+ ) {
85
+ const rootStat = await validateDiskUsageRoot(baseDir, signal, fsOps);
86
+ const root = { path: baseDir, level: 0, size: rootStat.size, parent: null };
87
+ const directories = [root];
88
+ const linkedDirectories = [];
89
+
90
+ async function scanDirectory(directory) {
91
+ throwIfAborted(signal);
92
+ let entries;
93
+ try { entries = await fsOps.readdir(directory.path, { withFileTypes: true }); } catch (error) {
94
+ if (isAbortError(error)) throw error;
95
+ return;
96
+ }
97
+ throwIfAborted(signal);
98
+ entries.sort((left, right) => left.name.localeCompare(right.name));
99
+ for (const entry of entries) {
100
+ throwIfAborted(signal);
101
+ const childPath = join(directory.path, entry.name);
102
+ let childStat;
103
+ try { childStat = await fsOps.lstat(childPath); } catch (error) {
104
+ if (isAbortError(error)) throw error;
105
+ continue;
106
+ }
107
+ throwIfAborted(signal);
108
+ if (childStat.isSymbolicLink()) {
109
+ directory.size += childStat.size;
110
+ let targetIsDirectory = false;
111
+ try { targetIsDirectory = (await fsOps.stat(childPath)).isDirectory(); } catch (error) {
112
+ if (isAbortError(error)) throw error;
113
+ }
114
+ throwIfAborted(signal);
115
+ if (targetIsDirectory && directory.level + 1 <= depth) {
116
+ linkedDirectories.push({
117
+ path: relative(baseDir, childPath),
118
+ size: childStat.size,
119
+ level: directory.level + 1,
120
+ });
121
+ }
122
+ } else if (childStat.isDirectory()) {
123
+ directories.push({
124
+ path: childPath,
125
+ level: directory.level + 1,
126
+ size: childStat.size,
127
+ parent: directory,
128
+ });
129
+ } else {
130
+ directory.size += childStat.size;
131
+ }
132
+ }
133
+ }
134
+
135
+ let cursor = 0;
136
+ while (cursor < directories.length) {
137
+ throwIfAborted(signal);
138
+ const batch = directories.slice(cursor, cursor + FALLBACK_CONCURRENCY);
139
+ cursor += batch.length;
140
+ const settled = await Promise.allSettled(batch.map(scanDirectory));
141
+ const rejected = settled.find(result => result.status === 'rejected');
142
+ if (rejected) throw rejected.reason;
143
+ }
144
+
145
+ for (let index = directories.length - 1; index > 0; index -= 1) {
146
+ directories[index].parent.size += directories[index].size;
147
+ }
148
+ const rows = [
149
+ ...directories
150
+ .filter(directory => directory.level <= depth)
151
+ .map(directory => ({
152
+ path: directory.level === 0 ? '.' : relative(baseDir, directory.path),
153
+ size: directory.size,
154
+ level: directory.level,
155
+ })),
156
+ ...linkedDirectories,
157
+ ];
158
+ const total = rows.find(row => row.level === 0);
159
+ const children = rows
160
+ .filter(row => row.level > 0)
161
+ .sort((a, b) => b.size - a.size || a.path.localeCompare(b.path))
162
+ .slice(0, Math.max(0, limit - 1));
163
+ return [total, ...children].filter(Boolean);
164
+ }
165
+
166
+ function formatRows(baseDir, rows) {
167
+ const lines = rows.map(row => `${formatSize(row.size).padStart(9)} ${row.path}`);
168
+ return `${baseDir}\n\n${lines.join('\n')}`;
169
+ }
170
+
171
+ export default defineTool({
172
+ name: 'DiskUsage',
173
+ description: {
174
+ en: `Show the largest directories under a path by apparent size.
175
+
176
+ Uses dust when available for parallel disk-usage scanning, with a Node.js fallback.
177
+ The root total is shown first, followed by the largest descendants. A root directory
178
+ symlink is scanned like dust; descendant symlinks are not followed.`,
179
+ zh: `按表观大小显示路径下占用最大的目录。
180
+
181
+ 优先使用 dust 并行扫描磁盘用量,回退到 Node.js 实现。根路径总量排在首行,之后按大小列出后代目录;
182
+ 根目录符号链接与 dust 一样扫描目标内容,后代符号链接不跟随。`,
183
+ },
184
+ parameters: {
185
+ type: 'object',
186
+ properties: {
187
+ path: {
188
+ type: 'string',
189
+ description: { en: 'Directory to inspect (default: cwd)', zh: '要检查的目录(默认当前工作目录)' },
190
+ },
191
+ depth: {
192
+ type: 'integer',
193
+ minimum: 0,
194
+ maximum: MAX_DEPTH,
195
+ description: { en: 'Maximum directory depth to show (default: 2)', zh: '要显示的最大目录深度(默认 2)' },
196
+ },
197
+ limit: {
198
+ type: 'integer',
199
+ minimum: 1,
200
+ maximum: MAX_LIMIT,
201
+ description: { en: 'Maximum rows to return (default: 20)', zh: '最多返回行数(默认 20)' },
202
+ },
203
+ },
204
+ },
205
+ isConcurrencySafe: () => true,
206
+ isReadOnly: () => true,
207
+ async execute(input, ctx) {
208
+ const { path, depth = 2, limit = 20 } = input;
209
+ if (!Number.isInteger(depth) || depth < 0 || depth > MAX_DEPTH) {
210
+ return JSON.stringify({ error: `depth must be an integer between 0 and ${MAX_DEPTH}` });
211
+ }
212
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
213
+ return JSON.stringify({ error: `limit must be an integer between 1 and ${MAX_LIMIT}` });
214
+ }
215
+ const cwd = ctx?.cwd || process.cwd();
216
+ const baseDir = path ? resolve(cwd, path) : cwd;
217
+ if (!existsSync(baseDir)) return JSON.stringify({ error: `Directory not found: ${baseDir}` });
218
+
219
+ try {
220
+ throwIfAborted(ctx?.signal);
221
+ await validateDiskUsageRoot(baseDir, ctx?.signal);
222
+ let rows;
223
+ let dustCommand = resolveManagedCliCommand('dust', { yeaftDir: ctx?.yeaftDir });
224
+ if (!dustCommand) {
225
+ await waitForAbortable(managedCliToolReady(ctx?.managedCliReady, 'dust'), ctx?.signal);
226
+ dustCommand = resolveManagedCliCommand('dust', { yeaftDir: ctx?.yeaftDir });
227
+ }
228
+ if (dustCommand) {
229
+ try {
230
+ rows = await runDust(dustCommand, baseDir, { depth, limit, signal: ctx?.signal });
231
+ } catch (error) {
232
+ if (isAbortError(error)) throw error;
233
+ rows = null;
234
+ }
235
+ }
236
+ if (!rows) rows = await nodeDiskUsage(baseDir, depth, limit, ctx?.signal);
237
+ return formatRows(baseDir, rows);
238
+ } catch (error) {
239
+ if (isAbortError(error)) throw error;
240
+ return JSON.stringify({ error: `Disk usage scan failed: ${error.message}` });
241
+ }
242
+ },
243
+ });
@@ -1,21 +1,30 @@
1
1
  /**
2
2
  * glob.js — Find files by pattern matching.
3
3
  *
4
- * Uses Node.js glob patterns to find files matching a pattern.
5
- * Results are sorted by modification time (newest first).
4
+ * Uses fd for traversal when available and the same local glob matcher in both
5
+ * fast and fallback paths. Results are sorted by modification time (newest first).
6
6
  */
7
7
 
8
8
  import { defineTool } from './types.js';
9
9
  import { readdir, stat } from 'fs/promises';
10
10
  import { existsSync } from 'fs';
11
11
  import { resolve, join, relative } from 'path';
12
+ import { managedCliToolReady, resolveManagedCliCommand } from '../managed-cli.js';
13
+ import { runProcess } from './process-runner.js';
14
+ import {
15
+ createSearchPathMatcher,
16
+ isAbortError,
17
+ isSkippedSearchDirectory,
18
+ SEARCH_SKIP_DIRS,
19
+ throwIfAborted,
20
+ waitForAbortable,
21
+ } from './search-paths.js';
12
22
 
13
23
  const STAT_CONCURRENCY = 32;
14
- const SKIP_DIRS = new Set([
15
- 'node_modules', '.git', '__pycache__', '.next', '.nuxt',
16
- 'dist', 'build', '.cache', '.venv', 'venv', '.tox',
17
- 'vendor', 'target', '.gradle', '.idea', '.vscode',
18
- ]);
24
+
25
+ function comparePaths(left, right) {
26
+ return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
27
+ }
19
28
 
20
29
  /**
21
30
  * Simple glob pattern matcher (supports * and **).
@@ -24,48 +33,72 @@ const SKIP_DIRS = new Set([
24
33
  * @returns {boolean}
25
34
  */
26
35
  function matchGlob(pattern, str) {
27
- // Convert glob pattern to regex
28
- // IMPORTANT: escape dots FIRST before replacing glob chars to avoid
29
- // corrupting regex tokens like [^/]* and .*
30
- let regex = pattern
31
- .replace(/\\/g, '/')
32
- .replace(/\./g, '\\.') // Escape dots first (before glob replacements)
33
- .replace(/\*\*/g, '<<<GLOBSTAR>>>')
34
- .replace(/\*/g, '[^/]*')
35
- .replace(/<<<GLOBSTAR>>>/g, '.*')
36
- .replace(/\?/g, '[^/]');
37
- regex = '^' + regex + '$';
38
- return new RegExp(regex).test(str.replace(/\\/g, '/'));
36
+ return createSearchPathMatcher({ glob: pattern })(str);
39
37
  }
40
38
 
41
39
  /**
42
40
  * Recursively walk a directory, yielding relative paths.
43
41
  */
44
- async function* walkDir(dir, baseDir, maxDepth = 10, depth = 0) {
42
+ async function* walkDir(dir, baseDir, signal, maxDepth = 10, depth = 0) {
43
+ throwIfAborted(signal);
45
44
  if (depth > maxDepth) return;
46
45
 
47
46
  let entries;
48
47
  try {
49
48
  entries = await readdir(dir, { withFileTypes: true });
50
- } catch {
49
+ } catch (error) {
50
+ if (isAbortError(error)) throw error;
51
51
  return;
52
52
  }
53
+ throwIfAborted(signal);
53
54
 
54
55
  for (const entry of entries) {
56
+ throwIfAborted(signal);
55
57
  const fullPath = join(dir, entry.name);
56
58
  const relPath = relative(baseDir, fullPath);
57
59
 
58
60
  if (entry.isDirectory()) {
59
61
  const normalized = relPath.replace(/\\/g, '/');
60
- if (SKIP_DIRS.has(entry.name) || normalized === '.yeaft/worktrees' || normalized.startsWith('.yeaft/worktrees/')) continue;
62
+ if (isSkippedSearchDirectory(normalized, entry.name)) continue;
61
63
  yield { path: relPath, isDir: true };
62
- yield* walkDir(fullPath, baseDir, maxDepth, depth + 1);
64
+ yield* walkDir(fullPath, baseDir, signal, maxDepth, depth + 1);
63
65
  } else {
64
66
  yield { path: relPath, isDir: false };
65
67
  }
66
68
  }
67
69
  }
68
70
 
71
+ async function listFilesWithFd(fdCommand, baseDir, signal) {
72
+ const args = [
73
+ '.', baseDir,
74
+ '--type', 'file',
75
+ '--type', 'symlink',
76
+ '--hidden',
77
+ '--no-ignore',
78
+ '--color', 'never',
79
+ '--max-depth', '11',
80
+ '--print0',
81
+ ];
82
+ for (const skipped of SEARCH_SKIP_DIRS) args.push('--exclude', skipped);
83
+ args.push('--exclude', '.yeaft/worktrees', '--exclude', '**/.yeaft/worktrees/**');
84
+ const result = await runProcess(fdCommand, args, {
85
+ cwd: baseDir,
86
+ signal,
87
+ timeoutMs: 120_000,
88
+ maxBytes: 16 * 1024 * 1024,
89
+ preserveCarriageReturns: true,
90
+ });
91
+ if (result.timedOut) throw new Error('fd timed out');
92
+ if (result.truncated) throw new Error('fd output exceeded the tool limit');
93
+ if (result.code !== 0) {
94
+ throw new Error(result.stderr.trim() || `fd exited with code ${result.code}`);
95
+ }
96
+ return result.stdout
97
+ .split('\0')
98
+ .filter(Boolean)
99
+ .map(path => relative(baseDir, resolve(baseDir, path)));
100
+ }
101
+
69
102
  export default defineTool({
70
103
  name: 'Glob',
71
104
  description: {
@@ -134,29 +167,52 @@ Guidelines:
134
167
  }
135
168
 
136
169
  try {
137
- const paths = [];
138
- for await (const entry of walkDir(baseDir, baseDir)) {
139
- if (!entry.isDir && matchGlob(pattern, entry.path)) paths.push(entry.path);
170
+ throwIfAborted(ctx?.signal);
171
+ let paths;
172
+ let fdCommand = resolveManagedCliCommand('fd', { yeaftDir: ctx?.yeaftDir });
173
+ if (!fdCommand) {
174
+ await waitForAbortable(managedCliToolReady(ctx?.managedCliReady, 'fd'), ctx?.signal);
175
+ fdCommand = resolveManagedCliCommand('fd', { yeaftDir: ctx?.yeaftDir });
176
+ }
177
+ if (fdCommand) {
178
+ try {
179
+ paths = (await listFilesWithFd(fdCommand, baseDir, ctx?.signal))
180
+ .filter(path => matchGlob(pattern, path));
181
+ } catch (error) {
182
+ if (isAbortError(error)) throw error;
183
+ paths = null;
184
+ }
185
+ }
186
+ if (!paths) {
187
+ paths = [];
188
+ for await (const entry of walkDir(baseDir, baseDir, ctx?.signal)) {
189
+ if (!entry.isDir && matchGlob(pattern, entry.path)) paths.push(entry.path);
190
+ }
140
191
  }
141
192
 
142
193
  // Exact newest-first semantics require every matching mtime. Batch the
143
194
  // metadata reads instead of serializing one syscall per path.
144
195
  const matches = [];
145
196
  for (let i = 0; i < paths.length; i += STAT_CONCURRENCY) {
197
+ throwIfAborted(ctx?.signal);
146
198
  matches.push(...await Promise.all(paths.slice(i, i + STAT_CONCURRENCY).map(async (path) => {
199
+ throwIfAborted(ctx?.signal);
147
200
  try {
148
201
  const fileStat = await stat(join(baseDir, path));
202
+ throwIfAborted(ctx?.signal);
149
203
  return { path, mtime: fileStat.mtimeMs };
150
- } catch {
204
+ } catch (error) {
205
+ if (isAbortError(error)) throw error;
151
206
  return { path, mtime: 0 };
152
207
  }
153
208
  })));
154
209
  }
155
- matches.sort((a, b) => b.mtime - a.mtime);
210
+ matches.sort((a, b) => b.mtime - a.mtime || comparePaths(a.path, b.path));
156
211
  const trimmed = matches.slice(0, limit);
157
212
 
158
213
  return trimmed.map(m => m.path).join('\n') || '(no matches)';
159
214
  } catch (err) {
215
+ if (isAbortError(err)) throw err;
160
216
  return JSON.stringify({ error: `Glob search failed: ${err.message}` });
161
217
  }
162
218
  },