paean 0.9.9 → 0.10.0

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,920 @@
1
+ /**
2
+ * Local Coding MCP Tools
3
+ *
4
+ * High-efficiency tools for file editing, code search, pattern matching,
5
+ * batch reading, web fetching, and persistent memory — executed locally
6
+ * with zero cloud round-trips.
7
+ *
8
+ * @module mcp/coding-tools
9
+ */
10
+ import { readFile, writeFile, readdir, stat, mkdir, appendFile, access } from 'fs/promises';
11
+ import { resolve, relative, join, basename } from 'path';
12
+ import { execFile } from 'child_process';
13
+ import { promisify } from 'util';
14
+ const execFileAsync = promisify(execFile);
15
+ // ============================================
16
+ // Tool Definitions
17
+ // ============================================
18
+ export function getCodingToolDefinitions() {
19
+ return [
20
+ // ── paean_edit_file ──────────────────────────────────────
21
+ {
22
+ name: 'paean_edit_file',
23
+ description: 'Edit a file by searching for an exact string and replacing it. ' +
24
+ 'Returns a unified diff showing what changed. ' +
25
+ 'The old_string must match EXACTLY (including whitespace and indentation). ' +
26
+ 'For creating new files, use paean_write_file instead. ' +
27
+ 'For inserting at a specific location, set old_string to the line AFTER which you want to insert, ' +
28
+ 'and new_string to old_string + the new content.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ filePath: {
33
+ type: 'string',
34
+ description: 'Absolute or relative path to the file to edit',
35
+ },
36
+ oldString: {
37
+ type: 'string',
38
+ description: 'The exact string to find in the file (must match exactly including whitespace)',
39
+ },
40
+ newString: {
41
+ type: 'string',
42
+ description: 'The replacement string',
43
+ },
44
+ allowMultiple: {
45
+ type: 'boolean',
46
+ description: 'If true, replace ALL occurrences. Default: false (replace first only, fail if ambiguous)',
47
+ },
48
+ },
49
+ required: ['filePath', 'oldString', 'newString'],
50
+ },
51
+ },
52
+ // ── paean_grep ───────────────────────────────────────────
53
+ {
54
+ name: 'paean_grep',
55
+ description: 'Search file contents using regex patterns. Uses ripgrep (rg) if available for speed, ' +
56
+ 'falls back to built-in search. Returns matching lines with file paths and line numbers. ' +
57
+ 'Use this instead of paean_execute_shell with grep for structured, reliable results.',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ pattern: {
62
+ type: 'string',
63
+ description: 'Regex pattern to search for (e.g., "function\\s+\\w+", "TODO")',
64
+ },
65
+ dirPath: {
66
+ type: 'string',
67
+ description: 'Directory to search in (default: current working directory)',
68
+ },
69
+ includePattern: {
70
+ type: 'string',
71
+ description: 'Glob pattern to include files (e.g., "*.ts", "*.{js,jsx}")',
72
+ },
73
+ excludePattern: {
74
+ type: 'string',
75
+ description: 'Glob pattern to exclude files (e.g., "*.test.ts")',
76
+ },
77
+ caseSensitive: {
78
+ type: 'boolean',
79
+ description: 'Case-sensitive search (default: true)',
80
+ },
81
+ maxMatches: {
82
+ type: 'number',
83
+ description: 'Maximum number of matches to return (default: 100)',
84
+ },
85
+ contextLines: {
86
+ type: 'number',
87
+ description: 'Number of context lines before/after each match (default: 0)',
88
+ },
89
+ },
90
+ required: ['pattern'],
91
+ },
92
+ },
93
+ // ── paean_glob ───────────────────────────────────────────
94
+ {
95
+ name: 'paean_glob',
96
+ description: 'Find files matching a glob pattern. Returns paths sorted by modification time (newest first). ' +
97
+ 'Respects .gitignore by default. Use this to discover files before reading them.',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ pattern: {
102
+ type: 'string',
103
+ description: 'Glob pattern (e.g., "**/*.ts", "src/**/*.{js,jsx}", "*.md")',
104
+ },
105
+ dirPath: {
106
+ type: 'string',
107
+ description: 'Base directory for the search (default: current working directory)',
108
+ },
109
+ respectGitignore: {
110
+ type: 'boolean',
111
+ description: 'Whether to respect .gitignore rules (default: true)',
112
+ },
113
+ maxResults: {
114
+ type: 'number',
115
+ description: 'Maximum number of results (default: 200)',
116
+ },
117
+ },
118
+ required: ['pattern'],
119
+ },
120
+ },
121
+ // ── paean_read_many_files ────────────────────────────────
122
+ {
123
+ name: 'paean_read_many_files',
124
+ description: 'Read multiple files in a single call. Much more efficient than calling paean_read_file repeatedly. ' +
125
+ 'Returns contents of each file with line numbers. Skips files that don\'t exist or are too large.',
126
+ inputSchema: {
127
+ type: 'object',
128
+ properties: {
129
+ paths: {
130
+ type: 'array',
131
+ items: { type: 'string' },
132
+ description: 'Array of file paths to read',
133
+ },
134
+ maxTotalLines: {
135
+ type: 'number',
136
+ description: 'Maximum total lines across all files (default: 5000). Files are read in order; stops when limit is reached.',
137
+ },
138
+ maxFileSize: {
139
+ type: 'number',
140
+ description: 'Skip files larger than this many bytes (default: 512000 = 500KB)',
141
+ },
142
+ },
143
+ required: ['paths'],
144
+ },
145
+ },
146
+ // ── paean_web_fetch ──────────────────────────────────────
147
+ {
148
+ name: 'paean_web_fetch',
149
+ description: 'Fetch content from a URL and return it as readable text. ' +
150
+ 'HTML is stripped to extract the main text content. ' +
151
+ 'Useful for reading documentation, API responses, or web pages.',
152
+ inputSchema: {
153
+ type: 'object',
154
+ properties: {
155
+ url: {
156
+ type: 'string',
157
+ description: 'The URL to fetch (must be HTTP or HTTPS)',
158
+ },
159
+ maxLength: {
160
+ type: 'number',
161
+ description: 'Maximum content length in characters (default: 50000)',
162
+ },
163
+ headers: {
164
+ type: 'object',
165
+ description: 'Optional custom headers to include in the request',
166
+ },
167
+ },
168
+ required: ['url'],
169
+ },
170
+ },
171
+ // ── paean_memory ─────────────────────────────────────────
172
+ {
173
+ name: 'paean_memory',
174
+ description: 'Save a fact or context to persistent memory (.paean/PAEAN.md in the project root). ' +
175
+ 'Memories persist across sessions and are automatically loaded as context. ' +
176
+ 'Use this to remember user preferences, project conventions, or important decisions.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ fact: {
181
+ type: 'string',
182
+ description: 'The fact or context to remember. Should be a clear, concise statement.',
183
+ },
184
+ },
185
+ required: ['fact'],
186
+ },
187
+ },
188
+ ];
189
+ }
190
+ // ============================================
191
+ // Tool Name Constants
192
+ // ============================================
193
+ export const CODING_TOOL_NAMES = new Set([
194
+ 'paean_edit_file',
195
+ 'paean_grep',
196
+ 'paean_glob',
197
+ 'paean_read_many_files',
198
+ 'paean_web_fetch',
199
+ 'paean_memory',
200
+ ]);
201
+ // ============================================
202
+ // Tool Implementations
203
+ // ============================================
204
+ /**
205
+ * Edit a file using exact string replacement, returning unified diff
206
+ */
207
+ async function editFile(args) {
208
+ const filePath = args.filePath;
209
+ const oldString = args.oldString;
210
+ const newString = args.newString;
211
+ const allowMultiple = args.allowMultiple;
212
+ if (!filePath)
213
+ return { success: false, error: 'filePath is required' };
214
+ if (oldString === undefined)
215
+ return { success: false, error: 'oldString is required' };
216
+ if (newString === undefined)
217
+ return { success: false, error: 'newString is required' };
218
+ if (oldString === newString)
219
+ return { success: false, error: 'oldString and newString are identical — no change needed' };
220
+ const resolvedPath = resolve(filePath);
221
+ // Security: block system paths
222
+ const blockedPrefixes = ['/etc/', '/usr/', '/bin/', '/sbin/', '/System/', '/Library/'];
223
+ if (blockedPrefixes.some(p => resolvedPath.startsWith(p))) {
224
+ return { success: false, error: `Editing system path is not allowed: ${resolvedPath}` };
225
+ }
226
+ let currentContent;
227
+ try {
228
+ currentContent = await readFile(resolvedPath, 'utf-8');
229
+ }
230
+ catch (err) {
231
+ const e = err;
232
+ if (e.code === 'ENOENT') {
233
+ return { success: false, error: `File not found: ${resolvedPath}. Use paean_write_file to create new files.` };
234
+ }
235
+ return { success: false, error: e.message || 'Failed to read file' };
236
+ }
237
+ // Count occurrences
238
+ const occurrences = countOccurrences(currentContent, oldString);
239
+ if (occurrences === 0) {
240
+ // Try to provide helpful context
241
+ const trimmedOld = oldString.trim();
242
+ const fuzzyCount = trimmedOld.length > 5
243
+ ? countOccurrences(currentContent, trimmedOld)
244
+ : 0;
245
+ const hint = fuzzyCount > 0
246
+ ? ` (found ${fuzzyCount} match(es) for the trimmed version — check whitespace/indentation)`
247
+ : '';
248
+ return {
249
+ success: false,
250
+ error: `old_string not found in ${resolvedPath}${hint}`,
251
+ hint: 'Ensure old_string matches the file content exactly, including all whitespace and indentation. Use paean_read_file to verify the current content.',
252
+ };
253
+ }
254
+ if (occurrences > 1 && !allowMultiple) {
255
+ return {
256
+ success: false,
257
+ error: `old_string appears ${occurrences} times in ${resolvedPath}. Set allowMultiple=true to replace all, or provide a more specific old_string with more surrounding context.`,
258
+ occurrences,
259
+ };
260
+ }
261
+ // Apply replacement
262
+ let newContent;
263
+ if (allowMultiple) {
264
+ newContent = currentContent.split(oldString).join(newString);
265
+ }
266
+ else {
267
+ const idx = currentContent.indexOf(oldString);
268
+ newContent = currentContent.slice(0, idx) + newString + currentContent.slice(idx + oldString.length);
269
+ }
270
+ // Generate unified diff
271
+ const diff = generateUnifiedDiff(resolvedPath, currentContent, newContent);
272
+ // Compute diff stats
273
+ const addedLines = diff.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).length;
274
+ const removedLines = diff.split('\n').filter(l => l.startsWith('-') && !l.startsWith('---')).length;
275
+ // Write the file
276
+ await writeFile(resolvedPath, newContent, 'utf-8');
277
+ return {
278
+ success: true,
279
+ filePath: resolvedPath,
280
+ replacements: allowMultiple ? occurrences : 1,
281
+ diff,
282
+ stats: {
283
+ linesAdded: addedLines,
284
+ linesRemoved: removedLines,
285
+ totalLines: newContent.split('\n').length,
286
+ },
287
+ };
288
+ }
289
+ /**
290
+ * Count non-overlapping occurrences of a substring
291
+ */
292
+ function countOccurrences(text, search) {
293
+ if (!search)
294
+ return 0;
295
+ let count = 0;
296
+ let pos = 0;
297
+ while ((pos = text.indexOf(search, pos)) !== -1) {
298
+ count++;
299
+ pos += search.length;
300
+ }
301
+ return count;
302
+ }
303
+ /**
304
+ * Generate a unified diff between two strings
305
+ */
306
+ function generateUnifiedDiff(filePath, oldContent, newContent) {
307
+ const oldLines = oldContent.split('\n');
308
+ const newLines = newContent.split('\n');
309
+ const shortPath = relative(process.cwd(), filePath) || basename(filePath);
310
+ const result = [
311
+ `--- a/${shortPath}`,
312
+ `+++ b/${shortPath}`,
313
+ ];
314
+ // Simple diff: find changed regions
315
+ const maxLen = Math.max(oldLines.length, newLines.length);
316
+ let i = 0;
317
+ while (i < maxLen) {
318
+ // Skip identical lines
319
+ if (i < oldLines.length && i < newLines.length && oldLines[i] === newLines[i]) {
320
+ i++;
321
+ continue;
322
+ }
323
+ // Find the start of a changed region
324
+ const changeStart = i;
325
+ const contextBefore = Math.max(0, changeStart - 3);
326
+ // Find how many old lines are different
327
+ let oldEnd = changeStart;
328
+ let newEnd = changeStart;
329
+ // Simple heuristic: advance until lines match again
330
+ while (oldEnd < oldLines.length || newEnd < newLines.length) {
331
+ if (oldEnd < oldLines.length && newEnd < newLines.length && oldLines[oldEnd] === newLines[newEnd]) {
332
+ // Check if the next few lines also match (avoid false sync)
333
+ let syncLen = 0;
334
+ while (oldEnd + syncLen < oldLines.length &&
335
+ newEnd + syncLen < newLines.length &&
336
+ oldLines[oldEnd + syncLen] === newLines[newEnd + syncLen]) {
337
+ syncLen++;
338
+ if (syncLen >= 3)
339
+ break;
340
+ }
341
+ if (syncLen >= 3)
342
+ break;
343
+ }
344
+ if (oldEnd < oldLines.length)
345
+ oldEnd++;
346
+ if (newEnd < newLines.length)
347
+ newEnd++;
348
+ }
349
+ const contextAfter = Math.min(maxLen, Math.max(oldEnd, newEnd) + 3);
350
+ // Emit hunk header
351
+ const oldStart = contextBefore + 1;
352
+ const oldCount = Math.min(oldEnd + 3, oldLines.length) - contextBefore;
353
+ const newStart = contextBefore + 1;
354
+ const newCount = Math.min(newEnd + 3, newLines.length) - contextBefore;
355
+ result.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
356
+ // Context before
357
+ for (let c = contextBefore; c < changeStart; c++) {
358
+ if (c < oldLines.length)
359
+ result.push(` ${oldLines[c]}`);
360
+ }
361
+ // Removed lines
362
+ for (let c = changeStart; c < oldEnd; c++) {
363
+ if (c < oldLines.length)
364
+ result.push(`-${oldLines[c]}`);
365
+ }
366
+ // Added lines
367
+ for (let c = changeStart; c < newEnd; c++) {
368
+ if (c < newLines.length)
369
+ result.push(`+${newLines[c]}`);
370
+ }
371
+ // Context after
372
+ for (let c = Math.max(oldEnd, newEnd); c < contextAfter; c++) {
373
+ if (c < newLines.length)
374
+ result.push(` ${newLines[c]}`);
375
+ }
376
+ i = contextAfter;
377
+ }
378
+ return result.join('\n');
379
+ }
380
+ // ─────────────────────────────────────────────────────────────────
381
+ // paean_grep
382
+ // ─────────────────────────────────────────────────────────────────
383
+ let _rgAvailable = null;
384
+ async function isRipgrepAvailable() {
385
+ if (_rgAvailable !== null)
386
+ return _rgAvailable;
387
+ try {
388
+ await execFileAsync('rg', ['--version']);
389
+ _rgAvailable = true;
390
+ }
391
+ catch {
392
+ _rgAvailable = false;
393
+ }
394
+ return _rgAvailable;
395
+ }
396
+ async function grepFiles(args) {
397
+ const pattern = args.pattern;
398
+ const dirPath = args.dirPath;
399
+ const includePattern = args.includePattern;
400
+ const excludePattern = args.excludePattern;
401
+ const caseSensitive = args.caseSensitive !== false; // default true
402
+ const maxMatches = Math.min(args.maxMatches || 100, 500);
403
+ const contextLines = Math.min(args.contextLines || 0, 5);
404
+ if (!pattern)
405
+ return { success: false, error: 'pattern is required' };
406
+ const searchDir = resolve(dirPath || process.cwd());
407
+ try {
408
+ await access(searchDir);
409
+ }
410
+ catch {
411
+ return { success: false, error: `Directory not found: ${searchDir}` };
412
+ }
413
+ const hasRg = await isRipgrepAvailable();
414
+ if (hasRg) {
415
+ return grepWithRipgrep(pattern, searchDir, {
416
+ includePattern, excludePattern, caseSensitive, maxMatches, contextLines,
417
+ });
418
+ }
419
+ return grepWithNode(pattern, searchDir, {
420
+ includePattern, excludePattern, caseSensitive, maxMatches, contextLines,
421
+ });
422
+ }
423
+ async function grepWithRipgrep(pattern, searchDir, opts) {
424
+ const rgArgs = [
425
+ '--line-number',
426
+ '--no-heading',
427
+ '--color', 'never',
428
+ '--max-count', String(Math.ceil(opts.maxMatches / 5)), // per-file limit
429
+ '-m', String(opts.maxMatches),
430
+ ];
431
+ if (!opts.caseSensitive)
432
+ rgArgs.push('-i');
433
+ if (opts.contextLines > 0)
434
+ rgArgs.push('-C', String(opts.contextLines));
435
+ if (opts.includePattern)
436
+ rgArgs.push('-g', opts.includePattern);
437
+ if (opts.excludePattern)
438
+ rgArgs.push('-g', `!${opts.excludePattern}`);
439
+ rgArgs.push('--', pattern, searchDir);
440
+ try {
441
+ const { stdout } = await execFileAsync('rg', rgArgs, {
442
+ timeout: 30000,
443
+ maxBuffer: 5 * 1024 * 1024,
444
+ });
445
+ const lines = stdout.trim().split('\n').filter(Boolean);
446
+ const matches = parseRgOutput(lines, searchDir);
447
+ return {
448
+ success: true,
449
+ engine: 'ripgrep',
450
+ matchCount: matches.length,
451
+ truncated: matches.length >= opts.maxMatches,
452
+ matches: matches.slice(0, opts.maxMatches),
453
+ };
454
+ }
455
+ catch (err) {
456
+ const e = err;
457
+ if (e.code === 1) {
458
+ return { success: true, engine: 'ripgrep', matchCount: 0, matches: [] };
459
+ }
460
+ return { success: false, error: e.stderr || e.message || 'ripgrep failed' };
461
+ }
462
+ }
463
+ function parseRgOutput(lines, baseDir) {
464
+ const matches = [];
465
+ for (const line of lines) {
466
+ // Format: filepath:linenum:content or filepath-linenum-content (context)
467
+ const match = line.match(/^(.+?)[:\-](\d+)[:\-](.*)$/);
468
+ if (match) {
469
+ const filePath = relative(baseDir, match[1]) || match[1];
470
+ matches.push({
471
+ file: filePath,
472
+ line: parseInt(match[2], 10),
473
+ content: match[3],
474
+ });
475
+ }
476
+ }
477
+ return matches;
478
+ }
479
+ async function grepWithNode(pattern, searchDir, opts) {
480
+ let regex;
481
+ try {
482
+ regex = new RegExp(pattern, opts.caseSensitive ? 'g' : 'gi');
483
+ }
484
+ catch (err) {
485
+ return { success: false, error: `Invalid regex pattern: ${err.message}` };
486
+ }
487
+ const matches = [];
488
+ const includeRe = opts.includePattern ? globToRegex(opts.includePattern) : null;
489
+ const excludeRe = opts.excludePattern ? globToRegex(opts.excludePattern) : null;
490
+ async function walkDir(dir, depth) {
491
+ if (depth > 10 || matches.length >= opts.maxMatches)
492
+ return;
493
+ let entries;
494
+ try {
495
+ entries = await readdir(dir, { withFileTypes: true });
496
+ }
497
+ catch {
498
+ return;
499
+ }
500
+ for (const entry of entries) {
501
+ if (matches.length >= opts.maxMatches)
502
+ break;
503
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist')
504
+ continue;
505
+ const fullPath = join(dir, entry.name);
506
+ if (entry.isDirectory()) {
507
+ await walkDir(fullPath, depth + 1);
508
+ }
509
+ else if (entry.isFile()) {
510
+ if (includeRe && !includeRe.test(entry.name))
511
+ continue;
512
+ if (excludeRe && excludeRe.test(entry.name))
513
+ continue;
514
+ // Skip binary/large files
515
+ try {
516
+ const s = await stat(fullPath);
517
+ if (s.size > 512000)
518
+ continue;
519
+ }
520
+ catch {
521
+ continue;
522
+ }
523
+ try {
524
+ const content = await readFile(fullPath, 'utf-8');
525
+ const lines = content.split('\n');
526
+ for (let i = 0; i < lines.length && matches.length < opts.maxMatches; i++) {
527
+ regex.lastIndex = 0;
528
+ if (regex.test(lines[i])) {
529
+ matches.push({
530
+ file: relative(searchDir, fullPath),
531
+ line: i + 1,
532
+ content: lines[i],
533
+ });
534
+ }
535
+ }
536
+ }
537
+ catch {
538
+ // Skip unreadable files
539
+ }
540
+ }
541
+ }
542
+ }
543
+ await walkDir(searchDir, 0);
544
+ return {
545
+ success: true,
546
+ engine: 'node',
547
+ matchCount: matches.length,
548
+ truncated: matches.length >= opts.maxMatches,
549
+ matches,
550
+ };
551
+ }
552
+ function globToRegex(pattern) {
553
+ const escaped = pattern
554
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
555
+ .replace(/\*/g, '.*')
556
+ .replace(/\?/g, '.')
557
+ .replace(/\{([^}]+)\}/g, (_m, group) => `(${group.replace(/,/g, '|')})`);
558
+ return new RegExp(`^${escaped}$`, 'i');
559
+ }
560
+ // ─────────────────────────────────────────────────────────────────
561
+ // paean_glob
562
+ // ─────────────────────────────────────────────────────────────────
563
+ async function globFiles(args) {
564
+ const pattern = args.pattern;
565
+ const dirPath = args.dirPath;
566
+ const respectGitignore = args.respectGitignore !== false;
567
+ const maxResults = Math.min(args.maxResults || 200, 1000);
568
+ if (!pattern)
569
+ return { success: false, error: 'pattern is required' };
570
+ const searchDir = resolve(dirPath || process.cwd());
571
+ // Try using rg --files with glob for speed + gitignore awareness
572
+ const hasRg = await isRipgrepAvailable();
573
+ if (hasRg) {
574
+ return globWithRipgrep(pattern, searchDir, respectGitignore, maxResults);
575
+ }
576
+ return globWithNode(pattern, searchDir, respectGitignore, maxResults);
577
+ }
578
+ async function globWithRipgrep(pattern, searchDir, respectGitignore, maxResults) {
579
+ const rgArgs = ['--files', '--glob', pattern];
580
+ if (!respectGitignore)
581
+ rgArgs.push('--no-ignore');
582
+ rgArgs.push(searchDir);
583
+ try {
584
+ const { stdout } = await execFileAsync('rg', rgArgs, {
585
+ timeout: 15000,
586
+ maxBuffer: 5 * 1024 * 1024,
587
+ });
588
+ let files = stdout.trim().split('\n').filter(Boolean);
589
+ // Stat files for modification time and sort by recency
590
+ const withStats = await Promise.all(files.slice(0, maxResults * 2).map(async (f) => {
591
+ try {
592
+ const s = await stat(f);
593
+ return { path: relative(searchDir, f), absolutePath: f, mtime: s.mtimeMs, size: s.size };
594
+ }
595
+ catch {
596
+ return { path: relative(searchDir, f), absolutePath: f, mtime: 0, size: 0 };
597
+ }
598
+ }));
599
+ withStats.sort((a, b) => b.mtime - a.mtime);
600
+ const results = withStats.slice(0, maxResults).map(f => ({
601
+ path: f.path,
602
+ size: f.size,
603
+ modifiedMs: Math.round(f.mtime),
604
+ }));
605
+ return {
606
+ success: true,
607
+ baseDir: searchDir,
608
+ fileCount: results.length,
609
+ truncated: files.length > maxResults,
610
+ files: results,
611
+ };
612
+ }
613
+ catch (err) {
614
+ const e = err;
615
+ if (e.code === 1) {
616
+ return { success: true, baseDir: searchDir, fileCount: 0, files: [] };
617
+ }
618
+ return { success: false, error: e.stderr || e.message || 'glob failed' };
619
+ }
620
+ }
621
+ async function globWithNode(pattern, searchDir, _respectGitignore, maxResults) {
622
+ const patternRe = globToRegex(pattern);
623
+ const results = [];
624
+ async function walk(dir, depth) {
625
+ if (depth > 10 || results.length >= maxResults * 2)
626
+ return;
627
+ let entries;
628
+ try {
629
+ entries = await readdir(dir, { withFileTypes: true });
630
+ }
631
+ catch {
632
+ return;
633
+ }
634
+ for (const entry of entries) {
635
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
636
+ continue;
637
+ const fullPath = join(dir, entry.name);
638
+ const relPath = relative(searchDir, fullPath);
639
+ if (entry.isDirectory()) {
640
+ // Check if pattern could match paths in this directory
641
+ await walk(fullPath, depth + 1);
642
+ }
643
+ else if (entry.isFile()) {
644
+ if (patternRe.test(entry.name) || patternRe.test(relPath)) {
645
+ try {
646
+ const s = await stat(fullPath);
647
+ results.push({
648
+ path: relPath,
649
+ size: s.size,
650
+ modifiedMs: Math.round(s.mtimeMs),
651
+ });
652
+ }
653
+ catch {
654
+ results.push({ path: relPath, size: 0, modifiedMs: 0 });
655
+ }
656
+ }
657
+ }
658
+ }
659
+ }
660
+ await walk(searchDir, 0);
661
+ results.sort((a, b) => b.modifiedMs - a.modifiedMs);
662
+ return {
663
+ success: true,
664
+ baseDir: searchDir,
665
+ fileCount: Math.min(results.length, maxResults),
666
+ truncated: results.length > maxResults,
667
+ files: results.slice(0, maxResults),
668
+ };
669
+ }
670
+ // ─────────────────────────────────────────────────────────────────
671
+ // paean_read_many_files
672
+ // ─────────────────────────────────────────────────────────────────
673
+ async function readManyFiles(args) {
674
+ const paths = args.paths;
675
+ const maxTotalLines = Math.min(args.maxTotalLines || 5000, 20000);
676
+ const maxFileSize = args.maxFileSize || 512000;
677
+ if (!paths || !Array.isArray(paths) || paths.length === 0) {
678
+ return { success: false, error: 'paths array is required and must not be empty' };
679
+ }
680
+ if (paths.length > 50) {
681
+ return { success: false, error: 'Maximum 50 files can be read in a single call' };
682
+ }
683
+ const results = [];
684
+ let totalLinesRead = 0;
685
+ for (const filePath of paths) {
686
+ if (totalLinesRead >= maxTotalLines) {
687
+ results.push({ path: filePath, error: 'Skipped: total line limit reached' });
688
+ continue;
689
+ }
690
+ const resolvedPath = resolve(filePath);
691
+ try {
692
+ const s = await stat(resolvedPath);
693
+ if (s.size > maxFileSize) {
694
+ results.push({ path: filePath, error: `Skipped: file too large (${(s.size / 1024).toFixed(0)}KB > ${(maxFileSize / 1024).toFixed(0)}KB)` });
695
+ continue;
696
+ }
697
+ if (s.isDirectory()) {
698
+ results.push({ path: filePath, error: 'Skipped: path is a directory' });
699
+ continue;
700
+ }
701
+ }
702
+ catch {
703
+ results.push({ path: filePath, error: 'File not found' });
704
+ continue;
705
+ }
706
+ try {
707
+ const content = await readFile(resolvedPath, 'utf-8');
708
+ const lines = content.split('\n');
709
+ const remainingLines = maxTotalLines - totalLinesRead;
710
+ const truncated = lines.length > remainingLines;
711
+ const displayLines = truncated ? lines.slice(0, remainingLines) : lines;
712
+ // Add line numbers
713
+ const numbered = displayLines.map((line, i) => `${String(i + 1).padStart(4)}|${line}`).join('\n');
714
+ results.push({
715
+ path: relative(process.cwd(), resolvedPath) || filePath,
716
+ content: numbered,
717
+ lineCount: lines.length,
718
+ truncated,
719
+ });
720
+ totalLinesRead += displayLines.length;
721
+ }
722
+ catch (err) {
723
+ results.push({ path: filePath, error: err.message || 'Failed to read' });
724
+ }
725
+ }
726
+ return {
727
+ success: true,
728
+ filesRead: results.filter(r => r.content !== undefined).length,
729
+ totalFiles: paths.length,
730
+ totalLinesRead,
731
+ results,
732
+ };
733
+ }
734
+ // ─────────────────────────────────────────────────────────────────
735
+ // paean_web_fetch
736
+ // ─────────────────────────────────────────────────────────────────
737
+ async function webFetch(args) {
738
+ const url = args.url;
739
+ const maxLength = Math.min(args.maxLength || 50000, 200000);
740
+ const customHeaders = args.headers;
741
+ if (!url)
742
+ return { success: false, error: 'url is required' };
743
+ let parsedUrl;
744
+ try {
745
+ parsedUrl = new URL(url);
746
+ }
747
+ catch {
748
+ return { success: false, error: 'Invalid URL format' };
749
+ }
750
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
751
+ return { success: false, error: `Unsupported protocol: ${parsedUrl.protocol}` };
752
+ }
753
+ try {
754
+ const response = await fetch(url, {
755
+ headers: {
756
+ 'User-Agent': 'Paean-CLI/1.0 (Bot)',
757
+ 'Accept': 'text/html,application/xhtml+xml,application/json,text/plain,*/*',
758
+ ...customHeaders,
759
+ },
760
+ signal: AbortSignal.timeout(30_000),
761
+ redirect: 'follow',
762
+ });
763
+ if (!response.ok) {
764
+ return {
765
+ success: false,
766
+ error: `HTTP ${response.status} ${response.statusText}`,
767
+ url,
768
+ };
769
+ }
770
+ const contentType = response.headers.get('content-type') || '';
771
+ const rawText = await response.text();
772
+ let content;
773
+ if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
774
+ content = stripHtml(rawText);
775
+ }
776
+ else {
777
+ content = rawText;
778
+ }
779
+ // Truncate if necessary
780
+ const truncated = content.length > maxLength;
781
+ if (truncated) {
782
+ content = content.slice(0, maxLength) + '\n\n[Content truncated]';
783
+ }
784
+ return {
785
+ success: true,
786
+ url,
787
+ contentType,
788
+ contentLength: content.length,
789
+ truncated,
790
+ content,
791
+ };
792
+ }
793
+ catch (err) {
794
+ if (err.name === 'TimeoutError') {
795
+ return { success: false, error: 'Request timed out after 30 seconds', url };
796
+ }
797
+ return { success: false, error: err.message || 'Fetch failed', url };
798
+ }
799
+ }
800
+ /**
801
+ * Strip HTML to extract readable text
802
+ */
803
+ function stripHtml(html) {
804
+ let text = html;
805
+ // Remove script and style blocks
806
+ text = text.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
807
+ text = text.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
808
+ text = text.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, '');
809
+ // Convert common block elements to newlines
810
+ text = text.replace(/<\/?(p|div|br|hr|h[1-6]|li|tr|blockquote|pre|section|article|header|footer|nav|main)\b[^>]*>/gi, '\n');
811
+ // Convert links to text [text](url) format
812
+ text = text.replace(/<a\b[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)');
813
+ // Remove remaining HTML tags
814
+ text = text.replace(/<[^>]+>/g, '');
815
+ // Decode common HTML entities
816
+ text = text.replace(/&amp;/g, '&');
817
+ text = text.replace(/&lt;/g, '<');
818
+ text = text.replace(/&gt;/g, '>');
819
+ text = text.replace(/&quot;/g, '"');
820
+ text = text.replace(/&#39;/g, "'");
821
+ text = text.replace(/&nbsp;/g, ' ');
822
+ text = text.replace(/&#(\d+);/g, (_m, code) => String.fromCharCode(parseInt(code, 10)));
823
+ // Clean up whitespace
824
+ text = text.replace(/[ \t]+/g, ' ');
825
+ text = text.replace(/\n{3,}/g, '\n\n');
826
+ text = text.trim();
827
+ return text;
828
+ }
829
+ // ─────────────────────────────────────────────────────────────────
830
+ // paean_memory
831
+ // ─────────────────────────────────────────────────────────────────
832
+ const MEMORY_DIR = '.paean';
833
+ const MEMORY_FILE = 'PAEAN.md';
834
+ const MEMORY_HEADER = '## Paean Memories\n\nFacts and context remembered across sessions.\n';
835
+ async function saveMemory(args) {
836
+ const fact = args.fact;
837
+ if (!fact || typeof fact !== 'string') {
838
+ return { success: false, error: 'fact is required and must be a string' };
839
+ }
840
+ const projectRoot = process.cwd();
841
+ const memoryDir = join(projectRoot, MEMORY_DIR);
842
+ const memoryPath = join(memoryDir, MEMORY_FILE);
843
+ try {
844
+ await mkdir(memoryDir, { recursive: true });
845
+ let existing = '';
846
+ try {
847
+ existing = await readFile(memoryPath, 'utf-8');
848
+ }
849
+ catch {
850
+ // File doesn't exist yet — will create
851
+ }
852
+ if (!existing) {
853
+ existing = MEMORY_HEADER;
854
+ }
855
+ // Check for duplicate
856
+ if (existing.includes(fact.trim())) {
857
+ return {
858
+ success: true,
859
+ message: 'This fact is already saved',
860
+ memoryPath,
861
+ duplicate: true,
862
+ };
863
+ }
864
+ // Append the fact with timestamp
865
+ const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
866
+ const entry = `\n- ${fact.trim()} *(${timestamp})*\n`;
867
+ await appendFile(memoryPath, entry, 'utf-8');
868
+ // Count total memories
869
+ const content = await readFile(memoryPath, 'utf-8');
870
+ const memoryCount = (content.match(/^- /gm) || []).length;
871
+ return {
872
+ success: true,
873
+ message: 'Memory saved',
874
+ memoryPath,
875
+ memoryCount,
876
+ };
877
+ }
878
+ catch (err) {
879
+ return {
880
+ success: false,
881
+ error: err.message || 'Failed to save memory',
882
+ };
883
+ }
884
+ }
885
+ /**
886
+ * Load all memories from PAEAN.md for injection into context
887
+ */
888
+ export async function loadMemories(projectRoot) {
889
+ const root = projectRoot || process.cwd();
890
+ const memoryPath = join(root, MEMORY_DIR, MEMORY_FILE);
891
+ try {
892
+ const content = await readFile(memoryPath, 'utf-8');
893
+ return content.trim() || null;
894
+ }
895
+ catch {
896
+ return null;
897
+ }
898
+ }
899
+ // ============================================
900
+ // Tool Router
901
+ // ============================================
902
+ export async function executeCodingTool(toolName, args) {
903
+ switch (toolName) {
904
+ case 'paean_edit_file':
905
+ return editFile(args);
906
+ case 'paean_grep':
907
+ return grepFiles(args);
908
+ case 'paean_glob':
909
+ return globFiles(args);
910
+ case 'paean_read_many_files':
911
+ return readManyFiles(args);
912
+ case 'paean_web_fetch':
913
+ return webFetch(args);
914
+ case 'paean_memory':
915
+ return saveMemory(args);
916
+ default:
917
+ return { success: false, error: `Unknown coding tool: ${toolName}` };
918
+ }
919
+ }
920
+ //# sourceMappingURL=coding-tools.js.map