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.
- package/dist/agent/cli-mode.d.ts.map +1 -1
- package/dist/agent/cli-mode.js +21 -13
- package/dist/agent/cli-mode.js.map +1 -1
- package/dist/agent/completer.d.ts.map +1 -1
- package/dist/agent/completer.js +12 -4
- package/dist/agent/completer.js.map +1 -1
- package/dist/mcp/coding-tools.d.ts +18 -0
- package/dist/mcp/coding-tools.d.ts.map +1 -0
- package/dist/mcp/coding-tools.js +920 -0
- package/dist/mcp/coding-tools.js.map +1 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +16 -4
- package/dist/mcp/tools.js.map +1 -1
- package/dist/ui/App.d.ts.map +1 -1
- package/dist/ui/App.js +3 -3
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/ToolCallDisplay.d.ts +1 -1
- package/dist/ui/ToolCallDisplay.d.ts.map +1 -1
- package/dist/ui/ToolCallDisplay.js +102 -27
- package/dist/ui/ToolCallDisplay.js.map +1 -1
- package/dist/ui/components/StatusBar.d.ts +14 -0
- package/dist/ui/components/StatusBar.d.ts.map +1 -0
- package/dist/ui/components/StatusBar.js +51 -0
- package/dist/ui/components/StatusBar.js.map +1 -0
- package/dist/ui/components/index.d.ts +1 -0
- package/dist/ui/components/index.d.ts.map +1 -1
- package/dist/ui/components/index.js +1 -0
- package/dist/ui/components/index.js.map +1 -1
- package/dist/ui/hooks/useCommands.d.ts +12 -1
- package/dist/ui/hooks/useCommands.d.ts.map +1 -1
- package/dist/ui/hooks/useCommands.js +155 -37
- package/dist/ui/hooks/useCommands.js.map +1 -1
- package/package.json +1 -1
|
@@ -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(/&/g, '&');
|
|
817
|
+
text = text.replace(/</g, '<');
|
|
818
|
+
text = text.replace(/>/g, '>');
|
|
819
|
+
text = text.replace(/"/g, '"');
|
|
820
|
+
text = text.replace(/'/g, "'");
|
|
821
|
+
text = text.replace(/ /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
|