@plumpslabs/kuma 2.0.5
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/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/index.js +4678 -0
- package/package.json +68 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4678 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
|
|
8
|
+
// src/manifest.ts
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
// src/tools/smartGrep.ts
|
|
12
|
+
import fg from "fast-glob";
|
|
13
|
+
import fs3 from "fs";
|
|
14
|
+
import path3 from "path";
|
|
15
|
+
|
|
16
|
+
// src/utils/pathValidator.ts
|
|
17
|
+
import path from "path";
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
function validateFilePath(filePath, projectRoot) {
|
|
20
|
+
try {
|
|
21
|
+
const root = projectRoot ?? getProjectRoot();
|
|
22
|
+
const resolvedRoot = path.resolve(root);
|
|
23
|
+
const normalizedRoot = path.normalize(resolvedRoot).toLowerCase();
|
|
24
|
+
let resolvedPath;
|
|
25
|
+
if (!path.isAbsolute(filePath)) {
|
|
26
|
+
const cwdPath = path.resolve(process.cwd(), filePath);
|
|
27
|
+
const normalizedCwdPath = path.normalize(cwdPath).toLowerCase();
|
|
28
|
+
if (fs.existsSync(cwdPath) && normalizedCwdPath.startsWith(normalizedRoot)) {
|
|
29
|
+
resolvedPath = cwdPath;
|
|
30
|
+
} else {
|
|
31
|
+
resolvedPath = path.resolve(resolvedRoot, filePath);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
resolvedPath = path.resolve(resolvedRoot, filePath);
|
|
35
|
+
}
|
|
36
|
+
const normalizedPath = path.normalize(resolvedPath).toLowerCase();
|
|
37
|
+
if (!normalizedPath.startsWith(normalizedRoot)) {
|
|
38
|
+
return {
|
|
39
|
+
valid: false,
|
|
40
|
+
error: new Error(
|
|
41
|
+
`PATH_TRAVERSAL: Access denied. Path "${filePath}" resolves outside project root "${resolvedRoot}".`
|
|
42
|
+
)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (normalizedPath.includes("..")) {
|
|
46
|
+
return {
|
|
47
|
+
valid: false,
|
|
48
|
+
error: new Error(
|
|
49
|
+
`PATH_TRAVERSAL: Path traversal detected in "${filePath}". Relative paths with ".." are not allowed.`
|
|
50
|
+
)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const blockedPatterns = [
|
|
54
|
+
"/etc/",
|
|
55
|
+
"/sys/",
|
|
56
|
+
"/proc/",
|
|
57
|
+
"/dev/",
|
|
58
|
+
"/boot/",
|
|
59
|
+
"/usr/",
|
|
60
|
+
"C:\\Windows",
|
|
61
|
+
"C:\\Program Files",
|
|
62
|
+
"C:\\Program Files (x86)",
|
|
63
|
+
"C:\\System32",
|
|
64
|
+
"~/.ssh",
|
|
65
|
+
"/root/"
|
|
66
|
+
];
|
|
67
|
+
for (const pattern of blockedPatterns) {
|
|
68
|
+
if (normalizedPath.includes(pattern.toLowerCase())) {
|
|
69
|
+
return {
|
|
70
|
+
valid: false,
|
|
71
|
+
error: new Error(
|
|
72
|
+
`PATH_TRAVERSAL: Access to system directory "${pattern}" is blocked.`
|
|
73
|
+
)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (normalizedPath.includes("node_modules")) {
|
|
78
|
+
return {
|
|
79
|
+
valid: true,
|
|
80
|
+
resolvedPath
|
|
81
|
+
// Warning will be handled by caller
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { valid: true, resolvedPath };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return {
|
|
87
|
+
valid: false,
|
|
88
|
+
error: err instanceof Error ? err : new Error(`Path validation error: ${String(err)}`)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function validateFileExtension(filePath) {
|
|
93
|
+
const allowedExtensions = [
|
|
94
|
+
".ts",
|
|
95
|
+
".tsx",
|
|
96
|
+
".js",
|
|
97
|
+
".jsx",
|
|
98
|
+
".mjs",
|
|
99
|
+
".cjs",
|
|
100
|
+
".json",
|
|
101
|
+
".md",
|
|
102
|
+
".css",
|
|
103
|
+
".html",
|
|
104
|
+
".htm",
|
|
105
|
+
".env",
|
|
106
|
+
".env.example",
|
|
107
|
+
".env.local",
|
|
108
|
+
".yml",
|
|
109
|
+
".yaml",
|
|
110
|
+
".toml",
|
|
111
|
+
".svg",
|
|
112
|
+
".png",
|
|
113
|
+
".jpg",
|
|
114
|
+
".gif",
|
|
115
|
+
".sh",
|
|
116
|
+
".bat",
|
|
117
|
+
".ps1",
|
|
118
|
+
".sql",
|
|
119
|
+
".graphql",
|
|
120
|
+
".gql",
|
|
121
|
+
".vue",
|
|
122
|
+
".svelte",
|
|
123
|
+
".astro",
|
|
124
|
+
".prisma",
|
|
125
|
+
".proto",
|
|
126
|
+
".txt",
|
|
127
|
+
".log"
|
|
128
|
+
];
|
|
129
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
130
|
+
return allowedExtensions.includes(ext);
|
|
131
|
+
}
|
|
132
|
+
function getProjectRoot() {
|
|
133
|
+
return process.env.AGENT_PROJECT_ROOT ?? process.cwd();
|
|
134
|
+
}
|
|
135
|
+
function getBackupPath(filePath, timestamp) {
|
|
136
|
+
const ts = timestamp ?? Date.now();
|
|
137
|
+
const root = getProjectRoot();
|
|
138
|
+
const relativePath = path.relative(root, filePath);
|
|
139
|
+
return path.join(root, ".agent-backups", String(ts), relativePath);
|
|
140
|
+
}
|
|
141
|
+
function ensureBackupDir() {
|
|
142
|
+
const root = getProjectRoot();
|
|
143
|
+
const backupDir = path.join(root, ".agent-backups");
|
|
144
|
+
if (!fs.existsSync(backupDir)) {
|
|
145
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
146
|
+
}
|
|
147
|
+
return backupDir;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/utils/tokenCounter.ts
|
|
151
|
+
function estimateTokens(text) {
|
|
152
|
+
return Math.ceil(text.length / 3);
|
|
153
|
+
}
|
|
154
|
+
function truncateToTokenLimit(text, maxTokens) {
|
|
155
|
+
const estimatedTokens = estimateTokens(text);
|
|
156
|
+
if (estimatedTokens <= maxTokens) return text;
|
|
157
|
+
const maxChars = maxTokens * 3;
|
|
158
|
+
const truncated = text.slice(0, maxChars);
|
|
159
|
+
return truncated + `
|
|
160
|
+
|
|
161
|
+
[...truncated: estimated ${estimatedTokens} tokens > limit of ${maxTokens} tokens]`;
|
|
162
|
+
}
|
|
163
|
+
function limitLines(text, maxLines) {
|
|
164
|
+
const lines = text.split("\n");
|
|
165
|
+
if (lines.length <= maxLines) return text;
|
|
166
|
+
const truncated = lines.slice(0, maxLines).join("\n");
|
|
167
|
+
return truncated + `
|
|
168
|
+
|
|
169
|
+
[...${lines.length - maxLines} more lines truncated]`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/engine/sessionMemory.ts
|
|
173
|
+
import fs2 from "fs";
|
|
174
|
+
import path2 from "path";
|
|
175
|
+
var SessionMemory = class {
|
|
176
|
+
state;
|
|
177
|
+
initialized = false;
|
|
178
|
+
init(config) {
|
|
179
|
+
const kumaDir = path2.join(config.projectRoot, ".kuma");
|
|
180
|
+
const sessionFile = path2.join(kumaDir, "memory.json");
|
|
181
|
+
const oldFile = path2.join(kumaDir, ".kuma-memory.json");
|
|
182
|
+
if (fs2.existsSync(oldFile) && !fs2.existsSync(sessionFile)) {
|
|
183
|
+
try {
|
|
184
|
+
fs2.renameSync(oldFile, sessionFile);
|
|
185
|
+
console.error("[SessionMemory] Migrated .kuma-memory.json \u2192 memory.json");
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const oldSessionFile = path2.join(kumaDir, "session.json");
|
|
190
|
+
if (fs2.existsSync(oldSessionFile) && !fs2.existsSync(sessionFile)) {
|
|
191
|
+
try {
|
|
192
|
+
fs2.renameSync(oldSessionFile, sessionFile);
|
|
193
|
+
console.error("[SessionMemory] Migrated session.json \u2192 memory.json");
|
|
194
|
+
} catch {
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (fs2.existsSync(sessionFile)) {
|
|
198
|
+
try {
|
|
199
|
+
const raw = fs2.readFileSync(sessionFile, "utf-8");
|
|
200
|
+
const parsed = JSON.parse(raw);
|
|
201
|
+
this.state = {
|
|
202
|
+
projectRoot: parsed.projectRoot || config.projectRoot,
|
|
203
|
+
startTime: parsed.startTime || config.startTime,
|
|
204
|
+
currentGoal: parsed.currentGoal || "",
|
|
205
|
+
completedSteps: parsed.completedSteps || [],
|
|
206
|
+
modifiedFiles: new Map(parsed.modifiedFiles || []),
|
|
207
|
+
failedFiles: new Map(parsed.failedFiles || []),
|
|
208
|
+
searchResults: new Map(parsed.searchResults || []),
|
|
209
|
+
dependencyGraph: new Map(parsed.dependencyGraph || []),
|
|
210
|
+
toolCalls: parsed.toolCalls || [],
|
|
211
|
+
conventions: parsed.conventions
|
|
212
|
+
};
|
|
213
|
+
this.initialized = true;
|
|
214
|
+
console.error(`[SessionMemory] Loaded persistent session memory.json`);
|
|
215
|
+
return;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
console.error(`[SessionMemory] Failed to load persistent session: ${err}. Re-initializing.`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
this.state = {
|
|
221
|
+
projectRoot: config.projectRoot,
|
|
222
|
+
startTime: config.startTime,
|
|
223
|
+
currentGoal: "",
|
|
224
|
+
completedSteps: [],
|
|
225
|
+
modifiedFiles: /* @__PURE__ */ new Map(),
|
|
226
|
+
failedFiles: /* @__PURE__ */ new Map(),
|
|
227
|
+
searchResults: /* @__PURE__ */ new Map(),
|
|
228
|
+
dependencyGraph: /* @__PURE__ */ new Map(),
|
|
229
|
+
toolCalls: []
|
|
230
|
+
};
|
|
231
|
+
this.initialized = true;
|
|
232
|
+
this.save();
|
|
233
|
+
this.ensureMemoriesDir();
|
|
234
|
+
console.error(`[SessionMemory] Initialized at ${new Date(config.startTime).toISOString()}`);
|
|
235
|
+
}
|
|
236
|
+
memoriesDir() {
|
|
237
|
+
return path2.join(this.state.projectRoot, ".kuma", "memories");
|
|
238
|
+
}
|
|
239
|
+
ensureMemoriesDir() {
|
|
240
|
+
const dir = this.memoriesDir();
|
|
241
|
+
if (!fs2.existsSync(dir)) {
|
|
242
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
memoryFilePath(topic) {
|
|
246
|
+
return path2.join(this.memoriesDir(), `${topic}.md`);
|
|
247
|
+
}
|
|
248
|
+
getMemoryContent(topic) {
|
|
249
|
+
this.ensureInit();
|
|
250
|
+
this.ensureMemoriesDir();
|
|
251
|
+
const filePath = this.memoryFilePath(topic);
|
|
252
|
+
if (fs2.existsSync(filePath)) {
|
|
253
|
+
return fs2.readFileSync(filePath, "utf-8");
|
|
254
|
+
}
|
|
255
|
+
return this.generateMemory(topic);
|
|
256
|
+
}
|
|
257
|
+
writeMemory(topic, content) {
|
|
258
|
+
this.ensureInit();
|
|
259
|
+
this.ensureMemoriesDir();
|
|
260
|
+
this.writeMemoryFile(topic, content);
|
|
261
|
+
}
|
|
262
|
+
writeMemoryFile(topic, content) {
|
|
263
|
+
const filePath = this.memoryFilePath(topic);
|
|
264
|
+
fs2.writeFileSync(filePath, content, "utf-8");
|
|
265
|
+
}
|
|
266
|
+
generateMemory(topic) {
|
|
267
|
+
switch (topic) {
|
|
268
|
+
case "architecture":
|
|
269
|
+
return this.generateArchitectureMd();
|
|
270
|
+
case "conventions":
|
|
271
|
+
return this.generateConventionsMd();
|
|
272
|
+
case "known-issues":
|
|
273
|
+
return this.generateKnownIssuesMd();
|
|
274
|
+
default:
|
|
275
|
+
return `# ${topic}
|
|
276
|
+
|
|
277
|
+
(empty)`;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
generateArchitectureMd() {
|
|
281
|
+
const conv = this.state.conventions;
|
|
282
|
+
if (!conv) return `# Architecture
|
|
283
|
+
|
|
284
|
+
Run project_conventions first to auto-generate.`;
|
|
285
|
+
const stackKeys = ["framework", "projectType", "monorepo", "buildTool", "packageManager"];
|
|
286
|
+
const lines = [
|
|
287
|
+
"# Architecture",
|
|
288
|
+
"",
|
|
289
|
+
`Generated at ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
290
|
+
"",
|
|
291
|
+
"## Stack",
|
|
292
|
+
""
|
|
293
|
+
];
|
|
294
|
+
for (const key of stackKeys) {
|
|
295
|
+
if (conv[key] !== void 0) {
|
|
296
|
+
lines.push(`- **${key}**: ${JSON.stringify(conv[key])}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
lines.push("", "## Project Layout", "");
|
|
300
|
+
if (conv.workspaces) {
|
|
301
|
+
lines.push(`- Monorepo workspaces: ${JSON.stringify(conv.workspaces)}`);
|
|
302
|
+
}
|
|
303
|
+
lines.push(`- Source root: ${conv.srcDir || "src/"}`);
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
306
|
+
generateConventionsMd() {
|
|
307
|
+
const conv = this.state.conventions;
|
|
308
|
+
if (!conv) return `# Conventions
|
|
309
|
+
|
|
310
|
+
Run project_conventions first to auto-generate.`;
|
|
311
|
+
const styleKeys = ["testRunner", "styling", "importAlias", "lintConfig", "codeStyle"];
|
|
312
|
+
const lines = [
|
|
313
|
+
"# Conventions",
|
|
314
|
+
"",
|
|
315
|
+
`Generated at ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
316
|
+
"",
|
|
317
|
+
"## Code Style",
|
|
318
|
+
""
|
|
319
|
+
];
|
|
320
|
+
for (const key of styleKeys) {
|
|
321
|
+
if (conv[key] !== void 0) {
|
|
322
|
+
lines.push(`- **${key}**: ${JSON.stringify(conv[key])}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (conv.testRunner) {
|
|
326
|
+
lines.push("", `## Testing
|
|
327
|
+
|
|
328
|
+
- Test runner: ${conv.testRunner}`);
|
|
329
|
+
}
|
|
330
|
+
if (conv.importAlias) {
|
|
331
|
+
lines.push("", `## Imports
|
|
332
|
+
|
|
333
|
+
- Alias: \`${conv.importAlias}\``);
|
|
334
|
+
}
|
|
335
|
+
return lines.join("\n");
|
|
336
|
+
}
|
|
337
|
+
generateKnownIssuesMd() {
|
|
338
|
+
const allFailures = this.getFailedFiles();
|
|
339
|
+
const unresolved = allFailures.filter((f) => f.failures.some((ff) => !ff.resolved));
|
|
340
|
+
if (unresolved.length === 0) return `# Known Issues
|
|
341
|
+
|
|
342
|
+
No unresolved issues.`;
|
|
343
|
+
const lines = [
|
|
344
|
+
"# Known Issues",
|
|
345
|
+
"",
|
|
346
|
+
`Generated at ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
347
|
+
""
|
|
348
|
+
];
|
|
349
|
+
for (const f of unresolved) {
|
|
350
|
+
lines.push(`## ${f.task}`);
|
|
351
|
+
for (const ff of f.failures) {
|
|
352
|
+
if (!ff.resolved) {
|
|
353
|
+
lines.push(`- ${new Date(ff.timestamp).toISOString()}: ${ff.error}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
lines.push("");
|
|
357
|
+
}
|
|
358
|
+
return lines.join("\n");
|
|
359
|
+
}
|
|
360
|
+
save() {
|
|
361
|
+
if (!this.initialized || !this.state) return;
|
|
362
|
+
try {
|
|
363
|
+
const kumaDir = path2.join(this.state.projectRoot, ".kuma");
|
|
364
|
+
if (!fs2.existsSync(kumaDir)) {
|
|
365
|
+
fs2.mkdirSync(kumaDir, { recursive: true });
|
|
366
|
+
}
|
|
367
|
+
const serialized = {
|
|
368
|
+
projectRoot: this.state.projectRoot,
|
|
369
|
+
startTime: this.state.startTime,
|
|
370
|
+
currentGoal: this.state.currentGoal,
|
|
371
|
+
completedSteps: this.state.completedSteps,
|
|
372
|
+
modifiedFiles: Array.from(this.state.modifiedFiles.entries()),
|
|
373
|
+
failedFiles: Array.from(this.state.failedFiles.entries()),
|
|
374
|
+
searchResults: Array.from(this.state.searchResults.entries()),
|
|
375
|
+
dependencyGraph: Array.from(this.state.dependencyGraph.entries()),
|
|
376
|
+
toolCalls: this.state.toolCalls,
|
|
377
|
+
conventions: this.state.conventions
|
|
378
|
+
};
|
|
379
|
+
fs2.writeFileSync(path2.join(kumaDir, "memory.json"), JSON.stringify(serialized, null, 2), "utf-8");
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error(`[SessionMemory] Failed to save session: ${err}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
setGoal(goal) {
|
|
385
|
+
this.ensureInit();
|
|
386
|
+
this.state.currentGoal = goal;
|
|
387
|
+
this.save();
|
|
388
|
+
}
|
|
389
|
+
addCompletedStep(step) {
|
|
390
|
+
this.ensureInit();
|
|
391
|
+
if (!this.state.completedSteps.includes(step)) {
|
|
392
|
+
this.state.completedSteps.push(step);
|
|
393
|
+
this.save();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
addModifiedFile(filePath) {
|
|
397
|
+
this.ensureInit();
|
|
398
|
+
const existing = this.state.modifiedFiles.get(filePath);
|
|
399
|
+
if (existing) {
|
|
400
|
+
existing.modifiedAt = Date.now();
|
|
401
|
+
existing.status = "modified";
|
|
402
|
+
} else {
|
|
403
|
+
this.state.modifiedFiles.set(filePath, {
|
|
404
|
+
filePath,
|
|
405
|
+
modifiedAt: Date.now(),
|
|
406
|
+
status: "modified"
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
this.save();
|
|
410
|
+
}
|
|
411
|
+
addCreatedFile(filePath) {
|
|
412
|
+
this.ensureInit();
|
|
413
|
+
this.state.modifiedFiles.set(filePath, {
|
|
414
|
+
filePath,
|
|
415
|
+
modifiedAt: Date.now(),
|
|
416
|
+
status: "created"
|
|
417
|
+
});
|
|
418
|
+
this.save();
|
|
419
|
+
}
|
|
420
|
+
addFailedFile(task, error) {
|
|
421
|
+
this.ensureInit();
|
|
422
|
+
const trimmedError = error?.trim() ?? "";
|
|
423
|
+
if (!trimmedError) return;
|
|
424
|
+
const truncatedError = trimmedError.substring(0, 500);
|
|
425
|
+
const failures = this.state.failedFiles.get(task) ?? [];
|
|
426
|
+
const lastUnresolved = [...failures].reverse().find((f) => !f.resolved);
|
|
427
|
+
if (lastUnresolved && lastUnresolved.error === truncatedError) return;
|
|
428
|
+
failures.push({
|
|
429
|
+
task,
|
|
430
|
+
error: truncatedError,
|
|
431
|
+
timestamp: Date.now(),
|
|
432
|
+
resolved: false
|
|
433
|
+
});
|
|
434
|
+
this.state.failedFiles.set(task, failures);
|
|
435
|
+
this.save();
|
|
436
|
+
this.ensureMemoriesDir();
|
|
437
|
+
this.writeMemoryFile("known-issues", this.generateKnownIssuesMd());
|
|
438
|
+
}
|
|
439
|
+
markFailureResolved(task) {
|
|
440
|
+
this.ensureInit();
|
|
441
|
+
const failures = this.state.failedFiles.get(task);
|
|
442
|
+
if (failures) {
|
|
443
|
+
let changed = false;
|
|
444
|
+
for (const f of failures) {
|
|
445
|
+
if (!f.resolved) {
|
|
446
|
+
f.resolved = true;
|
|
447
|
+
changed = true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (changed) {
|
|
451
|
+
this.save();
|
|
452
|
+
this.ensureMemoriesDir();
|
|
453
|
+
this.writeMemoryFile("known-issues", this.generateKnownIssuesMd());
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
addSearchResult(query, files) {
|
|
458
|
+
this.ensureInit();
|
|
459
|
+
this.state.searchResults.set(query, files);
|
|
460
|
+
this.save();
|
|
461
|
+
}
|
|
462
|
+
addDependency(file, dependsOn) {
|
|
463
|
+
this.ensureInit();
|
|
464
|
+
const deps = this.state.dependencyGraph.get(file) ?? [];
|
|
465
|
+
if (!deps.includes(dependsOn)) {
|
|
466
|
+
deps.push(dependsOn);
|
|
467
|
+
this.state.dependencyGraph.set(file, deps);
|
|
468
|
+
this.save();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
recordToolCall(toolName, params) {
|
|
472
|
+
this.ensureInit();
|
|
473
|
+
this.state.toolCalls.push({
|
|
474
|
+
toolName,
|
|
475
|
+
params,
|
|
476
|
+
timestamp: Date.now()
|
|
477
|
+
});
|
|
478
|
+
if (this.state.toolCalls.length > 100) {
|
|
479
|
+
this.state.toolCalls = this.state.toolCalls.slice(-100);
|
|
480
|
+
}
|
|
481
|
+
this.save();
|
|
482
|
+
}
|
|
483
|
+
setConventions(conventions) {
|
|
484
|
+
this.ensureInit();
|
|
485
|
+
this.state.conventions = conventions;
|
|
486
|
+
this.save();
|
|
487
|
+
this.ensureMemoriesDir();
|
|
488
|
+
this.writeMemoryFile("architecture", this.generateArchitectureMd());
|
|
489
|
+
this.writeMemoryFile("conventions", this.generateConventionsMd());
|
|
490
|
+
}
|
|
491
|
+
// ============================================================
|
|
492
|
+
// KEYWORD SEARCH — Search session memory content
|
|
493
|
+
// ============================================================
|
|
494
|
+
/**
|
|
495
|
+
* Search through tool call history, memory files, search results,
|
|
496
|
+
* errors, and file modifications for a keyword.
|
|
497
|
+
*/
|
|
498
|
+
searchMemory(query, limit = 20) {
|
|
499
|
+
this.ensureInit();
|
|
500
|
+
const results = [];
|
|
501
|
+
const q = query.toLowerCase();
|
|
502
|
+
for (const call of this.state.toolCalls) {
|
|
503
|
+
const paramStr = JSON.stringify(call.params);
|
|
504
|
+
if (call.toolName.toLowerCase().includes(q) || paramStr.toLowerCase().includes(q)) {
|
|
505
|
+
results.push({
|
|
506
|
+
type: `tool:${call.toolName}`,
|
|
507
|
+
content: `${call.toolName}(${JSON.stringify(call.params).substring(0, 200)})`,
|
|
508
|
+
timestamp: call.timestamp
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
for (const [queryStr, files] of this.state.searchResults) {
|
|
513
|
+
if (queryStr.toLowerCase().includes(q) || files.some((f) => f.toLowerCase().includes(q))) {
|
|
514
|
+
results.push({
|
|
515
|
+
type: "search",
|
|
516
|
+
content: `Query: "${queryStr}" \u2192 ${files.slice(0, 5).join(", ")}${files.length > 5 ? ` (+${files.length - 5} more)` : ""}`
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
for (const [, mod] of this.state.modifiedFiles) {
|
|
521
|
+
if (mod.filePath.toLowerCase().includes(q)) {
|
|
522
|
+
results.push({
|
|
523
|
+
type: `file:${mod.status}`,
|
|
524
|
+
content: `${mod.status.toUpperCase()}: ${mod.filePath}`,
|
|
525
|
+
timestamp: mod.modifiedAt
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
for (const [task, failures] of this.state.failedFiles) {
|
|
530
|
+
for (const f of failures) {
|
|
531
|
+
if (task.toLowerCase().includes(q) || f.error.toLowerCase().includes(q)) {
|
|
532
|
+
results.push({
|
|
533
|
+
type: `failure:${task}`,
|
|
534
|
+
content: `[${f.resolved ? "RESOLVED" : "UNRESOLVED"}] ${task}: ${f.error.substring(0, 200)}`,
|
|
535
|
+
timestamp: f.timestamp
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
for (const [file, deps] of this.state.dependencyGraph) {
|
|
541
|
+
if (file.toLowerCase().includes(q)) {
|
|
542
|
+
results.push({
|
|
543
|
+
type: "dependency",
|
|
544
|
+
content: `${file} depends on: ${deps.join(", ")}`
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const memoriesDir = this.memoriesDir();
|
|
549
|
+
if (fs2.existsSync(memoriesDir)) {
|
|
550
|
+
try {
|
|
551
|
+
const files = fs2.readdirSync(memoriesDir);
|
|
552
|
+
for (const file of files) {
|
|
553
|
+
if (!file.endsWith(".md")) continue;
|
|
554
|
+
const filePath = path2.join(memoriesDir, file);
|
|
555
|
+
try {
|
|
556
|
+
const content = fs2.readFileSync(filePath, "utf-8");
|
|
557
|
+
const lines = content.split("\n");
|
|
558
|
+
for (let i = 0; i < lines.length; i++) {
|
|
559
|
+
if (lines[i].toLowerCase().includes(q)) {
|
|
560
|
+
const topicName = file.replace(/\.md$/, "");
|
|
561
|
+
results.push({
|
|
562
|
+
type: `memory:${topicName}`,
|
|
563
|
+
content: `[${topicName}] L${i + 1}: ${lines[i].trim().substring(0, 150)}`
|
|
564
|
+
});
|
|
565
|
+
if (results.filter((r) => r.type.startsWith("memory")).length >= 5) break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
} catch {
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
results.sort((a, b) => {
|
|
575
|
+
if (a.timestamp && b.timestamp) return b.timestamp - a.timestamp;
|
|
576
|
+
if (a.timestamp) return -1;
|
|
577
|
+
if (b.timestamp) return 1;
|
|
578
|
+
return 0;
|
|
579
|
+
});
|
|
580
|
+
return results.slice(0, limit);
|
|
581
|
+
}
|
|
582
|
+
pruneMemory() {
|
|
583
|
+
this.ensureInit();
|
|
584
|
+
this.state.toolCalls = this.state.toolCalls.slice(-3);
|
|
585
|
+
this.state.searchResults.clear();
|
|
586
|
+
this.state.completedSteps = [];
|
|
587
|
+
this.save();
|
|
588
|
+
}
|
|
589
|
+
getSummary(topic) {
|
|
590
|
+
this.ensureInit();
|
|
591
|
+
if (topic) {
|
|
592
|
+
const content = this.getMemoryContent(topic);
|
|
593
|
+
return { topic, content };
|
|
594
|
+
}
|
|
595
|
+
const unresolvedFailures = [];
|
|
596
|
+
const seen = /* @__PURE__ */ new Set();
|
|
597
|
+
for (const [, failures] of this.state.failedFiles) {
|
|
598
|
+
for (const f of failures) {
|
|
599
|
+
if (f.resolved) continue;
|
|
600
|
+
const error = f.error.substring(0, 200);
|
|
601
|
+
if (!error.trim()) continue;
|
|
602
|
+
const key = `${f.task}::${error}`;
|
|
603
|
+
if (seen.has(key)) continue;
|
|
604
|
+
seen.add(key);
|
|
605
|
+
unresolvedFailures.push({ task: f.task, error });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const hasMemories = fs2.existsSync(this.memoriesDir());
|
|
609
|
+
let availableMemories = [];
|
|
610
|
+
if (hasMemories) {
|
|
611
|
+
try {
|
|
612
|
+
availableMemories = fs2.readdirSync(this.memoriesDir()).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
613
|
+
} catch {
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
projectRoot: this.state.projectRoot,
|
|
618
|
+
sessionDuration: Math.round((Date.now() - this.state.startTime) / 1e3) + "s",
|
|
619
|
+
currentGoal: this.state.currentGoal,
|
|
620
|
+
completedSteps: this.state.completedSteps,
|
|
621
|
+
modifiedFiles: Array.from(this.state.modifiedFiles.values()),
|
|
622
|
+
unresolvedFailures,
|
|
623
|
+
toolCallCount: this.state.toolCalls.length,
|
|
624
|
+
recentSearches: Array.from(this.state.searchResults.keys()).slice(-5),
|
|
625
|
+
hasConventions: !!this.state.conventions,
|
|
626
|
+
...availableMemories.length > 0 ? { availableMemories } : {},
|
|
627
|
+
...availableMemories.length > 0 ? { hint: `Use get_session_memory({ topic: "<name>" }) to load a specific memory. Topics: ${availableMemories.join(", ")}` } : {}
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
getModifiedFiles() {
|
|
631
|
+
this.ensureInit();
|
|
632
|
+
return Array.from(this.state.modifiedFiles.values());
|
|
633
|
+
}
|
|
634
|
+
getFailedFiles() {
|
|
635
|
+
this.ensureInit();
|
|
636
|
+
const result = [];
|
|
637
|
+
for (const [task, failures] of this.state.failedFiles) {
|
|
638
|
+
result.push({ task, failures });
|
|
639
|
+
}
|
|
640
|
+
return result;
|
|
641
|
+
}
|
|
642
|
+
getToolCallHistory(limit = 10) {
|
|
643
|
+
this.ensureInit();
|
|
644
|
+
return this.state.toolCalls.slice(-limit);
|
|
645
|
+
}
|
|
646
|
+
detectLoop() {
|
|
647
|
+
this.ensureInit();
|
|
648
|
+
const recentCalls = this.state.toolCalls.slice(-10);
|
|
649
|
+
if (recentCalls.length < 6) return { isLooping: false };
|
|
650
|
+
const toolCounts = /* @__PURE__ */ new Map();
|
|
651
|
+
for (const call of recentCalls) {
|
|
652
|
+
toolCounts.set(call.toolName, (toolCounts.get(call.toolName) ?? 0) + 1);
|
|
653
|
+
}
|
|
654
|
+
for (const [toolName, count] of toolCounts) {
|
|
655
|
+
if (count >= 4) {
|
|
656
|
+
return {
|
|
657
|
+
isLooping: true,
|
|
658
|
+
toolName,
|
|
659
|
+
message: `Detected potential loop: "${toolName}" called ${count} times in last ${recentCalls.length} tool calls. Consider a different approach.`
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return { isLooping: false };
|
|
664
|
+
}
|
|
665
|
+
ensureInit() {
|
|
666
|
+
if (!this.initialized) {
|
|
667
|
+
this.init({
|
|
668
|
+
projectRoot: process.cwd(),
|
|
669
|
+
startTime: Date.now()
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
var sessionMemory = new SessionMemory();
|
|
675
|
+
function getSessionMemory(topic) {
|
|
676
|
+
return sessionMemory.getSummary(topic);
|
|
677
|
+
}
|
|
678
|
+
function searchSessionMemory(params) {
|
|
679
|
+
const { query, limit = 20 } = params;
|
|
680
|
+
sessionMemory.recordToolCall("search_session_memory", { query, limit });
|
|
681
|
+
const results = sessionMemory.searchMemory(query, limit);
|
|
682
|
+
if (results.length === 0) {
|
|
683
|
+
return `\u{1F50D} **Search Memory** \u2014 No results for "${query}".`;
|
|
684
|
+
}
|
|
685
|
+
const lines = [
|
|
686
|
+
`\u{1F50D} **Search Memory** \u2014 ${results.length} results for "${query}"`,
|
|
687
|
+
""
|
|
688
|
+
];
|
|
689
|
+
for (const r of results) {
|
|
690
|
+
const icon = r.type.startsWith("tool:") ? "\u{1F6E0}\uFE0F" : r.type.startsWith("file:") ? r.type.includes("created") ? "\u2728" : "\u{1F4DD}" : r.type.startsWith("failure") ? "\u274C" : r.type.startsWith("memory") ? "\u{1F9E0}" : r.type === "search" ? "\u{1F50E}" : r.type === "dependency" ? "\u{1F517}" : "\u{1F4C4}";
|
|
691
|
+
const timeStr = r.timestamp ? new Date(r.timestamp).toLocaleTimeString() : "";
|
|
692
|
+
lines.push(`${icon} ${timeStr ? `[${timeStr}] ` : ""}${r.content}`);
|
|
693
|
+
}
|
|
694
|
+
lines.push("", `\u{1F4A1} Use get_session_memory({topic: "..."}) to load a specific memory topic.`);
|
|
695
|
+
return lines.join("\n");
|
|
696
|
+
}
|
|
697
|
+
function handleWriteMemory(params) {
|
|
698
|
+
const { topic, content, mode = "append" } = params;
|
|
699
|
+
const existing = sessionMemory.getMemoryContent(topic);
|
|
700
|
+
let finalContent = content;
|
|
701
|
+
if (mode === "prepend") {
|
|
702
|
+
finalContent = content + "\n\n" + existing;
|
|
703
|
+
} else if (mode === "append") {
|
|
704
|
+
finalContent = existing + "\n\n" + content;
|
|
705
|
+
}
|
|
706
|
+
sessionMemory.writeMemory(topic, finalContent);
|
|
707
|
+
sessionMemory.recordToolCall("write_memory", { topic, mode });
|
|
708
|
+
return `\u2705 Memory "${topic}" updated (mode: ${mode}).`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/tools/smartGrep.ts
|
|
712
|
+
var IGNORE_PATTERNS = [
|
|
713
|
+
"**/node_modules/**",
|
|
714
|
+
"**/.next/**",
|
|
715
|
+
"**/dist/**",
|
|
716
|
+
"**/build/**",
|
|
717
|
+
"**/.git/**",
|
|
718
|
+
"**/.cache/**",
|
|
719
|
+
"**/.agent-backups/**",
|
|
720
|
+
"**/*.min.js",
|
|
721
|
+
"**/*.bundle.js",
|
|
722
|
+
"**/*.map",
|
|
723
|
+
"**/package-lock.json",
|
|
724
|
+
"**/yarn.lock",
|
|
725
|
+
"**/pnpm-lock.yaml",
|
|
726
|
+
"**/*.svg",
|
|
727
|
+
"**/*.png",
|
|
728
|
+
"**/*.jpg",
|
|
729
|
+
"**/*.jpeg",
|
|
730
|
+
"**/*.gif",
|
|
731
|
+
"**/*.ico",
|
|
732
|
+
"**/*.woff",
|
|
733
|
+
"**/*.woff2",
|
|
734
|
+
"**/*.ttf",
|
|
735
|
+
"**/*.eot",
|
|
736
|
+
"**/coverage/**",
|
|
737
|
+
"**/.nyc_output/**"
|
|
738
|
+
];
|
|
739
|
+
var grepCache = /* @__PURE__ */ new Map();
|
|
740
|
+
var CACHE_TTL_MS = 3e4;
|
|
741
|
+
async function handleSmartGrep(params) {
|
|
742
|
+
const { query, targetFolder, maxResults = 30, extensions } = params;
|
|
743
|
+
if (!query || query.length < 1) {
|
|
744
|
+
return "Error: 'query' parameter is required.";
|
|
745
|
+
}
|
|
746
|
+
const cacheKey = `${query}:${targetFolder ?? "root"}:${maxResults}:${extensions ? extensions.join(",") : ""}`;
|
|
747
|
+
const cached = grepCache.get(cacheKey);
|
|
748
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
749
|
+
sessionMemory.recordToolCall("smart_grep", { query, cached: true });
|
|
750
|
+
return cached.results;
|
|
751
|
+
}
|
|
752
|
+
const projectRoot = getProjectRoot();
|
|
753
|
+
try {
|
|
754
|
+
const searchPattern = targetFolder ? path3.join(targetFolder, "**/*").replace(/\\/g, "/") : "**/*";
|
|
755
|
+
let entries = await fg(searchPattern, {
|
|
756
|
+
cwd: projectRoot,
|
|
757
|
+
ignore: IGNORE_PATTERNS,
|
|
758
|
+
onlyFiles: true,
|
|
759
|
+
absolute: false,
|
|
760
|
+
deep: targetFolder ? 3 : 10,
|
|
761
|
+
// Batasi kedalaman
|
|
762
|
+
dot: false
|
|
763
|
+
// Skip dotfiles
|
|
764
|
+
});
|
|
765
|
+
if (extensions && extensions.length > 0) {
|
|
766
|
+
const normalizedExts = extensions.map(
|
|
767
|
+
(e) => e.startsWith(".") ? e.toLowerCase() : `.${e.toLowerCase()}`
|
|
768
|
+
);
|
|
769
|
+
entries = entries.filter((entry) => {
|
|
770
|
+
const ext = path3.extname(entry).toLowerCase();
|
|
771
|
+
return normalizedExts.includes(ext);
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
if (entries.length === 0) {
|
|
775
|
+
const msg = `No files found${targetFolder ? ` in folder "${targetFolder}"` : ""}${extensions ? ` with extensions [${extensions.join(", ")}]` : ""}.`;
|
|
776
|
+
grepCache.set(cacheKey, { results: msg, timestamp: Date.now() });
|
|
777
|
+
return msg;
|
|
778
|
+
}
|
|
779
|
+
const regex = createRegex(query);
|
|
780
|
+
const results = [];
|
|
781
|
+
let filesScanned = 0;
|
|
782
|
+
for (const entry of entries) {
|
|
783
|
+
if (results.length >= maxResults) break;
|
|
784
|
+
filesScanned++;
|
|
785
|
+
try {
|
|
786
|
+
const fullPath = path3.join(projectRoot, entry);
|
|
787
|
+
const stat = fs3.statSync(fullPath);
|
|
788
|
+
if (stat.size > 5e5) continue;
|
|
789
|
+
if (isBinaryFile(fullPath)) continue;
|
|
790
|
+
const content = fs3.readFileSync(fullPath, "utf-8");
|
|
791
|
+
const lines = content.split("\n");
|
|
792
|
+
for (let i = 0; i < lines.length; i++) {
|
|
793
|
+
if (results.length >= maxResults) break;
|
|
794
|
+
const line = lines[i];
|
|
795
|
+
if (regex.test(line)) {
|
|
796
|
+
const contextLines = [];
|
|
797
|
+
if (i > 0)
|
|
798
|
+
contextLines.push(`${i}: ${lines[i - 1].substring(0, 200)}`);
|
|
799
|
+
contextLines.push(`${i + 1}: ${line.substring(0, 200)}`);
|
|
800
|
+
if (i < lines.length - 1)
|
|
801
|
+
contextLines.push(`${i + 2}: ${lines[i + 1].substring(0, 200)}`);
|
|
802
|
+
results.push({
|
|
803
|
+
file: entry,
|
|
804
|
+
line: i + 1,
|
|
805
|
+
content: contextLines.join("\n")
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
} catch {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const formatted = formatResults(results, query, filesScanned);
|
|
814
|
+
grepCache.set(cacheKey, { results: formatted, timestamp: Date.now() });
|
|
815
|
+
sessionMemory.recordToolCall("smart_grep", {
|
|
816
|
+
query,
|
|
817
|
+
matchCount: results.length,
|
|
818
|
+
filesScanned
|
|
819
|
+
});
|
|
820
|
+
sessionMemory.addSearchResult(
|
|
821
|
+
query,
|
|
822
|
+
results.map((r) => r.file)
|
|
823
|
+
);
|
|
824
|
+
return formatted;
|
|
825
|
+
} catch (err) {
|
|
826
|
+
return `Error searching "${query}": ${err instanceof Error ? err.message : String(err)}`;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
function createRegex(query) {
|
|
830
|
+
try {
|
|
831
|
+
if (/[.\\+*?[\](){}^$|]/.test(query)) {
|
|
832
|
+
return new RegExp(query, "i");
|
|
833
|
+
}
|
|
834
|
+
} catch {
|
|
835
|
+
}
|
|
836
|
+
return new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
837
|
+
}
|
|
838
|
+
function formatResults(results, query, filesScanned) {
|
|
839
|
+
if (results.length === 0) {
|
|
840
|
+
return `\u{1F50D} No matches for "${query}" (${filesScanned} files scanned).`;
|
|
841
|
+
}
|
|
842
|
+
const lines = [
|
|
843
|
+
`\u{1F50D} Smart Grep: "${query}"`,
|
|
844
|
+
`\u{1F4C1} ${results.length} matches from ${filesScanned} files scanned`,
|
|
845
|
+
"",
|
|
846
|
+
...results.map((r, i) => {
|
|
847
|
+
const filePath = r.file;
|
|
848
|
+
return `[${i + 1}] \u{1F4C4} ${filePath}:${r.line}
|
|
849
|
+
${r.content.split("\n").map((l) => ` ${l}`).join("\n")}`;
|
|
850
|
+
}),
|
|
851
|
+
"",
|
|
852
|
+
`\u{1F4A1} Use smart_file_picker to open a specific file.`
|
|
853
|
+
];
|
|
854
|
+
return limitLines(lines.join("\n"), 150);
|
|
855
|
+
}
|
|
856
|
+
function isBinaryFile(filePath) {
|
|
857
|
+
try {
|
|
858
|
+
const buffer = Buffer.alloc(512);
|
|
859
|
+
const fd = fs3.openSync(filePath, "r");
|
|
860
|
+
const bytesRead = fs3.readSync(fd, buffer, 0, 512, 0);
|
|
861
|
+
fs3.closeSync(fd);
|
|
862
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
863
|
+
if (buffer[i] === 0) return true;
|
|
864
|
+
}
|
|
865
|
+
return false;
|
|
866
|
+
} catch {
|
|
867
|
+
return true;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// src/tools/smartFilePicker.ts
|
|
872
|
+
import fs4 from "fs";
|
|
873
|
+
import path4 from "path";
|
|
874
|
+
var MAX_FILE_SIZE = 1e6;
|
|
875
|
+
var CHUNK_THRESHOLD = 300;
|
|
876
|
+
async function handleSmartFilePicker(params) {
|
|
877
|
+
const { filePath, startLine, endLine, chunkStrategy = "smart" } = params;
|
|
878
|
+
const validation = validateFilePath(filePath);
|
|
879
|
+
if (!validation.valid) {
|
|
880
|
+
return "Error: " + validation.error.message;
|
|
881
|
+
}
|
|
882
|
+
const resolvedPath = validation.resolvedPath;
|
|
883
|
+
if (!fs4.existsSync(resolvedPath)) {
|
|
884
|
+
const projectRoot = getProjectRoot();
|
|
885
|
+
let suggestion;
|
|
886
|
+
if (!path4.isAbsolute(filePath)) {
|
|
887
|
+
const cwdPath = path4.resolve(process.cwd(), filePath);
|
|
888
|
+
suggestion = "Path resolved to: " + resolvedPath + "\nCWD: " + process.cwd() + "\nProject root: " + projectRoot + "\nHint: Try path relative to project root, or relative to CWD (" + cwdPath + ")\nTry smart_grep first to locate the correct file.";
|
|
889
|
+
} else {
|
|
890
|
+
suggestion = "File does not exist. Try smart_grep to locate it.";
|
|
891
|
+
}
|
|
892
|
+
return 'Error: File not found: "' + filePath + '".\n' + suggestion;
|
|
893
|
+
}
|
|
894
|
+
const stat = fs4.statSync(resolvedPath);
|
|
895
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
896
|
+
return "Error: File too large (" + (stat.size / 1024 / 1024).toFixed(1) + "MB). Max 1MB.\nUse smart_grep to find specific content instead.";
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
const content = fs4.readFileSync(resolvedPath, "utf-8");
|
|
900
|
+
const lines = content.split("\n");
|
|
901
|
+
const totalLines = lines.length;
|
|
902
|
+
sessionMemory.recordToolCall("smart_file_picker", { filePath, chunkStrategy, totalLines });
|
|
903
|
+
if (startLine !== void 0 || endLine !== void 0) {
|
|
904
|
+
const start = startLine ?? 1;
|
|
905
|
+
const end = endLine ?? totalLines;
|
|
906
|
+
const selectedLines = lines.slice(start - 1, end);
|
|
907
|
+
return formatOutput(filePath, selectedLines, start, totalLines, false);
|
|
908
|
+
}
|
|
909
|
+
if (totalLines <= CHUNK_THRESHOLD || chunkStrategy === "full") {
|
|
910
|
+
return formatOutput(filePath, lines, 1, totalLines, false);
|
|
911
|
+
}
|
|
912
|
+
switch (chunkStrategy) {
|
|
913
|
+
case "outline":
|
|
914
|
+
return handleOutlineStrategy(filePath, lines, totalLines);
|
|
915
|
+
case "smart":
|
|
916
|
+
return handleSmartStrategy(filePath, lines, totalLines);
|
|
917
|
+
default:
|
|
918
|
+
return formatOutput(filePath, lines.slice(0, CHUNK_THRESHOLD), 1, totalLines, true);
|
|
919
|
+
}
|
|
920
|
+
} catch (err) {
|
|
921
|
+
return 'Error reading file "' + filePath + '": ' + (err instanceof Error ? err.message : String(err));
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function formatOutput(filePath, lines, startLine, totalLines, truncated) {
|
|
925
|
+
const header = [
|
|
926
|
+
"File: " + filePath,
|
|
927
|
+
totalLines + " total lines",
|
|
928
|
+
truncated ? "Showing " + lines.length + " lines (file >" + CHUNK_THRESHOLD + " lines). Use startLine/endLine for a specific range." : "",
|
|
929
|
+
""
|
|
930
|
+
].filter(Boolean).join("\n");
|
|
931
|
+
const body = lines.map((line, i) => {
|
|
932
|
+
const lineNum = startLine + i;
|
|
933
|
+
return String(lineNum).padStart(4, " ") + " | " + line;
|
|
934
|
+
}).join("\n");
|
|
935
|
+
const full = header + "\n" + body;
|
|
936
|
+
return truncateToTokenLimit(full, 2e3);
|
|
937
|
+
}
|
|
938
|
+
async function handleOutlineStrategy(filePath, lines, totalLines) {
|
|
939
|
+
const importLines = [];
|
|
940
|
+
const exportLines = [];
|
|
941
|
+
for (let i = 0; i < lines.length; i++) {
|
|
942
|
+
const line = lines[i].trim();
|
|
943
|
+
if (line.startsWith("//") || line.startsWith("#") || line.startsWith("/*") || line.startsWith("*")) continue;
|
|
944
|
+
if (line.startsWith("import ") || line.startsWith("from ") || line.startsWith("require(")) {
|
|
945
|
+
importLines.push(line);
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (line.startsWith("export ") || line.startsWith("function ") || line.startsWith("const ") || line.startsWith("let ") || line.startsWith("var ") || line.startsWith("class ") || line.startsWith("interface ") || line.startsWith("type ") || line.startsWith("enum ") || line.startsWith("def ") || line.startsWith("async function ") || line.startsWith("async def ")) {
|
|
949
|
+
exportLines.push({ line: i + 1, text: line.substring(0, 150) });
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const result = [
|
|
953
|
+
"File: " + filePath,
|
|
954
|
+
totalLines + " total lines (OUTLINE MODE - signatures and imports only)",
|
|
955
|
+
"",
|
|
956
|
+
"Imports:",
|
|
957
|
+
...importLines.slice(0, 30).map((l) => " " + l.substring(0, 150)),
|
|
958
|
+
importLines.length > 30 ? " ...and " + (importLines.length - 30) + " more imports" : "",
|
|
959
|
+
"",
|
|
960
|
+
"Exports & Declarations:",
|
|
961
|
+
...exportLines.map((e) => " [L" + e.line + "] " + e.text),
|
|
962
|
+
"",
|
|
963
|
+
"Pass startLine/endLine to read a specific range.",
|
|
964
|
+
'Or use chunkStrategy: "full" to read the entire file.'
|
|
965
|
+
].filter(Boolean).join("\n");
|
|
966
|
+
return truncateToTokenLimit(result, 1500);
|
|
967
|
+
}
|
|
968
|
+
async function handleSmartStrategy(filePath, lines, totalLines) {
|
|
969
|
+
const headerEnd = Math.min(findHeaderEnd(lines), 50);
|
|
970
|
+
const tailStart = Math.max(totalLines - 30, headerEnd);
|
|
971
|
+
const smartLines = [];
|
|
972
|
+
for (let i = 0; i < headerEnd; i++) {
|
|
973
|
+
smartLines.push({ line: i + 1, text: lines[i] });
|
|
974
|
+
}
|
|
975
|
+
if (headerEnd < tailStart) {
|
|
976
|
+
smartLines.push({ line: -1, text: "" });
|
|
977
|
+
smartLines.push({ line: -1, text: " ... " + (tailStart - headerEnd) + " lines hidden ..." });
|
|
978
|
+
smartLines.push({ line: -1, text: "" });
|
|
979
|
+
}
|
|
980
|
+
const keyDeclarations = extractKeyDeclarations(lines.slice(headerEnd, tailStart));
|
|
981
|
+
for (const decl of keyDeclarations) {
|
|
982
|
+
smartLines.push({ line: decl.line + 1 + headerEnd, text: decl.text });
|
|
983
|
+
}
|
|
984
|
+
if (keyDeclarations.length > 0 && headerEnd < tailStart) {
|
|
985
|
+
smartLines.push({ line: -1, text: "" });
|
|
986
|
+
}
|
|
987
|
+
for (let i = tailStart; i < totalLines; i++) {
|
|
988
|
+
smartLines.push({ line: i + 1, text: lines[i] });
|
|
989
|
+
}
|
|
990
|
+
const header = [
|
|
991
|
+
"File: " + filePath,
|
|
992
|
+
totalLines + " total lines (SMART MODE - header + signatures + tail)",
|
|
993
|
+
"Pass startLine/endLine to read a specific range.",
|
|
994
|
+
'Or use chunkStrategy: "full" to read the entire file.',
|
|
995
|
+
""
|
|
996
|
+
].join("\n");
|
|
997
|
+
const body = smartLines.map((s) => {
|
|
998
|
+
if (s.line === -1) return s.text;
|
|
999
|
+
return String(s.line).padStart(4, " ") + " | " + s.text;
|
|
1000
|
+
}).join("\n");
|
|
1001
|
+
const full = header + "\n" + body;
|
|
1002
|
+
return truncateToTokenLimit(full, 3e3);
|
|
1003
|
+
}
|
|
1004
|
+
function findHeaderEnd(lines) {
|
|
1005
|
+
let lastImportLine = 0;
|
|
1006
|
+
for (let i = 0; i < Math.min(lines.length, 80); i++) {
|
|
1007
|
+
const line = lines[i].trim();
|
|
1008
|
+
if (line.startsWith("import ") || line.startsWith("from ") || line.startsWith("require(") || line.startsWith("//") || line.startsWith("#") || line.startsWith("/*") || line.startsWith("*") || line === "") {
|
|
1009
|
+
if (!line.startsWith("//") && !line.startsWith("#") && !line.startsWith("/*") && !line.startsWith("*") && line !== "") {
|
|
1010
|
+
lastImportLine = i;
|
|
1011
|
+
}
|
|
1012
|
+
} else {
|
|
1013
|
+
if (lastImportLine > 0) break;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return Math.max(lastImportLine + 1, 10);
|
|
1017
|
+
}
|
|
1018
|
+
function extractKeyDeclarations(lines) {
|
|
1019
|
+
const decls = [];
|
|
1020
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1021
|
+
const line = lines[i].trim();
|
|
1022
|
+
if (!line || line.startsWith("//") || line.startsWith("#")) continue;
|
|
1023
|
+
if (/^(export\s+)?(async\s+)?(function|class|interface|type|enum|const|let|var|def)\s/.test(line)) {
|
|
1024
|
+
decls.push({ line: i, text: line.substring(0, 150) });
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return decls;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// src/tools/preciseDiffEditor.ts
|
|
1031
|
+
import fs5 from "fs";
|
|
1032
|
+
import path5 from "path";
|
|
1033
|
+
|
|
1034
|
+
// src/utils/errorHandler.ts
|
|
1035
|
+
var CircuitBreakerStore = class {
|
|
1036
|
+
store = /* @__PURE__ */ new Map();
|
|
1037
|
+
MAX_ATTEMPTS = 3;
|
|
1038
|
+
RESET_TIMEOUT_MS = 6e4;
|
|
1039
|
+
// Reset after 1 min
|
|
1040
|
+
check(toolName, params) {
|
|
1041
|
+
const key = this.makeKey(toolName, params);
|
|
1042
|
+
const state = this.store.get(key);
|
|
1043
|
+
if (!state) {
|
|
1044
|
+
this.store.set(key, {
|
|
1045
|
+
toolName,
|
|
1046
|
+
attemptCount: 1,
|
|
1047
|
+
lastAttempt: /* @__PURE__ */ new Date(),
|
|
1048
|
+
lastParams: JSON.stringify(params),
|
|
1049
|
+
isOpen: false
|
|
1050
|
+
});
|
|
1051
|
+
return { allowed: true };
|
|
1052
|
+
}
|
|
1053
|
+
if (state.isOpen) {
|
|
1054
|
+
if (state.lastAttempt && Date.now() - state.lastAttempt.getTime() > this.RESET_TIMEOUT_MS) {
|
|
1055
|
+
this.store.delete(key);
|
|
1056
|
+
return { allowed: true };
|
|
1057
|
+
}
|
|
1058
|
+
return { allowed: false, reason: `Circuit breaker open for ${toolName} after ${state.attemptCount} attempts. Reset in ${Math.ceil((this.RESET_TIMEOUT_MS - (Date.now() - state.lastAttempt.getTime())) / 1e3)}s` };
|
|
1059
|
+
}
|
|
1060
|
+
state.attemptCount++;
|
|
1061
|
+
state.lastAttempt = /* @__PURE__ */ new Date();
|
|
1062
|
+
if (state.attemptCount >= this.MAX_ATTEMPTS) {
|
|
1063
|
+
state.isOpen = true;
|
|
1064
|
+
return { allowed: false, reason: `Circuit breaker TRIPPED for ${toolName} after ${state.attemptCount} identical attempts. Trying same approach again won't work.` };
|
|
1065
|
+
}
|
|
1066
|
+
return { allowed: true };
|
|
1067
|
+
}
|
|
1068
|
+
reset(toolName) {
|
|
1069
|
+
for (const [key, state] of this.store.entries()) {
|
|
1070
|
+
if (state.toolName === toolName) {
|
|
1071
|
+
this.store.delete(key);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
makeKey(toolName, params) {
|
|
1076
|
+
const simplified = { ...params };
|
|
1077
|
+
delete simplified.timestamp;
|
|
1078
|
+
return `${toolName}:${JSON.stringify(simplified)}`;
|
|
1079
|
+
}
|
|
1080
|
+
getAttemptCount(toolName, params) {
|
|
1081
|
+
const key = this.makeKey(toolName, params);
|
|
1082
|
+
return this.store.get(key)?.attemptCount ?? 0;
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
var circuitBreaker = new CircuitBreakerStore();
|
|
1086
|
+
|
|
1087
|
+
// src/tools/preciseDiffEditor.ts
|
|
1088
|
+
async function handlePreciseDiffEditor(params) {
|
|
1089
|
+
const { filePath, edits, dryRun = false, action } = params;
|
|
1090
|
+
if (action === "rollback") {
|
|
1091
|
+
return handleRollbackEdit({ filePath, version: params.version });
|
|
1092
|
+
}
|
|
1093
|
+
if (!edits || edits.length === 0) {
|
|
1094
|
+
return "Error: 'edits' required for edit mode, or use action: 'rollback' for rollback.";
|
|
1095
|
+
}
|
|
1096
|
+
const validation = validateFilePath(filePath);
|
|
1097
|
+
if (!validation.valid) {
|
|
1098
|
+
return `Error: ${validation.error.message}`;
|
|
1099
|
+
}
|
|
1100
|
+
const resolvedPath = validation.resolvedPath;
|
|
1101
|
+
const cbResult = circuitBreaker.check("precise_diff_editor", { filePath });
|
|
1102
|
+
if (!cbResult.allowed) {
|
|
1103
|
+
return `\u26A0\uFE0F ${cbResult.reason}
|
|
1104
|
+
|
|
1105
|
+
Try reading the file first with smart_file_picker to verify current content.`;
|
|
1106
|
+
}
|
|
1107
|
+
if (!fs5.existsSync(resolvedPath)) {
|
|
1108
|
+
return `Error: File not found: "${filePath}".
|
|
1109
|
+
Use batch_file_writer to create a new file.`;
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
const originalContent = fs5.readFileSync(resolvedPath, "utf-8");
|
|
1113
|
+
let currentContent = originalContent;
|
|
1114
|
+
const results = [];
|
|
1115
|
+
for (let i = 0; i < edits.length; i++) {
|
|
1116
|
+
const edit = edits[i];
|
|
1117
|
+
const result = applyEdit(currentContent, edit, resolvedPath, i, dryRun);
|
|
1118
|
+
if (result.success) {
|
|
1119
|
+
if (!dryRun) {
|
|
1120
|
+
fs5.writeFileSync(resolvedPath, result.details, "utf-8");
|
|
1121
|
+
sessionMemory.recordToolCall("precise_diff_editor", {
|
|
1122
|
+
filePath,
|
|
1123
|
+
editIndex: i,
|
|
1124
|
+
success: true,
|
|
1125
|
+
matched: result.matched
|
|
1126
|
+
});
|
|
1127
|
+
sessionMemory.addModifiedFile(filePath);
|
|
1128
|
+
}
|
|
1129
|
+
currentContent = result.details;
|
|
1130
|
+
}
|
|
1131
|
+
results.push(result);
|
|
1132
|
+
}
|
|
1133
|
+
if (dryRun) {
|
|
1134
|
+
return formatDryRunResult(results, filePath, originalContent);
|
|
1135
|
+
}
|
|
1136
|
+
return formatDiffResult(results, filePath);
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
return `Error editing file "${filePath}": ${err instanceof Error ? err.message : String(err)}`;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
function applyEdit(content, edit, filePath, editIndex, dryRun = false) {
|
|
1142
|
+
const { searchBlock, replaceBlock, allowMultiple = false, fuzzyThreshold = 0.85 } = edit;
|
|
1143
|
+
const exactCount = countOccurrences(content, searchBlock);
|
|
1144
|
+
if (exactCount > 0) {
|
|
1145
|
+
const backupPath = dryRun ? void 0 : createBackup(filePath);
|
|
1146
|
+
const newContent = allowMultiple ? replaceAll(content, searchBlock, replaceBlock) : content.replace(searchBlock, replaceBlock);
|
|
1147
|
+
return {
|
|
1148
|
+
success: true,
|
|
1149
|
+
matched: exactCount,
|
|
1150
|
+
replaced: allowMultiple ? exactCount : 1,
|
|
1151
|
+
backupPath,
|
|
1152
|
+
details: newContent
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
const normSearchLines = normalizeLines(searchBlock);
|
|
1156
|
+
const normContentLines = normalizeLines(content);
|
|
1157
|
+
const matchIndex = normContentLines.findIndex(
|
|
1158
|
+
(_, i) => i + normSearchLines.length <= normContentLines.length && normContentLines.slice(i, i + normSearchLines.length).every((line, j) => line === normSearchLines[j])
|
|
1159
|
+
);
|
|
1160
|
+
if (matchIndex >= 0) {
|
|
1161
|
+
const backupPath = dryRun ? void 0 : createBackup(filePath);
|
|
1162
|
+
const origLines = content.split("\n");
|
|
1163
|
+
const replaceLines = replaceBlock.split("\n");
|
|
1164
|
+
const newLines = [
|
|
1165
|
+
...origLines.slice(0, matchIndex),
|
|
1166
|
+
...replaceLines,
|
|
1167
|
+
...origLines.slice(matchIndex + normSearchLines.length)
|
|
1168
|
+
];
|
|
1169
|
+
const newContent = newLines.join("\n");
|
|
1170
|
+
return {
|
|
1171
|
+
success: true,
|
|
1172
|
+
matched: 1,
|
|
1173
|
+
replaced: 1,
|
|
1174
|
+
backupPath,
|
|
1175
|
+
details: newContent
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
const fuzzyResult = findFuzzyMatch(content, searchBlock, fuzzyThreshold);
|
|
1179
|
+
if (fuzzyResult) {
|
|
1180
|
+
const backupPath = dryRun ? void 0 : createBackup(filePath);
|
|
1181
|
+
const newContent = content.replace(fuzzyResult.match, replaceBlock);
|
|
1182
|
+
return {
|
|
1183
|
+
success: true,
|
|
1184
|
+
matched: 1,
|
|
1185
|
+
replaced: 1,
|
|
1186
|
+
backupPath,
|
|
1187
|
+
details: newContent
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
const nearestLine = findNearestLine(content, searchBlock);
|
|
1191
|
+
return {
|
|
1192
|
+
success: false,
|
|
1193
|
+
matched: 0,
|
|
1194
|
+
replaced: 0,
|
|
1195
|
+
error: `DIFF_MISMATCH: searchBlock not found in file "${filePath}" (edit #${editIndex + 1}).`,
|
|
1196
|
+
details: nearestLine ? `Nearest line with similar content found at line ${nearestLine.line}:
|
|
1197
|
+
\`\`\`
|
|
1198
|
+
${nearestLine.content}
|
|
1199
|
+
\`\`\`
|
|
1200
|
+
Check whether:
|
|
1201
|
+
1. Spacing/indentation matches (try 1:1 matching)
|
|
1202
|
+
2. searchBlock content is exactly the same as what's in the file
|
|
1203
|
+
3. File was not modified by a previous edit
|
|
1204
|
+
|
|
1205
|
+
\u{1F4A1} Read the file first with smart_file_picker to verify current content.` : "No similar lines found. The file may have completely different content than expected."
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
function countOccurrences(content, search) {
|
|
1209
|
+
if (search === "") return 0;
|
|
1210
|
+
let count = 0;
|
|
1211
|
+
let pos = 0;
|
|
1212
|
+
while (true) {
|
|
1213
|
+
pos = content.indexOf(search, pos);
|
|
1214
|
+
if (pos === -1) break;
|
|
1215
|
+
count++;
|
|
1216
|
+
pos += search.length;
|
|
1217
|
+
}
|
|
1218
|
+
return count;
|
|
1219
|
+
}
|
|
1220
|
+
function replaceAll(content, search, replace) {
|
|
1221
|
+
return content.split(search).join(replace);
|
|
1222
|
+
}
|
|
1223
|
+
function normalizeLines(text) {
|
|
1224
|
+
return text.split("\n").map((line) => line.trim().replace(/[ \t]+/g, " "));
|
|
1225
|
+
}
|
|
1226
|
+
function findFuzzyMatch(content, search, threshold) {
|
|
1227
|
+
const searchLines = search.split("\n").filter((l) => l.trim());
|
|
1228
|
+
const contentLines = content.split("\n");
|
|
1229
|
+
if (searchLines.length === 0) return null;
|
|
1230
|
+
const normalizedSearch = normalizeLines(search).join("\n");
|
|
1231
|
+
let bestMatch = null;
|
|
1232
|
+
let bestSimilarity = 0;
|
|
1233
|
+
for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
|
|
1234
|
+
const candidate = contentLines.slice(i, i + searchLines.length).join("\n");
|
|
1235
|
+
const normalizedCandidate = normalizeLines(candidate).join("\n");
|
|
1236
|
+
const similarity = calculateSimilarity(normalizedSearch, normalizedCandidate);
|
|
1237
|
+
if (similarity > bestSimilarity) {
|
|
1238
|
+
bestSimilarity = similarity;
|
|
1239
|
+
bestMatch = candidate;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
if (bestMatch && bestSimilarity >= threshold) {
|
|
1243
|
+
return { match: bestMatch, similarity: bestSimilarity };
|
|
1244
|
+
}
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
function calculateSimilarity(a, b) {
|
|
1248
|
+
const longer = a.length > b.length ? a : b;
|
|
1249
|
+
const shorter = a.length > b.length ? b : a;
|
|
1250
|
+
if (longer.length === 0) return 1;
|
|
1251
|
+
const distance = levenshteinDistance(longer, shorter);
|
|
1252
|
+
return 1 - distance / longer.length;
|
|
1253
|
+
}
|
|
1254
|
+
function levenshteinDistance(a, b) {
|
|
1255
|
+
const matrix = [];
|
|
1256
|
+
for (let i = 0; i <= b.length; i++) {
|
|
1257
|
+
matrix[i] = [i];
|
|
1258
|
+
}
|
|
1259
|
+
for (let j = 0; j <= a.length; j++) {
|
|
1260
|
+
matrix[0][j] = j;
|
|
1261
|
+
}
|
|
1262
|
+
for (let i = 1; i <= b.length; i++) {
|
|
1263
|
+
for (let j = 1; j <= a.length; j++) {
|
|
1264
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
1265
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
1266
|
+
} else {
|
|
1267
|
+
matrix[i][j] = Math.min(
|
|
1268
|
+
matrix[i - 1][j - 1] + 1,
|
|
1269
|
+
// substitution
|
|
1270
|
+
matrix[i][j - 1] + 1,
|
|
1271
|
+
// insertion
|
|
1272
|
+
matrix[i - 1][j] + 1
|
|
1273
|
+
// deletion
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return matrix[b.length][a.length];
|
|
1279
|
+
}
|
|
1280
|
+
function findNearestLine(content, search) {
|
|
1281
|
+
const lines = content.split("\n");
|
|
1282
|
+
const searchTrimmed = search.trim();
|
|
1283
|
+
const searchWords = new Set(searchTrimmed.split(/\s+/).filter((w) => w.length > 3));
|
|
1284
|
+
if (searchWords.size < 2) return null;
|
|
1285
|
+
let bestLine = -1;
|
|
1286
|
+
let bestScore = 0;
|
|
1287
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1288
|
+
const lineWords = new Set(lines[i].split(/\s+/).filter((w) => w.length > 3));
|
|
1289
|
+
let matchCount = 0;
|
|
1290
|
+
for (const word of searchWords) {
|
|
1291
|
+
if (lineWords.has(word)) matchCount++;
|
|
1292
|
+
}
|
|
1293
|
+
const score = matchCount / searchWords.size;
|
|
1294
|
+
if (score > bestScore) {
|
|
1295
|
+
bestScore = score;
|
|
1296
|
+
bestLine = i;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (bestLine >= 0 && bestScore > 0.3) {
|
|
1300
|
+
return {
|
|
1301
|
+
line: bestLine + 1,
|
|
1302
|
+
content: lines[bestLine].substring(0, 300)
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
return null;
|
|
1306
|
+
}
|
|
1307
|
+
function createBackup(filePath) {
|
|
1308
|
+
ensureBackupDir();
|
|
1309
|
+
const backupPath = getBackupPath(filePath);
|
|
1310
|
+
const backupDir = path5.dirname(backupPath);
|
|
1311
|
+
fs5.mkdirSync(backupDir, { recursive: true });
|
|
1312
|
+
fs5.copyFileSync(filePath, backupPath);
|
|
1313
|
+
return backupPath;
|
|
1314
|
+
}
|
|
1315
|
+
function formatDryRunResult(results, filePath, originalContent) {
|
|
1316
|
+
const lines = [
|
|
1317
|
+
`\u{1F50D} **DRY RUN** \u2014 ${filePath}`,
|
|
1318
|
+
`\u26A0\uFE0F File not modified (dryRun=true). Preview of changes from ${results.length} edits:`,
|
|
1319
|
+
""
|
|
1320
|
+
];
|
|
1321
|
+
let prevContent = originalContent;
|
|
1322
|
+
for (let i = 0; i < results.length; i++) {
|
|
1323
|
+
const r = results[i];
|
|
1324
|
+
if (r.success) {
|
|
1325
|
+
lines.push(`**Edit #${i + 1}:** \u2705 Matched: ${r.matched}x`);
|
|
1326
|
+
const prevLines = prevContent.split("\n");
|
|
1327
|
+
const newLines = r.details.split("\n");
|
|
1328
|
+
let diffStart = 0;
|
|
1329
|
+
while (diffStart < prevLines.length && diffStart < newLines.length && prevLines[diffStart] === newLines[diffStart]) {
|
|
1330
|
+
diffStart++;
|
|
1331
|
+
}
|
|
1332
|
+
let pEnd = prevLines.length - 1;
|
|
1333
|
+
let nEnd = newLines.length - 1;
|
|
1334
|
+
while (pEnd > diffStart && nEnd > diffStart && prevLines[pEnd] === newLines[nEnd]) {
|
|
1335
|
+
pEnd--;
|
|
1336
|
+
nEnd--;
|
|
1337
|
+
}
|
|
1338
|
+
lines.push("```diff");
|
|
1339
|
+
if (diffStart > pEnd && diffStart > nEnd) {
|
|
1340
|
+
lines.push(" (no visible change)");
|
|
1341
|
+
} else {
|
|
1342
|
+
const contextStart = Math.max(0, diffStart - 1);
|
|
1343
|
+
const contextEnd = Math.min(newLines.length, nEnd + 2);
|
|
1344
|
+
if (diffStart <= pEnd) {
|
|
1345
|
+
for (let j = contextStart; j <= pEnd; j++) {
|
|
1346
|
+
if (j < diffStart) {
|
|
1347
|
+
lines.push(` ${prevLines[j]}`);
|
|
1348
|
+
} else {
|
|
1349
|
+
lines.push(`- ${prevLines[j]}`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (diffStart <= nEnd) {
|
|
1354
|
+
for (let j = diffStart; j < contextEnd && j < newLines.length; j++) {
|
|
1355
|
+
if (j <= nEnd) {
|
|
1356
|
+
lines.push(`+ ${newLines[j]}`);
|
|
1357
|
+
} else {
|
|
1358
|
+
lines.push(` ${newLines[j]}`);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
lines.push("```");
|
|
1364
|
+
lines.push("");
|
|
1365
|
+
prevContent = r.details;
|
|
1366
|
+
} else {
|
|
1367
|
+
lines.push(`**Edit #${i + 1}:** \u274C ${r.error}`, "");
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const finalLines = prevContent.split("\n");
|
|
1371
|
+
const origLineCount = originalContent.split("\n").length;
|
|
1372
|
+
const diff = finalLines.length - origLineCount;
|
|
1373
|
+
const sign = diff >= 0 ? "+" : "";
|
|
1374
|
+
lines.push(`\u{1F4CA} **Net change:** ${sign}${diff} lines (${origLineCount} \u2192 ${finalLines.length})`);
|
|
1375
|
+
lines.push(
|
|
1376
|
+
"",
|
|
1377
|
+
`\u{1F4A1} **Remove dryRun** or set \`dryRun: false\` to write changes to file.`
|
|
1378
|
+
);
|
|
1379
|
+
return lines.join("\n");
|
|
1380
|
+
}
|
|
1381
|
+
function formatDiffResult(results, filePath) {
|
|
1382
|
+
const successCount = results.filter((r) => r.success).length;
|
|
1383
|
+
const failCount = results.filter((r) => !r.success).length;
|
|
1384
|
+
const history = sessionMemory.getToolCallHistory(50);
|
|
1385
|
+
const callCount = history.filter((c) => c.toolName === "precise_diff_editor").length;
|
|
1386
|
+
const isFirstEdit = callCount <= 1;
|
|
1387
|
+
const lines = [
|
|
1388
|
+
...isFirstEdit ? ["\u{1F4A1} **The Ladder:** 1. Does this code need to exist? 2. Does stdlib cover it? 3. Is there a one-liner? 4. Only then, write it.\n"] : [],
|
|
1389
|
+
`\u{1F4DD} Diff Editor \u2014 ${filePath}`,
|
|
1390
|
+
`\u2705 ${successCount} edits successful | \u274C ${failCount} edits failed`,
|
|
1391
|
+
""
|
|
1392
|
+
];
|
|
1393
|
+
for (let i = 0; i < results.length; i++) {
|
|
1394
|
+
const r = results[i];
|
|
1395
|
+
if (r.success) {
|
|
1396
|
+
lines.push(`[${i + 1}] \u2705 Matched: ${r.matched}x, Replaced: ${r.replaced}x`);
|
|
1397
|
+
if (r.backupPath) {
|
|
1398
|
+
const relativePath = path5.relative(getProjectRoot(), r.backupPath);
|
|
1399
|
+
lines.push(` Backup: ${relativePath}`);
|
|
1400
|
+
}
|
|
1401
|
+
} else {
|
|
1402
|
+
lines.push(`[${i + 1}] \u274C ${r.error}`);
|
|
1403
|
+
if (r.details) {
|
|
1404
|
+
lines.push(` ${r.details.replace(/\n/g, "\n ")}`);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
lines.push(
|
|
1409
|
+
"",
|
|
1410
|
+
`\u{1F4A1} Use smart_file_picker to read the file and verify edit results.`,
|
|
1411
|
+
`\u{1F4A1} Or execute_safe_test({task: "typecheck"}) to check if edits broke anything.`
|
|
1412
|
+
);
|
|
1413
|
+
return lines.join("\n");
|
|
1414
|
+
}
|
|
1415
|
+
function formatRelativeTime(timestamp) {
|
|
1416
|
+
const diff = Date.now() - timestamp;
|
|
1417
|
+
const minutes = Math.floor(diff / 6e4);
|
|
1418
|
+
if (minutes < 1) return "just now";
|
|
1419
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
1420
|
+
const hours = Math.floor(minutes / 60);
|
|
1421
|
+
if (hours < 24) return `${hours}h ago`;
|
|
1422
|
+
const days = Math.floor(hours / 24);
|
|
1423
|
+
return `${days}d ago`;
|
|
1424
|
+
}
|
|
1425
|
+
async function handleRollbackEdit(params) {
|
|
1426
|
+
const { filePath, version } = params;
|
|
1427
|
+
const validation = validateFilePath(filePath);
|
|
1428
|
+
if (!validation.valid) {
|
|
1429
|
+
return `Error: ${validation.error.message}`;
|
|
1430
|
+
}
|
|
1431
|
+
const resolvedPath = validation.resolvedPath;
|
|
1432
|
+
const root = getProjectRoot();
|
|
1433
|
+
const relativePath = path5.relative(root, resolvedPath);
|
|
1434
|
+
const backupRoot = path5.join(root, ".agent-backups");
|
|
1435
|
+
if (!fs5.existsSync(backupRoot)) {
|
|
1436
|
+
return `Error: No backup folder (.agent-backups) found in project root.`;
|
|
1437
|
+
}
|
|
1438
|
+
try {
|
|
1439
|
+
const dirs = fs5.readdirSync(backupRoot).filter((name) => {
|
|
1440
|
+
const fullPath = path5.join(backupRoot, name);
|
|
1441
|
+
return fs5.statSync(fullPath).isDirectory() && /^\d+$/.test(name);
|
|
1442
|
+
});
|
|
1443
|
+
if (dirs.length === 0) {
|
|
1444
|
+
return `Error: No valid backup found in .agent-backups folder.`;
|
|
1445
|
+
}
|
|
1446
|
+
dirs.sort((a, b) => Number(b) - Number(a));
|
|
1447
|
+
const backupVersions = [];
|
|
1448
|
+
for (const dir of dirs) {
|
|
1449
|
+
const potentialBackupPath = path5.join(backupRoot, dir, relativePath);
|
|
1450
|
+
if (fs5.existsSync(potentialBackupPath)) {
|
|
1451
|
+
backupVersions.push({ dir, backupPath: potentialBackupPath });
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
if (backupVersions.length === 0) {
|
|
1455
|
+
return `Error: No backup history found for file "${filePath}".`;
|
|
1456
|
+
}
|
|
1457
|
+
if (version === "list") {
|
|
1458
|
+
const lines = [`\u{1F4CB} Backup Versions for "${filePath}":`];
|
|
1459
|
+
backupVersions.forEach((v, i) => {
|
|
1460
|
+
const ts = Number(v.dir);
|
|
1461
|
+
const date = new Date(ts).toISOString();
|
|
1462
|
+
const relative = formatRelativeTime(ts);
|
|
1463
|
+
lines.push(` [${i + 1}] ${date} (${relative})`);
|
|
1464
|
+
});
|
|
1465
|
+
lines.push("");
|
|
1466
|
+
lines.push(`\u{1F4A1} Use rollback_last_edit({ filePath: "${filePath}", version: <N> }) to restore a specific version.`);
|
|
1467
|
+
return lines.join("\n");
|
|
1468
|
+
}
|
|
1469
|
+
let selectedIndex = 0;
|
|
1470
|
+
if (typeof version === "number") {
|
|
1471
|
+
if (version < 1 || version > backupVersions.length) {
|
|
1472
|
+
return `Error: Version ${version} is invalid. ${backupVersions.length} backups available (1-${backupVersions.length}).`;
|
|
1473
|
+
}
|
|
1474
|
+
selectedIndex = version - 1;
|
|
1475
|
+
}
|
|
1476
|
+
const selected = backupVersions[selectedIndex];
|
|
1477
|
+
fs5.copyFileSync(selected.backupPath, resolvedPath);
|
|
1478
|
+
sessionMemory.recordToolCall("rollback_last_edit", {
|
|
1479
|
+
filePath,
|
|
1480
|
+
backupTimestamp: selected.dir,
|
|
1481
|
+
version: version ?? 1,
|
|
1482
|
+
success: true
|
|
1483
|
+
});
|
|
1484
|
+
const relBackupPath = path5.relative(root, selected.backupPath);
|
|
1485
|
+
return `\u2705 Rollback Successful!
|
|
1486
|
+
File "${filePath}" restored from backup: "${relBackupPath}".`;
|
|
1487
|
+
} catch (err) {
|
|
1488
|
+
return `Error performing rollback for "${filePath}": ${err instanceof Error ? err.message : String(err)}`;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// src/tools/batchFileWriter.ts
|
|
1493
|
+
import fs6 from "fs";
|
|
1494
|
+
import path6 from "path";
|
|
1495
|
+
async function handleBatchFileWriter(params) {
|
|
1496
|
+
const { files } = params;
|
|
1497
|
+
if (files.length > 15) {
|
|
1498
|
+
return `Error: Maximum 15 files per batch. You sent ${files.length} files.`;
|
|
1499
|
+
}
|
|
1500
|
+
const results = [];
|
|
1501
|
+
for (const file of files) {
|
|
1502
|
+
if (!file.content || file.content.trim().length === 0) {
|
|
1503
|
+
results.push({ filePath: file.filePath, success: false, error: "File content must not be empty." });
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
if (!file.instructions || file.instructions.trim().length === 0) {
|
|
1507
|
+
results.push({ filePath: file.filePath, success: false, error: "File creation reason (instructions) is required." });
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
try {
|
|
1511
|
+
const validation = validateFilePath(file.filePath);
|
|
1512
|
+
if (!validation.valid) {
|
|
1513
|
+
results.push({ filePath: file.filePath, success: false, error: validation.error.message });
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
const resolvedPath = validation.resolvedPath;
|
|
1517
|
+
if (!validateFileExtension(file.filePath)) {
|
|
1518
|
+
results.push({
|
|
1519
|
+
filePath: file.filePath,
|
|
1520
|
+
success: false,
|
|
1521
|
+
error: `File extension not allowed: "${path6.extname(file.filePath)}". Allowed extensions: .ts, .js, .tsx, .jsx, .json, .md, .css, .html, .yml, .yaml, .toml, .sh, .env`
|
|
1522
|
+
});
|
|
1523
|
+
continue;
|
|
1524
|
+
}
|
|
1525
|
+
if (fs6.existsSync(resolvedPath)) {
|
|
1526
|
+
ensureBackupDir();
|
|
1527
|
+
const backupPath = getBackupPath(file.filePath);
|
|
1528
|
+
const backupDir = path6.dirname(backupPath);
|
|
1529
|
+
fs6.mkdirSync(backupDir, { recursive: true });
|
|
1530
|
+
fs6.copyFileSync(resolvedPath, backupPath);
|
|
1531
|
+
}
|
|
1532
|
+
const dir = path6.dirname(resolvedPath);
|
|
1533
|
+
if (!fs6.existsSync(dir)) {
|
|
1534
|
+
fs6.mkdirSync(dir, { recursive: true });
|
|
1535
|
+
}
|
|
1536
|
+
fs6.writeFileSync(resolvedPath, file.content, "utf-8");
|
|
1537
|
+
sessionMemory.recordToolCall("batch_file_writer", {
|
|
1538
|
+
filePath: file.filePath,
|
|
1539
|
+
instructions: file.instructions,
|
|
1540
|
+
size: file.content.length
|
|
1541
|
+
});
|
|
1542
|
+
results.push({ filePath: file.filePath, success: true });
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
results.push({
|
|
1545
|
+
filePath: file.filePath,
|
|
1546
|
+
success: false,
|
|
1547
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
const totalRequested = files.length;
|
|
1552
|
+
return formatBatchResult(results, totalRequested);
|
|
1553
|
+
}
|
|
1554
|
+
function formatBatchResult(results, totalRequested) {
|
|
1555
|
+
const successCount = results.filter((r) => r.success).length;
|
|
1556
|
+
const failCount = results.filter((r) => !r.success).length;
|
|
1557
|
+
const lines = [
|
|
1558
|
+
...totalRequested && totalRequested > 3 ? [`\u{1F4A1} **The Ladder:** Are all ${totalRequested} files needed? Could any be combined or skipped?
|
|
1559
|
+
`] : [],
|
|
1560
|
+
`\u{1F4DD} Batch File Writer`,
|
|
1561
|
+
`\u2705 ${successCount} files created | \u274C ${failCount} files failed`,
|
|
1562
|
+
""
|
|
1563
|
+
];
|
|
1564
|
+
for (const r of results) {
|
|
1565
|
+
if (r.success) {
|
|
1566
|
+
lines.push(`\u2705 ${r.filePath} \u2014 created successfully`);
|
|
1567
|
+
} else {
|
|
1568
|
+
lines.push(`\u274C ${r.filePath} \u2014 ${r.error}`);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (failCount === 0 && successCount > 0) {
|
|
1572
|
+
lines.push("", "\u2705 All files created successfully.");
|
|
1573
|
+
lines.push('\u{1F4A1} Run execute_safe_test({task: "typecheck"}) to verify.');
|
|
1574
|
+
}
|
|
1575
|
+
return lines.join("\n");
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// src/tools/safeTerminalExec.ts
|
|
1579
|
+
import { spawn } from "child_process";
|
|
1580
|
+
var TASK_COMMANDS = {
|
|
1581
|
+
test: "npm test",
|
|
1582
|
+
build: "npm run build",
|
|
1583
|
+
lint: "npm run lint",
|
|
1584
|
+
typecheck: "npx tsc --noEmit"
|
|
1585
|
+
};
|
|
1586
|
+
var DANGEROUS_PATTERNS = [
|
|
1587
|
+
"rm -rf",
|
|
1588
|
+
"rm -fr",
|
|
1589
|
+
"del /f",
|
|
1590
|
+
"rd /s",
|
|
1591
|
+
"rmdir /s",
|
|
1592
|
+
"git push",
|
|
1593
|
+
"git commit",
|
|
1594
|
+
"npm publish",
|
|
1595
|
+
"npx publish",
|
|
1596
|
+
"yarn publish",
|
|
1597
|
+
"pnpm publish",
|
|
1598
|
+
"> /dev/sda",
|
|
1599
|
+
"format",
|
|
1600
|
+
"mkfs",
|
|
1601
|
+
"dd if=",
|
|
1602
|
+
":(){ :|:& };:",
|
|
1603
|
+
// fork bomb
|
|
1604
|
+
"curl ",
|
|
1605
|
+
"wget "
|
|
1606
|
+
];
|
|
1607
|
+
async function handleSafeTerminalExec(params) {
|
|
1608
|
+
const { task, customCommand, timeout = 60 } = params;
|
|
1609
|
+
if (task === "custom" && !customCommand) {
|
|
1610
|
+
return "Error: Task 'custom' requires the 'customCommand' parameter.";
|
|
1611
|
+
}
|
|
1612
|
+
let command;
|
|
1613
|
+
if (task === "custom") {
|
|
1614
|
+
command = customCommand;
|
|
1615
|
+
} else {
|
|
1616
|
+
command = TASK_COMMANDS[task];
|
|
1617
|
+
}
|
|
1618
|
+
const cbResult = circuitBreaker.check("safe_terminal_exec", { task, command });
|
|
1619
|
+
if (!cbResult.allowed) {
|
|
1620
|
+
return `\u26A0\uFE0F Circuit breaker: ${cbResult.reason}
|
|
1621
|
+
|
|
1622
|
+
Fix the code first before running the task again.`;
|
|
1623
|
+
}
|
|
1624
|
+
const dangerousPattern = DANGEROUS_PATTERNS.find((p) => command.toLowerCase().includes(p.toLowerCase()));
|
|
1625
|
+
if (dangerousPattern) {
|
|
1626
|
+
return `\u{1F6AB} BLOCKED: Command contains a dangerous pattern: "${dangerousPattern}".
|
|
1627
|
+
This command is not permitted.`;
|
|
1628
|
+
}
|
|
1629
|
+
const projectRoot = getProjectRoot();
|
|
1630
|
+
try {
|
|
1631
|
+
sessionMemory.recordToolCall("execute_safe_test", { task, command });
|
|
1632
|
+
const result = await executeWithTimeout(command, projectRoot, timeout);
|
|
1633
|
+
const output = formatExecResult(result, command, task);
|
|
1634
|
+
if (result.exitCode !== 0) {
|
|
1635
|
+
sessionMemory.addFailedFile(task, result.stderr || result.stdout);
|
|
1636
|
+
}
|
|
1637
|
+
return output;
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1640
|
+
const isTimeout = errorMsg.toLowerCase().includes("timeout");
|
|
1641
|
+
if (isTimeout) {
|
|
1642
|
+
return formatTimeoutResult(command, timeout);
|
|
1643
|
+
}
|
|
1644
|
+
return `Error running "${command}": ${errorMsg}`;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
async function executeWithTimeout(command, cwd, timeoutSeconds) {
|
|
1648
|
+
return new Promise((resolve, reject) => {
|
|
1649
|
+
const parts = command.split(" ");
|
|
1650
|
+
const cmd = parts[0];
|
|
1651
|
+
const args = parts.slice(1);
|
|
1652
|
+
const proc = spawn(cmd, args, {
|
|
1653
|
+
cwd,
|
|
1654
|
+
shell: process.platform === "win32",
|
|
1655
|
+
// Use shell on Windows
|
|
1656
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1657
|
+
timeout: timeoutSeconds * 1e3
|
|
1658
|
+
});
|
|
1659
|
+
let stdout = "";
|
|
1660
|
+
let stderr = "";
|
|
1661
|
+
let timedOut = false;
|
|
1662
|
+
const timeoutId = setTimeout(() => {
|
|
1663
|
+
timedOut = true;
|
|
1664
|
+
proc.kill("SIGTERM");
|
|
1665
|
+
if (process.platform === "win32") {
|
|
1666
|
+
try {
|
|
1667
|
+
spawn("taskkill", ["/pid", String(proc.pid), "/f", "/t"], { stdio: "ignore" });
|
|
1668
|
+
} catch {
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}, timeoutSeconds * 1e3);
|
|
1672
|
+
proc.stdout?.on("data", (data) => {
|
|
1673
|
+
stdout += data.toString();
|
|
1674
|
+
});
|
|
1675
|
+
proc.stderr?.on("data", (data) => {
|
|
1676
|
+
stderr += data.toString();
|
|
1677
|
+
});
|
|
1678
|
+
proc.on("close", (code) => {
|
|
1679
|
+
clearTimeout(timeoutId);
|
|
1680
|
+
resolve({
|
|
1681
|
+
stdout: truncateOutput(stdout, 5e3),
|
|
1682
|
+
stderr: truncateOutput(stderr, 2e3),
|
|
1683
|
+
exitCode: code ?? -1,
|
|
1684
|
+
timedOut
|
|
1685
|
+
});
|
|
1686
|
+
});
|
|
1687
|
+
proc.on("error", (err) => {
|
|
1688
|
+
clearTimeout(timeoutId);
|
|
1689
|
+
reject(err);
|
|
1690
|
+
});
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
function truncateOutput(output, maxChars) {
|
|
1694
|
+
if (output.length <= maxChars) return output;
|
|
1695
|
+
return output.slice(0, maxChars) + `
|
|
1696
|
+
|
|
1697
|
+
[...truncated, ${output.length - maxChars} more characters]`;
|
|
1698
|
+
}
|
|
1699
|
+
function formatExecResult(result, command, task) {
|
|
1700
|
+
const status = result.exitCode === 0 ? "\u2705 PASS" : "\u274C FAIL";
|
|
1701
|
+
const lines = [
|
|
1702
|
+
`\u{1F4BB} ${status} \u2014 Task: ${task}`,
|
|
1703
|
+
`$ ${command}`,
|
|
1704
|
+
`Exit code: ${result.exitCode}`,
|
|
1705
|
+
`Duration: ${result.timedOut ? "TIMEOUT" : "completed"}`,
|
|
1706
|
+
""
|
|
1707
|
+
];
|
|
1708
|
+
if (result.stdout.trim()) {
|
|
1709
|
+
lines.push("\u{1F4E4} STDOUT:", "```", result.stdout, "```", "");
|
|
1710
|
+
}
|
|
1711
|
+
if (result.stderr.trim()) {
|
|
1712
|
+
lines.push("\u{1F4E4} STDERR:", "```", result.stderr, "```", "");
|
|
1713
|
+
}
|
|
1714
|
+
if (result.exitCode !== 0) {
|
|
1715
|
+
lines.push(
|
|
1716
|
+
"\u{1F4A1} Recovery steps:",
|
|
1717
|
+
" 1. Read the error above \u2014 which file is failing?",
|
|
1718
|
+
" 2. Use smart_file_picker to open the failing file",
|
|
1719
|
+
" 3. Fix it with precise_diff_editor",
|
|
1720
|
+
" 4. Re-run the task to verify"
|
|
1721
|
+
);
|
|
1722
|
+
} else {
|
|
1723
|
+
lines.push("\u2705 All checks passed.");
|
|
1724
|
+
}
|
|
1725
|
+
return lines.join("\n");
|
|
1726
|
+
}
|
|
1727
|
+
function formatTimeoutResult(command, timeout) {
|
|
1728
|
+
return [
|
|
1729
|
+
`\u23F0 TIMEOUT \u2014 "${command}" exceeded the ${timeout}s limit.`,
|
|
1730
|
+
"",
|
|
1731
|
+
"Possible causes:",
|
|
1732
|
+
" 1. Infinite loop in code",
|
|
1733
|
+
" 2. Test suite too slow (needs optimization)",
|
|
1734
|
+
" 3. Background process blocking",
|
|
1735
|
+
"",
|
|
1736
|
+
"Suggestions:",
|
|
1737
|
+
" - Inspect the code for infinite loops",
|
|
1738
|
+
" - Increase the timeout (max 180s)",
|
|
1739
|
+
" - Run the command manually for diagnostics"
|
|
1740
|
+
].join("\n");
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// src/agents/codeReviewer.ts
|
|
1744
|
+
import fs7 from "fs";
|
|
1745
|
+
import path7 from "path";
|
|
1746
|
+
import { execSync } from "child_process";
|
|
1747
|
+
var CODE_EXTENSIONS = [
|
|
1748
|
+
".ts",
|
|
1749
|
+
".tsx",
|
|
1750
|
+
".js",
|
|
1751
|
+
".jsx",
|
|
1752
|
+
".mjs",
|
|
1753
|
+
".cjs",
|
|
1754
|
+
".py",
|
|
1755
|
+
".go",
|
|
1756
|
+
".rs",
|
|
1757
|
+
".php",
|
|
1758
|
+
".cs",
|
|
1759
|
+
".java"
|
|
1760
|
+
];
|
|
1761
|
+
function getGitChangedFiles() {
|
|
1762
|
+
try {
|
|
1763
|
+
const root = getProjectRoot();
|
|
1764
|
+
const stdout = execSync("git status --porcelain", {
|
|
1765
|
+
cwd: root,
|
|
1766
|
+
encoding: "utf-8"
|
|
1767
|
+
});
|
|
1768
|
+
const files = [];
|
|
1769
|
+
for (const line of stdout.split("\n")) {
|
|
1770
|
+
const trimmed = line.trim();
|
|
1771
|
+
if (!trimmed) continue;
|
|
1772
|
+
const parts = trimmed.split(/\s+/);
|
|
1773
|
+
let filePath = parts.slice(1).join(" ");
|
|
1774
|
+
if (filePath.startsWith('"') && filePath.endsWith('"')) {
|
|
1775
|
+
filePath = filePath.substring(1, filePath.length - 1);
|
|
1776
|
+
}
|
|
1777
|
+
const ext = path7.extname(filePath).toLowerCase();
|
|
1778
|
+
if (CODE_EXTENSIONS.includes(ext)) files.push(filePath);
|
|
1779
|
+
}
|
|
1780
|
+
return files.slice(0, 10);
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
console.error(`[CodeReviewer] Failed to list git-changed files: ${err}`);
|
|
1783
|
+
return [];
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
async function handleCodeReviewer(params) {
|
|
1787
|
+
const { files: inputFiles, focus = "correctness", customCriteria, format = "text" } = params;
|
|
1788
|
+
let files = inputFiles ?? [];
|
|
1789
|
+
let isAutoDetected = false;
|
|
1790
|
+
if (files.length === 0) {
|
|
1791
|
+
files = getGitChangedFiles();
|
|
1792
|
+
isAutoDetected = true;
|
|
1793
|
+
if (files.length === 0) {
|
|
1794
|
+
return `\u2139\uFE0F No code files (TS/JS/Python/Go/Rust/PHP/C#/Java) detected as changed in git status.
|
|
1795
|
+
Pass an explicit 'files' array to review specific files.`;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
const allIssues = [];
|
|
1799
|
+
let filesReviewed = 0;
|
|
1800
|
+
for (const filePath of files) {
|
|
1801
|
+
const validation = validateFilePath(filePath);
|
|
1802
|
+
if (!validation.valid) {
|
|
1803
|
+
allIssues.push({
|
|
1804
|
+
file: filePath,
|
|
1805
|
+
line: 0,
|
|
1806
|
+
severity: "error",
|
|
1807
|
+
rule: "path/invalid",
|
|
1808
|
+
message: `Invalid path: ${validation.error.message}`,
|
|
1809
|
+
suggestion: "Fix the file path"
|
|
1810
|
+
});
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
const resolvedPath = validation.resolvedPath;
|
|
1814
|
+
if (!fs7.existsSync(resolvedPath)) {
|
|
1815
|
+
allIssues.push({
|
|
1816
|
+
file: filePath,
|
|
1817
|
+
line: 0,
|
|
1818
|
+
severity: "error",
|
|
1819
|
+
rule: "path/not-found",
|
|
1820
|
+
message: "File not found",
|
|
1821
|
+
suggestion: "Verify the file path"
|
|
1822
|
+
});
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1825
|
+
try {
|
|
1826
|
+
const content = fs7.readFileSync(resolvedPath, "utf-8");
|
|
1827
|
+
filesReviewed++;
|
|
1828
|
+
checkGeneral(filePath, content, allIssues);
|
|
1829
|
+
switch (focus) {
|
|
1830
|
+
case "correctness":
|
|
1831
|
+
checkCorrectness(filePath, content, allIssues);
|
|
1832
|
+
break;
|
|
1833
|
+
case "conventions":
|
|
1834
|
+
checkConventions(filePath, content, allIssues);
|
|
1835
|
+
break;
|
|
1836
|
+
case "security":
|
|
1837
|
+
checkSecurity(filePath, content, allIssues);
|
|
1838
|
+
break;
|
|
1839
|
+
case "performance":
|
|
1840
|
+
checkPerformance(filePath, content, allIssues);
|
|
1841
|
+
break;
|
|
1842
|
+
case "over-engineering":
|
|
1843
|
+
checkOverEngineering(filePath, content, allIssues);
|
|
1844
|
+
break;
|
|
1845
|
+
}
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
allIssues.push({
|
|
1848
|
+
file: filePath,
|
|
1849
|
+
line: 0,
|
|
1850
|
+
severity: "error",
|
|
1851
|
+
rule: "io/read-failed",
|
|
1852
|
+
message: `Error reading file: ${err}`,
|
|
1853
|
+
suggestion: ""
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
sessionMemory.recordToolCall("code_reviewer", {
|
|
1858
|
+
filesReviewed,
|
|
1859
|
+
focus,
|
|
1860
|
+
issuesFound: allIssues.length,
|
|
1861
|
+
errors: allIssues.filter((i) => i.severity === "error").length,
|
|
1862
|
+
autoDetected: isAutoDetected
|
|
1863
|
+
});
|
|
1864
|
+
if (format === "json") {
|
|
1865
|
+
const jsonSummary = {
|
|
1866
|
+
totalIssues: allIssues.length,
|
|
1867
|
+
errors: allIssues.filter((i) => i.severity === "error").length,
|
|
1868
|
+
warnings: allIssues.filter((i) => i.severity === "warning").length,
|
|
1869
|
+
info: allIssues.filter((i) => i.severity === "info").length,
|
|
1870
|
+
filesReviewed,
|
|
1871
|
+
filesRequested: files.length,
|
|
1872
|
+
focus,
|
|
1873
|
+
autoDetected: isAutoDetected,
|
|
1874
|
+
...customCriteria ? { customCriteria } : {}
|
|
1875
|
+
};
|
|
1876
|
+
const issuesByFile = {};
|
|
1877
|
+
for (const issue of allIssues) {
|
|
1878
|
+
if (!issuesByFile[issue.file]) issuesByFile[issue.file] = [];
|
|
1879
|
+
issuesByFile[issue.file].push(issue);
|
|
1880
|
+
}
|
|
1881
|
+
return JSON.stringify({ summary: jsonSummary, issuesByFile, issues: allIssues }, null, 2);
|
|
1882
|
+
}
|
|
1883
|
+
return formatReviewOutput(allIssues, filesReviewed, files.length, focus, customCriteria, isAutoDetected);
|
|
1884
|
+
}
|
|
1885
|
+
function stripCommentsAndStrings(line) {
|
|
1886
|
+
let result = "";
|
|
1887
|
+
let i = 0;
|
|
1888
|
+
let inString = null;
|
|
1889
|
+
while (i < line.length) {
|
|
1890
|
+
const ch = line[i];
|
|
1891
|
+
const next = line[i + 1];
|
|
1892
|
+
if (inString) {
|
|
1893
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
1894
|
+
i += 2;
|
|
1895
|
+
continue;
|
|
1896
|
+
}
|
|
1897
|
+
if (ch === inString) {
|
|
1898
|
+
inString = null;
|
|
1899
|
+
i++;
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
i++;
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
if (ch === "/" && next === "/") break;
|
|
1906
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
1907
|
+
inString = ch;
|
|
1908
|
+
i++;
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
result += ch;
|
|
1912
|
+
i++;
|
|
1913
|
+
}
|
|
1914
|
+
return result;
|
|
1915
|
+
}
|
|
1916
|
+
function isTestFile(filePath) {
|
|
1917
|
+
return /\.(test|spec)\.[a-z]+$/i.test(filePath) || /__tests__/.test(filePath);
|
|
1918
|
+
}
|
|
1919
|
+
function isTsLike(filePath) {
|
|
1920
|
+
const ext = path7.extname(filePath).toLowerCase();
|
|
1921
|
+
return ext === ".ts" || ext === ".tsx";
|
|
1922
|
+
}
|
|
1923
|
+
function checkGeneral(filePath, content, issues) {
|
|
1924
|
+
const lines = content.split("\n");
|
|
1925
|
+
if (content.length > 0 && !content.endsWith("\n")) {
|
|
1926
|
+
issues.push({
|
|
1927
|
+
file: filePath,
|
|
1928
|
+
line: lines.length,
|
|
1929
|
+
severity: "info",
|
|
1930
|
+
rule: "style/final-newline",
|
|
1931
|
+
message: "File does not end with a newline",
|
|
1932
|
+
suggestion: "Add a trailing newline"
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
if (lines.length > 500) {
|
|
1936
|
+
issues.push({
|
|
1937
|
+
file: filePath,
|
|
1938
|
+
line: 1,
|
|
1939
|
+
severity: "info",
|
|
1940
|
+
rule: "complexity/file-too-long",
|
|
1941
|
+
message: `File is ${lines.length} lines \u2014 consider splitting`,
|
|
1942
|
+
suggestion: "Extract cohesive sections into separate modules"
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
function checkCorrectness(filePath, content, issues) {
|
|
1947
|
+
const lines = content.split("\n");
|
|
1948
|
+
const isTest = isTestFile(filePath);
|
|
1949
|
+
const isTs = isTsLike(filePath);
|
|
1950
|
+
let braceDepth = 0;
|
|
1951
|
+
let maxDepth = 0;
|
|
1952
|
+
let maxDepthLine = 0;
|
|
1953
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1954
|
+
const rawLine = lines[i];
|
|
1955
|
+
const line = stripCommentsAndStrings(rawLine);
|
|
1956
|
+
const lineNum = i + 1;
|
|
1957
|
+
for (const ch of line) {
|
|
1958
|
+
if (ch === "{") braceDepth++;
|
|
1959
|
+
else if (ch === "}") braceDepth = Math.max(0, braceDepth - 1);
|
|
1960
|
+
if (braceDepth > maxDepth) {
|
|
1961
|
+
maxDepth = braceDepth;
|
|
1962
|
+
maxDepthLine = lineNum;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
if (!isTest && /\bconsole\.(log|debug|info)\s*\(/.test(line)) {
|
|
1966
|
+
issues.push({
|
|
1967
|
+
file: filePath,
|
|
1968
|
+
line: lineNum,
|
|
1969
|
+
severity: "warning",
|
|
1970
|
+
rule: "correctness/console-log",
|
|
1971
|
+
message: "Leftover console.log",
|
|
1972
|
+
suggestion: "Remove or replace with a structured logger"
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
if (/\b(TODO|FIXME|HACK|XXX)\b/.test(rawLine)) {
|
|
1976
|
+
issues.push({
|
|
1977
|
+
file: filePath,
|
|
1978
|
+
line: lineNum,
|
|
1979
|
+
severity: "info",
|
|
1980
|
+
rule: "correctness/todo",
|
|
1981
|
+
message: "Unresolved TODO/FIXME/HACK marker",
|
|
1982
|
+
suggestion: "Resolve or file a tracked issue and link it"
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
if (isTs) {
|
|
1986
|
+
if (/:\s*any\b/.test(line) && !/eslint-disable/.test(rawLine)) {
|
|
1987
|
+
issues.push({
|
|
1988
|
+
file: filePath,
|
|
1989
|
+
line: lineNum,
|
|
1990
|
+
severity: "warning",
|
|
1991
|
+
rule: "ts/no-any",
|
|
1992
|
+
message: "'any' type annotation",
|
|
1993
|
+
suggestion: "Replace with a specific type or 'unknown'"
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
if (/\bas\s+any\b/.test(line)) {
|
|
1997
|
+
issues.push({
|
|
1998
|
+
file: filePath,
|
|
1999
|
+
line: lineNum,
|
|
2000
|
+
severity: "error",
|
|
2001
|
+
rule: "ts/no-as-any",
|
|
2002
|
+
message: "'as any' cast bypasses the type system",
|
|
2003
|
+
suggestion: "Narrow with a type guard or cast to 'unknown' then validate"
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
if (/\)\s*as\s+\w[^;]*\bas\s+\w/.test(line)) {
|
|
2007
|
+
issues.push({
|
|
2008
|
+
file: filePath,
|
|
2009
|
+
line: lineNum,
|
|
2010
|
+
severity: "warning",
|
|
2011
|
+
rule: "ts/double-cast",
|
|
2012
|
+
message: "Chained 'as' casts indicate a type-modeling problem",
|
|
2013
|
+
suggestion: "Fix the upstream type or use a type guard"
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
if (/[a-zA-Z_$\]\)]\s*!\s*\./.test(line) && !/!==|!=/.test(line)) {
|
|
2017
|
+
issues.push({
|
|
2018
|
+
file: filePath,
|
|
2019
|
+
line: lineNum,
|
|
2020
|
+
severity: "warning",
|
|
2021
|
+
rule: "ts/non-null-assertion",
|
|
2022
|
+
message: "Non-null assertion (!) suppresses null checks",
|
|
2023
|
+
suggestion: "Use optional chaining or an explicit guard"
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
if (/@ts-ignore\b/.test(rawLine)) {
|
|
2027
|
+
issues.push({
|
|
2028
|
+
file: filePath,
|
|
2029
|
+
line: lineNum,
|
|
2030
|
+
severity: "warning",
|
|
2031
|
+
rule: "ts/ts-ignore",
|
|
2032
|
+
message: "@ts-ignore silently suppresses type errors",
|
|
2033
|
+
suggestion: "Use @ts-expect-error with a reason, or fix the underlying type"
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
if (/:\s*Function\b/.test(line)) {
|
|
2037
|
+
issues.push({
|
|
2038
|
+
file: filePath,
|
|
2039
|
+
line: lineNum,
|
|
2040
|
+
severity: "warning",
|
|
2041
|
+
rule: "ts/no-unsafe-function",
|
|
2042
|
+
message: "'Function' type is unsafe (loses signature)",
|
|
2043
|
+
suggestion: "Use a specific signature like (x: T) => U"
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
if (/catch\s*\([^)]*\)\s*{\s*}/.test(line)) {
|
|
2048
|
+
issues.push({
|
|
2049
|
+
file: filePath,
|
|
2050
|
+
line: lineNum,
|
|
2051
|
+
severity: "error",
|
|
2052
|
+
rule: "correctness/empty-catch",
|
|
2053
|
+
message: "Empty catch swallows the error",
|
|
2054
|
+
suggestion: "Log the error or rethrow \u2014 silent catch hides bugs"
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
if (/(password|secret|api[_-]?key|token|bearer)\s*[:=]\s*['"][^'"]{6,}['"]/i.test(line) && !/process\.env|import\.meta\.env|getenv|os\.environ/.test(line)) {
|
|
2058
|
+
issues.push({
|
|
2059
|
+
file: filePath,
|
|
2060
|
+
line: lineNum,
|
|
2061
|
+
severity: "error",
|
|
2062
|
+
rule: "security/hardcoded-secret",
|
|
2063
|
+
message: "Possible hardcoded secret",
|
|
2064
|
+
suggestion: "Read from an environment variable instead"
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
const asyncFnMatch = line.match(/\basync\s+(function\b|\([^)]*\)\s*=>|[a-zA-Z_$][\w$]*\s*\()/);
|
|
2068
|
+
if (asyncFnMatch) {
|
|
2069
|
+
const body = lines.slice(i, Math.min(i + 40, lines.length)).join("\n");
|
|
2070
|
+
if (!/\bawait\b/.test(body) && !/return\s+\w+\s*\(/.test(body)) {
|
|
2071
|
+
issues.push({
|
|
2072
|
+
file: filePath,
|
|
2073
|
+
line: lineNum,
|
|
2074
|
+
severity: "info",
|
|
2075
|
+
rule: "correctness/async-without-await",
|
|
2076
|
+
message: "async function without await",
|
|
2077
|
+
suggestion: "Drop 'async' or add the awaited call"
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
if (isTs || /\.(js|jsx|mjs|cjs)$/i.test(filePath)) {
|
|
2082
|
+
if (/[^=!<>]==[^=]/.test(line) || /!=[^=]/.test(line)) {
|
|
2083
|
+
issues.push({
|
|
2084
|
+
file: filePath,
|
|
2085
|
+
line: lineNum,
|
|
2086
|
+
severity: "warning",
|
|
2087
|
+
rule: "correctness/loose-equality",
|
|
2088
|
+
message: "Loose equality (==/!=) \u2014 prefer strict equality",
|
|
2089
|
+
suggestion: "Use === / !=="
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
if (isTs && /^\s*[a-zA-Z_$][\w$]*\([^)]*\)\.[a-zA-Z]/.test(line) === false) {
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
if (maxDepth >= 5) {
|
|
2097
|
+
issues.push({
|
|
2098
|
+
file: filePath,
|
|
2099
|
+
line: maxDepthLine,
|
|
2100
|
+
severity: "warning",
|
|
2101
|
+
rule: "complexity/deep-nesting",
|
|
2102
|
+
message: `Code nests ${maxDepth} levels deep`,
|
|
2103
|
+
suggestion: "Extract helper functions or use early returns"
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
function checkConventions(filePath, content, issues) {
|
|
2108
|
+
const lines = content.split("\n");
|
|
2109
|
+
let hasTabs = false;
|
|
2110
|
+
let hasSpaces = false;
|
|
2111
|
+
for (let i = 0; i < Math.min(lines.length, 50); i++) {
|
|
2112
|
+
if (lines[i].startsWith(" ")) hasTabs = true;
|
|
2113
|
+
if (/^ {2,}/.test(lines[i])) hasSpaces = true;
|
|
2114
|
+
}
|
|
2115
|
+
if (hasTabs && hasSpaces) {
|
|
2116
|
+
issues.push({
|
|
2117
|
+
file: filePath,
|
|
2118
|
+
line: 1,
|
|
2119
|
+
severity: "warning",
|
|
2120
|
+
rule: "style/mixed-indent",
|
|
2121
|
+
message: "Mixed indentation (tabs and spaces)",
|
|
2122
|
+
suggestion: "Pick one (typically 2 spaces for JS/TS)"
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2126
|
+
if (lines[i].length > 200) {
|
|
2127
|
+
issues.push({
|
|
2128
|
+
file: filePath,
|
|
2129
|
+
line: i + 1,
|
|
2130
|
+
severity: "info",
|
|
2131
|
+
rule: "style/line-length",
|
|
2132
|
+
message: `Line is ${lines[i].length} chars`,
|
|
2133
|
+
suggestion: "Wrap lines over ~120 chars"
|
|
2134
|
+
});
|
|
2135
|
+
break;
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2139
|
+
const line = stripCommentsAndStrings(lines[i]);
|
|
2140
|
+
const classMatch = line.match(/\bclass\s+([a-zA-Z_$][\w$]*)/);
|
|
2141
|
+
if (classMatch && !/^[A-Z]/.test(classMatch[1])) {
|
|
2142
|
+
issues.push({
|
|
2143
|
+
file: filePath,
|
|
2144
|
+
line: i + 1,
|
|
2145
|
+
severity: "warning",
|
|
2146
|
+
rule: "naming/class-pascalcase",
|
|
2147
|
+
message: `Class "${classMatch[1]}" should be PascalCase`,
|
|
2148
|
+
suggestion: `Rename to "${classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1)}"`
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
const funcMatch = line.match(/\bfunction\s+([A-Z][a-zA-Z0-9_$]*)\s*\(/);
|
|
2152
|
+
if (funcMatch && !/React|Component/.test(line)) {
|
|
2153
|
+
issues.push({
|
|
2154
|
+
file: filePath,
|
|
2155
|
+
line: i + 1,
|
|
2156
|
+
severity: "info",
|
|
2157
|
+
rule: "naming/function-camelcase",
|
|
2158
|
+
message: `Function "${funcMatch[1]}" starts with uppercase (component?)`,
|
|
2159
|
+
suggestion: "Use camelCase for regular functions"
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
function checkSecurity(filePath, content, issues) {
|
|
2165
|
+
const lines = content.split("\n");
|
|
2166
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2167
|
+
const rawLine = lines[i];
|
|
2168
|
+
const line = stripCommentsAndStrings(rawLine);
|
|
2169
|
+
const lineNum = i + 1;
|
|
2170
|
+
if (/\beval\s*\(/.test(line)) {
|
|
2171
|
+
issues.push({
|
|
2172
|
+
file: filePath,
|
|
2173
|
+
line: lineNum,
|
|
2174
|
+
severity: "error",
|
|
2175
|
+
rule: "security/no-eval",
|
|
2176
|
+
message: "eval() is a security risk",
|
|
2177
|
+
suggestion: "Use JSON.parse or a real parser"
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
if (/\.innerHTML\s*=/.test(line)) {
|
|
2181
|
+
issues.push({
|
|
2182
|
+
file: filePath,
|
|
2183
|
+
line: lineNum,
|
|
2184
|
+
severity: "warning",
|
|
2185
|
+
rule: "security/innerhtml",
|
|
2186
|
+
message: "innerHTML can lead to XSS",
|
|
2187
|
+
suggestion: "Use textContent or sanitize with DOMPurify"
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
if (/\.query\s*\(\s*[`'"][^`'"]*(SELECT|INSERT|UPDATE|DELETE)/i.test(line) && /\$\{|\+\s*\w/.test(line)) {
|
|
2191
|
+
issues.push({
|
|
2192
|
+
file: filePath,
|
|
2193
|
+
line: lineNum,
|
|
2194
|
+
severity: "error",
|
|
2195
|
+
rule: "security/sql-injection",
|
|
2196
|
+
message: "SQL query built via string interpolation",
|
|
2197
|
+
suggestion: "Use parameterized queries or an ORM"
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
if (/\b(exec|execSync|spawn|spawnSync)\s*\(/.test(line) && /\$\{|\+\s*\w/.test(line) && !/\/\/\s*reviewed/i.test(rawLine)) {
|
|
2201
|
+
issues.push({
|
|
2202
|
+
file: filePath,
|
|
2203
|
+
line: lineNum,
|
|
2204
|
+
severity: "warning",
|
|
2205
|
+
rule: "security/shell-injection",
|
|
2206
|
+
message: "Shell command built from interpolated input",
|
|
2207
|
+
suggestion: "Sanitize input or use array-form spawn with explicit args"
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
if (/['"]http:\/\/(?!localhost|127\.|0\.0\.0\.0)/.test(rawLine)) {
|
|
2211
|
+
issues.push({
|
|
2212
|
+
file: filePath,
|
|
2213
|
+
line: lineNum,
|
|
2214
|
+
severity: "info",
|
|
2215
|
+
rule: "security/insecure-http",
|
|
2216
|
+
message: "Insecure http:// URL",
|
|
2217
|
+
suggestion: "Use https:// where possible"
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
function checkPerformance(filePath, content, issues) {
|
|
2223
|
+
const lines = content.split("\n");
|
|
2224
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2225
|
+
const line = stripCommentsAndStrings(lines[i]);
|
|
2226
|
+
const lineNum = i + 1;
|
|
2227
|
+
if (/\b(for|while)\s*\(/.test(line) && i > 0 && /\b(for|while)\s*\(/.test(stripCommentsAndStrings(lines[i - 1]))) {
|
|
2228
|
+
issues.push({
|
|
2229
|
+
file: filePath,
|
|
2230
|
+
line: lineNum,
|
|
2231
|
+
severity: "warning",
|
|
2232
|
+
rule: "perf/nested-loops",
|
|
2233
|
+
message: "Nested loops \u2014 potential O(n\xB2)",
|
|
2234
|
+
suggestion: "Use Map/Set lookups or a single-pass algorithm"
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
if (/\.(filter|map|reduce|find)\s*\(/.test(line) && i > 0 && /\bfor\s*\(/.test(stripCommentsAndStrings(lines[i - 1]))) {
|
|
2238
|
+
issues.push({
|
|
2239
|
+
file: filePath,
|
|
2240
|
+
line: lineNum,
|
|
2241
|
+
severity: "info",
|
|
2242
|
+
rule: "perf/array-in-loop",
|
|
2243
|
+
message: "Array method inside a loop",
|
|
2244
|
+
suggestion: "Hoist the computation outside the loop if possible"
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
if (/\.\.\./.test(line) && /\b(for|while|forEach|map|reduce)\b/.test(line)) {
|
|
2248
|
+
issues.push({
|
|
2249
|
+
file: filePath,
|
|
2250
|
+
line: lineNum,
|
|
2251
|
+
severity: "info",
|
|
2252
|
+
rule: "perf/spread-in-loop",
|
|
2253
|
+
message: "Spread inside a loop is O(n)",
|
|
2254
|
+
suggestion: "Use push() or concat() to grow arrays in loops"
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
if (/\b(readFileSync|writeFileSync|existsSync)\s*\(/.test(line) && /\b(for|while|forEach|map)\b/.test(lines[Math.max(0, i - 2)] + lines[Math.max(0, i - 1)] + lines[i])) {
|
|
2258
|
+
issues.push({
|
|
2259
|
+
file: filePath,
|
|
2260
|
+
line: lineNum,
|
|
2261
|
+
severity: "warning",
|
|
2262
|
+
rule: "perf/sync-io-in-loop",
|
|
2263
|
+
message: "Synchronous I/O inside a loop",
|
|
2264
|
+
suggestion: "Use the async variant and await in parallel where safe"
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
function checkOverEngineering(filePath, content, issues) {
|
|
2270
|
+
const lines = content.split("\n");
|
|
2271
|
+
const text = content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
2272
|
+
const classBodies = findBlockBodies(lines, /^\s*(export\s+)?(abstract\s+)?class\s+\w+/);
|
|
2273
|
+
for (const { body, nameLine, name } of classBodies) {
|
|
2274
|
+
const methodCount = (body.match(/^\s*(public|private|protected|static|async)?\s*\w+\s*\([^)]*\)\s*{/gm) || []).length;
|
|
2275
|
+
if (methodCount <= 1 && body.length > 0) {
|
|
2276
|
+
const trimmed = body.trim();
|
|
2277
|
+
const nonEmptyLines = trimmed ? trimmed.split("\n").filter((l) => l.trim()).length : 0;
|
|
2278
|
+
if (nonEmptyLines <= 3) {
|
|
2279
|
+
issues.push({
|
|
2280
|
+
file: filePath,
|
|
2281
|
+
line: nameLine,
|
|
2282
|
+
severity: "info",
|
|
2283
|
+
rule: "over-engineering/wrapper-class",
|
|
2284
|
+
message: `Class "${name}" has only 1 method \u2014 likely a wrapper that could be a plain function`,
|
|
2285
|
+
suggestion: "Replace with a standalone function or a utility module export"
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
const factoryMatches = text.matchAll(/^\s*(export\s+)?function\s+(create\w+|make\w+|build\w+|factory\w+)\s*\(/gm);
|
|
2291
|
+
for (const match of factoryMatches) {
|
|
2292
|
+
const funcName = match[2];
|
|
2293
|
+
const body = extractBodyAfter(lines, match);
|
|
2294
|
+
const creationCount = (body.match(/\bnew\s+\w+/g) || []).length;
|
|
2295
|
+
if (creationCount <= 1) {
|
|
2296
|
+
issues.push({
|
|
2297
|
+
file: filePath,
|
|
2298
|
+
line: lines.findIndex((l) => l.includes(funcName)) + 1,
|
|
2299
|
+
severity: "warning",
|
|
2300
|
+
rule: "over-engineering/single-product-factory",
|
|
2301
|
+
message: `Factory "${funcName}" produces only 1 product \u2014 unnecessary abstraction`,
|
|
2302
|
+
suggestion: "Inline the construction or drop the factory pattern"
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
const interfaceNames = [];
|
|
2307
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2308
|
+
const m = lines[i].match(/^\s*(export\s+)?interface\s+(\w+)/);
|
|
2309
|
+
if (m) interfaceNames.push(m[2]);
|
|
2310
|
+
}
|
|
2311
|
+
for (const iface of interfaceNames) {
|
|
2312
|
+
const implCount = (text.match(new RegExp(`implements\\s+.*\\b${iface}\\b`, "g")) || []).length;
|
|
2313
|
+
if (implCount <= 1) {
|
|
2314
|
+
const lineNum = lines.findIndex((l) => l.includes(`interface ${iface}`)) + 1;
|
|
2315
|
+
issues.push({
|
|
2316
|
+
file: filePath,
|
|
2317
|
+
line: lineNum,
|
|
2318
|
+
severity: "info",
|
|
2319
|
+
rule: "over-engineering/single-impl-interface",
|
|
2320
|
+
message: `Interface "${iface}" has only 1 implementation \u2014 unnecessary unless more planned`,
|
|
2321
|
+
suggestion: "Remove the interface or inline the type"
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
const configObjPattern = /^\s*(export\s+)?(const|let|var)\s+(\w+)\s*[=:]\s*\{/;
|
|
2326
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2327
|
+
const cm = lines[i].match(configObjPattern);
|
|
2328
|
+
if (!cm || !/config|setting|option|default/i.test(cm[3])) continue;
|
|
2329
|
+
const configName = cm[3];
|
|
2330
|
+
const refs = (text.match(new RegExp(`\\b${configName}\\b`, "g")) || []).length;
|
|
2331
|
+
if (refs <= 2) {
|
|
2332
|
+
issues.push({
|
|
2333
|
+
file: filePath,
|
|
2334
|
+
line: i + 1,
|
|
2335
|
+
severity: "info",
|
|
2336
|
+
rule: "over-engineering/unused-config",
|
|
2337
|
+
message: `Config "${configName}" is referenced ${refs === 0 ? "nowhere" : `only ${refs - 1}x outside definition`}`,
|
|
2338
|
+
suggestion: refs === 0 ? "Remove the dead config" : "Inline the value or remove if unused elsewhere"
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
const stdlibPatterns = [
|
|
2343
|
+
{ pattern: /function\s+\w+\s*\([^)]*\)\s*\{[^}]*fs\.readFileSync[^}]*\}/, rule: "over-engineering/hand-rolled-file-read", msg: "Hand-rolled file reader \u2014 use fs.readFileSync directly", suggestion: "Call fs.readFileSync directly instead of wrapping" },
|
|
2344
|
+
{ pattern: /function\s+\w+\s*\([^)]*\)\s*\{[^}]*crypto\.createHash[^}]*\}/, rule: "over-engineering/hand-rolled-hash", msg: "Hand-rolled hash \u2014 use crypto directly", suggestion: "Call crypto.createHash directly" },
|
|
2345
|
+
{ pattern: /function\s+uuid\s*\(|function\s+generateId\s*\(/, rule: "over-engineering/hand-rolled-uuid", msg: "Hand-rolled UUID \u2014 use crypto.randomUUID()", suggestion: "Replace with crypto.randomUUID() (Node 19+) or the uuid package" },
|
|
2346
|
+
{ pattern: /function\s+debounce\s*\(|function\s+throttle\s*\(/, rule: "over-engineering/hand-rolled-debounce", msg: "Hand-rolled debounce/throttle \u2014 utility lib or native", suggestion: "Use lodash.debounce or a 3-line inline version" }
|
|
2347
|
+
];
|
|
2348
|
+
for (const sp of stdlibPatterns) {
|
|
2349
|
+
const sl = text.match(sp.pattern);
|
|
2350
|
+
if (sl) {
|
|
2351
|
+
const matchIndex = sl.index ?? 0;
|
|
2352
|
+
const preText = text.substring(0, matchIndex);
|
|
2353
|
+
const lineNum = preText.split("\n").length;
|
|
2354
|
+
issues.push({
|
|
2355
|
+
file: filePath,
|
|
2356
|
+
line: lineNum,
|
|
2357
|
+
severity: "info",
|
|
2358
|
+
rule: sp.rule,
|
|
2359
|
+
message: sp.msg,
|
|
2360
|
+
suggestion: sp.suggestion
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
const exportDecls = [];
|
|
2365
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2366
|
+
const ed = lines[i].match(/^\s*export\s+(const|let|var|function|class)\s+(\w+)/);
|
|
2367
|
+
if (ed) exportDecls.push({ name: ed[2], line: i + 1 });
|
|
2368
|
+
}
|
|
2369
|
+
for (const decl of exportDecls) {
|
|
2370
|
+
const refCount = (text.match(new RegExp(`\\b${decl.name}\\b`, "g")) || []).length;
|
|
2371
|
+
const isReactComponent = /^[A-Z]/.test(decl.name) && /return\s+<|React\./.test(text);
|
|
2372
|
+
if (refCount <= 1 && decl.name !== "default" && !isReactComponent) {
|
|
2373
|
+
issues.push({
|
|
2374
|
+
file: filePath,
|
|
2375
|
+
line: decl.line,
|
|
2376
|
+
severity: "info",
|
|
2377
|
+
rule: "over-engineering/unused-export",
|
|
2378
|
+
message: `"${decl.name}" is exported but referenced only in its declaration`,
|
|
2379
|
+
suggestion: "Check if it is imported elsewhere; if not, remove or inline"
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
function findBlockBodies(lines, startPattern) {
|
|
2385
|
+
const results = [];
|
|
2386
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2387
|
+
const m = lines[i].match(startPattern);
|
|
2388
|
+
if (!m) continue;
|
|
2389
|
+
const name = m[0].match(/class\s+(\w+)/)?.[1] ?? "Unknown";
|
|
2390
|
+
const body = collectBlockLines(lines, i);
|
|
2391
|
+
results.push({ body: body.join("\n"), nameLine: i + 1, name });
|
|
2392
|
+
}
|
|
2393
|
+
return results;
|
|
2394
|
+
}
|
|
2395
|
+
function collectBlockLines(lines, startIdx) {
|
|
2396
|
+
let depth = 0;
|
|
2397
|
+
let started = false;
|
|
2398
|
+
const block = [];
|
|
2399
|
+
for (let j = startIdx; j < lines.length; j++) {
|
|
2400
|
+
const line = lines[j];
|
|
2401
|
+
block.push(line);
|
|
2402
|
+
for (const ch of line) {
|
|
2403
|
+
if (ch === "{") {
|
|
2404
|
+
depth++;
|
|
2405
|
+
started = true;
|
|
2406
|
+
} else if (ch === "}") depth--;
|
|
2407
|
+
}
|
|
2408
|
+
if (started && depth <= 0) break;
|
|
2409
|
+
}
|
|
2410
|
+
return block;
|
|
2411
|
+
}
|
|
2412
|
+
function extractBodyAfter(lines, match) {
|
|
2413
|
+
const startIdx = lines.findIndex((l) => l.includes(match[0].trim().split(/\s+/).pop() ?? ""));
|
|
2414
|
+
if (startIdx < 0) return "";
|
|
2415
|
+
return collectBlockLines(lines, startIdx).join("\n");
|
|
2416
|
+
}
|
|
2417
|
+
function formatReviewOutput(issues, filesReviewed, totalFiles, focus, customCriteria, autoDetected) {
|
|
2418
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
2419
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
2420
|
+
const infos = issues.filter((i) => i.severity === "info");
|
|
2421
|
+
const lines = [
|
|
2422
|
+
`\u{1F50D} **Code Review \u2014 Focus: ${focus}**`,
|
|
2423
|
+
...focus === "over-engineering" ? ["\u{1F4A1} **The Ladder:** 1. Does this code need to exist? 2. Does stdlib cover it? 3. Is there a one-liner? 4. Only then, write it."] : [],
|
|
2424
|
+
...autoDetected ? [`\u{1F4C2} Auto-detected ${totalFiles} changed file(s) from git`] : [],
|
|
2425
|
+
...customCriteria ? [`\u{1F4CB} **Custom Criteria:** ${customCriteria}`] : [],
|
|
2426
|
+
`\u{1F4C1} ${filesReviewed}/${totalFiles} files reviewed`,
|
|
2427
|
+
`\u{1F534} ${errors.length} errors | \u{1F7E1} ${warnings.length} warnings | \u{1F535} ${infos.length} info`,
|
|
2428
|
+
""
|
|
2429
|
+
];
|
|
2430
|
+
if (issues.length === 0) {
|
|
2431
|
+
lines.push("\u2705 No issues found.");
|
|
2432
|
+
return lines.join("\n");
|
|
2433
|
+
}
|
|
2434
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
2435
|
+
for (const issue of issues) {
|
|
2436
|
+
const existing = grouped.get(issue.file) ?? [];
|
|
2437
|
+
existing.push(issue);
|
|
2438
|
+
grouped.set(issue.file, existing);
|
|
2439
|
+
}
|
|
2440
|
+
for (const [file, fileIssues] of grouped) {
|
|
2441
|
+
lines.push(`**\u{1F4C4} ${file}:**`);
|
|
2442
|
+
for (const issue of fileIssues) {
|
|
2443
|
+
const icon = issue.severity === "error" ? "\u{1F534}" : issue.severity === "warning" ? "\u{1F7E1}" : "\u{1F535}";
|
|
2444
|
+
lines.push(` ${icon} [L${issue.line}] [${issue.rule}] ${issue.message}`);
|
|
2445
|
+
if (issue.suggestion) lines.push(` \u{1F4A1} ${issue.suggestion}`);
|
|
2446
|
+
}
|
|
2447
|
+
lines.push("");
|
|
2448
|
+
}
|
|
2449
|
+
if (errors.length > 0) {
|
|
2450
|
+
lines.push("\u26A0\uFE0F Fix errors first, then warnings.");
|
|
2451
|
+
lines.push("\u{1F4A1} Use precise_diff_editor to apply fixes.");
|
|
2452
|
+
} else if (warnings.length > 0) {
|
|
2453
|
+
lines.push("\u{1F4A1} Address warnings before merging.");
|
|
2454
|
+
} else {
|
|
2455
|
+
lines.push("\u2705 Only informational issues \u2014 ready for testing.");
|
|
2456
|
+
}
|
|
2457
|
+
return lines.join("\n");
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// src/utils/conventionsDetector.ts
|
|
2461
|
+
import fs8 from "fs";
|
|
2462
|
+
import path8 from "path";
|
|
2463
|
+
var cachedConventions = null;
|
|
2464
|
+
async function detectConventions(forceRescan = false) {
|
|
2465
|
+
if (cachedConventions && !forceRescan) {
|
|
2466
|
+
return cachedConventions;
|
|
2467
|
+
}
|
|
2468
|
+
const projectRoot = getProjectRoot();
|
|
2469
|
+
const workspaces = detectWorkspaces(projectRoot);
|
|
2470
|
+
const conventions = {
|
|
2471
|
+
framework: detectFramework(projectRoot),
|
|
2472
|
+
projectType: detectProjectType(projectRoot),
|
|
2473
|
+
testRunner: detectTestRunner(projectRoot),
|
|
2474
|
+
styling: detectStyling(projectRoot),
|
|
2475
|
+
importAlias: detectImportAlias(projectRoot),
|
|
2476
|
+
lintRules: detectLintRules(projectRoot),
|
|
2477
|
+
packageManager: detectPackageManager(),
|
|
2478
|
+
moduleSystem: detectModuleSystem(projectRoot),
|
|
2479
|
+
language: detectLanguage(projectRoot),
|
|
2480
|
+
features: detectFeatures(projectRoot),
|
|
2481
|
+
isMonorepo: workspaces.length > 0,
|
|
2482
|
+
workspaces
|
|
2483
|
+
};
|
|
2484
|
+
cachedConventions = conventions;
|
|
2485
|
+
return conventions;
|
|
2486
|
+
}
|
|
2487
|
+
function detectFramework(root) {
|
|
2488
|
+
const pkg = readJsonSafe(path8.join(root, "package.json"));
|
|
2489
|
+
if (pkg) {
|
|
2490
|
+
const pkgDeps = pkg.dependencies ?? {};
|
|
2491
|
+
const pkgDevDeps = pkg.devDependencies ?? {};
|
|
2492
|
+
const deps = { ...pkgDeps, ...pkgDevDeps };
|
|
2493
|
+
if (deps.next) return "Next.js";
|
|
2494
|
+
if (deps["@remix-run/react"]) return "Remix";
|
|
2495
|
+
if (deps.gatsby) return "Gatsby";
|
|
2496
|
+
if (deps.nuxt || deps["@nuxt/core"]) return "Nuxt.js";
|
|
2497
|
+
if (deps.vue) return "Vue";
|
|
2498
|
+
if (deps.react) return "React";
|
|
2499
|
+
if (deps.svelte) return "Svelte";
|
|
2500
|
+
if (deps.astro) return "Astro";
|
|
2501
|
+
if (deps.solid || deps["solid-js"]) return "SolidJS";
|
|
2502
|
+
if (deps.qwik || deps["@builder.io/qwik"]) return "Qwik";
|
|
2503
|
+
if (deps.vite) return "Vite";
|
|
2504
|
+
if (deps["@nestjs/core"]) return "NestJS";
|
|
2505
|
+
if (deps.fastify) return "Fastify";
|
|
2506
|
+
if (deps.express) return "Express";
|
|
2507
|
+
if (deps.koa) return "Koa";
|
|
2508
|
+
if (deps.hono) return "Hono";
|
|
2509
|
+
if (deps["@hapi/hapi"]) return "Hapi";
|
|
2510
|
+
if (deps["@modelcontextprotocol/sdk"]) return "MCP Server";
|
|
2511
|
+
if (deps["@anthropic-ai/claude-agent-sdk"]) return "Claude Agent SDK";
|
|
2512
|
+
if (deps.commander) return "Commander CLI";
|
|
2513
|
+
if (deps.yargs) return "Yargs CLI";
|
|
2514
|
+
if (deps["@oclif/core"] || deps.oclif) return "Oclif CLI";
|
|
2515
|
+
if (deps.ink) return "Ink (React CLI)";
|
|
2516
|
+
if (pkg.bin) return "CLI Tool";
|
|
2517
|
+
if (pkg.main || pkg.exports) return "Library";
|
|
2518
|
+
}
|
|
2519
|
+
const subFrameworks = scanSubProjectFrameworks(root);
|
|
2520
|
+
if (subFrameworks.length > 0) {
|
|
2521
|
+
const freq = /* @__PURE__ */ new Map();
|
|
2522
|
+
for (const fw of subFrameworks) {
|
|
2523
|
+
freq.set(fw, (freq.get(fw) ?? 0) + 1);
|
|
2524
|
+
}
|
|
2525
|
+
const [topFw] = [...freq.entries()].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
2526
|
+
if (topFw) return `${topFw} (monorepo)`;
|
|
2527
|
+
}
|
|
2528
|
+
return "unknown";
|
|
2529
|
+
}
|
|
2530
|
+
function scanSubProjectFrameworks(root) {
|
|
2531
|
+
const frameworks = [];
|
|
2532
|
+
try {
|
|
2533
|
+
const entries = fs8.readdirSync(root, { withFileTypes: true });
|
|
2534
|
+
for (const entry of entries) {
|
|
2535
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
2536
|
+
const subDir = path8.join(root, entry.name);
|
|
2537
|
+
const subPkg = readJsonSafe(path8.join(subDir, "package.json"));
|
|
2538
|
+
if (!subPkg) continue;
|
|
2539
|
+
const subDeps = { ...subPkg.dependencies ?? {}, ...subPkg.devDependencies ?? {} };
|
|
2540
|
+
if (subDeps.next) frameworks.push("Next.js");
|
|
2541
|
+
else if (subDeps.react) frameworks.push("React");
|
|
2542
|
+
else if (subDeps.vue) frameworks.push("Vue");
|
|
2543
|
+
else if (subDeps.svelte) frameworks.push("Svelte");
|
|
2544
|
+
else if (subDeps.astro) frameworks.push("Astro");
|
|
2545
|
+
else if (subDeps["@nestjs/core"]) frameworks.push("NestJS");
|
|
2546
|
+
else if (subDeps.express) frameworks.push("Express");
|
|
2547
|
+
else if (subDeps.hono) frameworks.push("Hono");
|
|
2548
|
+
else if (subDeps.vite) frameworks.push("Vite");
|
|
2549
|
+
else if (subDeps["@modelcontextprotocol/sdk"]) frameworks.push("MCP Server");
|
|
2550
|
+
}
|
|
2551
|
+
} catch {
|
|
2552
|
+
}
|
|
2553
|
+
return frameworks;
|
|
2554
|
+
}
|
|
2555
|
+
function detectProjectType(root) {
|
|
2556
|
+
const pkg = readJsonSafe(path8.join(root, "package.json"));
|
|
2557
|
+
if (pkg) {
|
|
2558
|
+
const pkgDeps = pkg.dependencies ?? {};
|
|
2559
|
+
const pkgDevDeps = pkg.devDependencies ?? {};
|
|
2560
|
+
const deps = { ...pkgDeps, ...pkgDevDeps };
|
|
2561
|
+
if (deps["@modelcontextprotocol/sdk"]) return "mcp-server";
|
|
2562
|
+
if (deps.next || deps.react || deps.vue || deps.svelte || deps.astro || deps.gatsby || deps.nuxt || deps["@remix-run/react"] || deps.solid) {
|
|
2563
|
+
return "web-app";
|
|
2564
|
+
}
|
|
2565
|
+
if (deps.express || deps.fastify || deps.koa || deps.hono || deps["@nestjs/core"] || deps["@hapi/hapi"]) {
|
|
2566
|
+
return "backend";
|
|
2567
|
+
}
|
|
2568
|
+
if (deps.commander || deps.yargs || deps["@oclif/core"] || deps.oclif || deps.ink) {
|
|
2569
|
+
return "cli";
|
|
2570
|
+
}
|
|
2571
|
+
if (pkg.bin) return "cli";
|
|
2572
|
+
if (pkg.main || pkg.exports) return "library";
|
|
2573
|
+
}
|
|
2574
|
+
const subFrameworks = scanSubProjectFrameworks(root);
|
|
2575
|
+
if (subFrameworks.length > 0) return "web-app";
|
|
2576
|
+
return "unknown";
|
|
2577
|
+
}
|
|
2578
|
+
function detectWorkspaces(root) {
|
|
2579
|
+
const results = [];
|
|
2580
|
+
const pkg = readJsonSafe(path8.join(root, "package.json"));
|
|
2581
|
+
const pnpmWorkspace = readYamlLite(path8.join(root, "pnpm-workspace.yaml"));
|
|
2582
|
+
const patterns = /* @__PURE__ */ new Set();
|
|
2583
|
+
const pkgWorkspaces = pkg?.workspaces;
|
|
2584
|
+
if (Array.isArray(pkgWorkspaces)) {
|
|
2585
|
+
for (const p of pkgWorkspaces) if (typeof p === "string") patterns.add(p);
|
|
2586
|
+
} else if (pkgWorkspaces && typeof pkgWorkspaces === "object") {
|
|
2587
|
+
const packages = pkgWorkspaces.packages;
|
|
2588
|
+
if (Array.isArray(packages)) {
|
|
2589
|
+
for (const p of packages) if (typeof p === "string") patterns.add(p);
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
if (Array.isArray(pnpmWorkspace?.packages)) {
|
|
2593
|
+
for (const p of pnpmWorkspace.packages) if (typeof p === "string") patterns.add(p);
|
|
2594
|
+
}
|
|
2595
|
+
patterns.add("apps/*");
|
|
2596
|
+
patterns.add("packages/*");
|
|
2597
|
+
patterns.add("services/*");
|
|
2598
|
+
for (const pattern of patterns) {
|
|
2599
|
+
const match = pattern.match(/^([^*]+)\/\*$/);
|
|
2600
|
+
if (!match) continue;
|
|
2601
|
+
const dir = path8.join(root, match[1]);
|
|
2602
|
+
if (!fs8.existsSync(dir)) continue;
|
|
2603
|
+
let entries = [];
|
|
2604
|
+
try {
|
|
2605
|
+
entries = fs8.readdirSync(dir, { withFileTypes: true });
|
|
2606
|
+
} catch {
|
|
2607
|
+
continue;
|
|
2608
|
+
}
|
|
2609
|
+
for (const entry of entries) {
|
|
2610
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
2611
|
+
const pkgPath = path8.join(dir, entry.name, "package.json");
|
|
2612
|
+
const subPkg = readJsonSafe(pkgPath);
|
|
2613
|
+
if (!subPkg) continue;
|
|
2614
|
+
results.push({
|
|
2615
|
+
path: path8.relative(root, path8.join(dir, entry.name)),
|
|
2616
|
+
name: subPkg.name ?? entry.name,
|
|
2617
|
+
framework: detectFramework(path8.join(dir, entry.name))
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2622
|
+
return results.filter((w) => {
|
|
2623
|
+
if (seen.has(w.path)) return false;
|
|
2624
|
+
seen.add(w.path);
|
|
2625
|
+
return true;
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
function readYamlLite(filePath) {
|
|
2629
|
+
try {
|
|
2630
|
+
if (!fs8.existsSync(filePath)) return null;
|
|
2631
|
+
const text = fs8.readFileSync(filePath, "utf-8");
|
|
2632
|
+
const packages = [];
|
|
2633
|
+
let inPackages = false;
|
|
2634
|
+
for (const rawLine of text.split("\n")) {
|
|
2635
|
+
const line = rawLine.replace(/#.*$/, "").trimEnd();
|
|
2636
|
+
if (/^packages:\s*$/.test(line)) {
|
|
2637
|
+
inPackages = true;
|
|
2638
|
+
continue;
|
|
2639
|
+
}
|
|
2640
|
+
if (inPackages) {
|
|
2641
|
+
const m = line.match(/^\s*-\s*['"]?([^'"]+)['"]?\s*$/);
|
|
2642
|
+
if (m) packages.push(m[1]);
|
|
2643
|
+
else if (/^\S/.test(line)) inPackages = false;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
return { packages };
|
|
2647
|
+
} catch {
|
|
2648
|
+
return null;
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
function detectTestRunner(root) {
|
|
2652
|
+
const pkg = readJsonSafe(path8.join(root, "package.json"));
|
|
2653
|
+
if (!pkg) return "unknown";
|
|
2654
|
+
const pkgDeps2 = pkg.dependencies ?? {};
|
|
2655
|
+
const pkgDevDeps2 = pkg.devDependencies ?? {};
|
|
2656
|
+
const deps = { ...pkgDeps2, ...pkgDevDeps2 };
|
|
2657
|
+
if (deps.vitest) return "Vitest";
|
|
2658
|
+
if (deps.jest) return "Jest";
|
|
2659
|
+
if (deps.mocha) return "Mocha";
|
|
2660
|
+
if (deps.ava) return "AVA";
|
|
2661
|
+
if (deps.cypress) return "Cypress";
|
|
2662
|
+
if (deps.playwright) return "Playwright";
|
|
2663
|
+
if (deps["@testing-library/react"]) return "React Testing Library";
|
|
2664
|
+
const scripts = pkg.scripts ?? {};
|
|
2665
|
+
if (scripts.test) {
|
|
2666
|
+
if (scripts.test.includes("vitest")) return "Vitest";
|
|
2667
|
+
if (scripts.test.includes("jest")) return "Jest";
|
|
2668
|
+
if (scripts.test.includes("mocha")) return "Mocha";
|
|
2669
|
+
}
|
|
2670
|
+
return "unknown";
|
|
2671
|
+
}
|
|
2672
|
+
function detectStyling(root) {
|
|
2673
|
+
const pkg = readJsonSafe(path8.join(root, "package.json"));
|
|
2674
|
+
if (!pkg) return "unknown";
|
|
2675
|
+
const pkgDeps3 = pkg.dependencies ?? {};
|
|
2676
|
+
const pkgDevDeps3 = pkg.devDependencies ?? {};
|
|
2677
|
+
const deps = { ...pkgDeps3, ...pkgDevDeps3 };
|
|
2678
|
+
if (deps.tailwindcss) return "Tailwind CSS";
|
|
2679
|
+
if (deps["styled-components"]) return "Styled Components";
|
|
2680
|
+
if (deps["@emotion/react"]) return "Emotion";
|
|
2681
|
+
if (deps.linaria) return "Linaria";
|
|
2682
|
+
if (deps.sass || deps["node-sass"]) return "SCSS";
|
|
2683
|
+
if (deps.less) return "Less";
|
|
2684
|
+
if (deps["@vanilla-extract/css"]) return "Vanilla Extract";
|
|
2685
|
+
if (deps.cssmodules || hasCSSModules(root)) return "CSS Modules";
|
|
2686
|
+
const srcDir = path8.join(root, "src");
|
|
2687
|
+
if (fs8.existsSync(srcDir)) {
|
|
2688
|
+
const cssFiles = findFiles(srcDir, [".css", ".scss", ".less"]);
|
|
2689
|
+
if (cssFiles.length > 0) return "Plain CSS/SCSS";
|
|
2690
|
+
}
|
|
2691
|
+
return "unknown";
|
|
2692
|
+
}
|
|
2693
|
+
function detectImportAlias(root) {
|
|
2694
|
+
const tsconfig = readJsonSafe(path8.join(root, "tsconfig.json"));
|
|
2695
|
+
const tsconfigPaths = tsconfig?.compilerOptions;
|
|
2696
|
+
if (tsconfigPaths?.paths) {
|
|
2697
|
+
const paths = tsconfigPaths.paths;
|
|
2698
|
+
const alias = Object.keys(paths)[0];
|
|
2699
|
+
if (alias) return alias.replace("/*", "");
|
|
2700
|
+
}
|
|
2701
|
+
const jsconfig = readJsonSafe(path8.join(root, "jsconfig.json"));
|
|
2702
|
+
const jsconfigPaths = jsconfig?.compilerOptions;
|
|
2703
|
+
if (jsconfigPaths?.paths) {
|
|
2704
|
+
const paths = jsconfigPaths.paths;
|
|
2705
|
+
const alias = Object.keys(paths)[0];
|
|
2706
|
+
if (alias) return alias.replace("/*", "");
|
|
2707
|
+
}
|
|
2708
|
+
return void 0;
|
|
2709
|
+
}
|
|
2710
|
+
function detectLintRules(root) {
|
|
2711
|
+
const rules = [];
|
|
2712
|
+
if (fs8.existsSync(path8.join(root, ".eslintrc"))) rules.push("ESLint (.eslintrc)");
|
|
2713
|
+
if (fs8.existsSync(path8.join(root, ".eslintrc.js"))) rules.push("ESLint (.eslintrc.js)");
|
|
2714
|
+
if (fs8.existsSync(path8.join(root, ".eslintrc.json"))) rules.push("ESLint (.eslintrc.json)");
|
|
2715
|
+
if (fs8.existsSync(path8.join(root, "eslint.config.js"))) rules.push("ESLint (flat config)");
|
|
2716
|
+
if (fs8.existsSync(path8.join(root, ".prettierrc"))) rules.push("Prettier");
|
|
2717
|
+
if (fs8.existsSync(path8.join(root, ".prettierrc.json"))) rules.push("Prettier");
|
|
2718
|
+
if (fs8.existsSync(path8.join(root, ".prettierrc.js"))) rules.push("Prettier");
|
|
2719
|
+
if (fs8.existsSync(path8.join(root, ".stylelintrc"))) rules.push("StyleLint");
|
|
2720
|
+
if (fs8.existsSync(path8.join(root, ".stylelintrc.json"))) rules.push("StyleLint");
|
|
2721
|
+
return rules;
|
|
2722
|
+
}
|
|
2723
|
+
function detectPackageManager() {
|
|
2724
|
+
const root = getProjectRoot();
|
|
2725
|
+
if (fs8.existsSync(path8.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
2726
|
+
if (fs8.existsSync(path8.join(root, "yarn.lock"))) return "yarn";
|
|
2727
|
+
if (fs8.existsSync(path8.join(root, "package-lock.json"))) return "npm";
|
|
2728
|
+
if (fs8.existsSync(path8.join(root, "bun.lockb"))) return "bun";
|
|
2729
|
+
return "npm";
|
|
2730
|
+
}
|
|
2731
|
+
function detectModuleSystem(root) {
|
|
2732
|
+
const pkg = readJsonSafe(path8.join(root, "package.json"));
|
|
2733
|
+
if (pkg?.type === "module") return "esm";
|
|
2734
|
+
const srcDir = path8.join(root, "src");
|
|
2735
|
+
if (fs8.existsSync(srcDir)) {
|
|
2736
|
+
const tsFiles = findFiles(srcDir, [".ts", ".tsx", ".js", ".jsx", ".mjs"]);
|
|
2737
|
+
for (const file of tsFiles.slice(0, 20)) {
|
|
2738
|
+
try {
|
|
2739
|
+
const content = fs8.readFileSync(file, "utf-8");
|
|
2740
|
+
if (content.includes("import ") || content.includes("export ")) return "esm";
|
|
2741
|
+
} catch {
|
|
2742
|
+
continue;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
return "cjs";
|
|
2747
|
+
}
|
|
2748
|
+
function detectLanguage(root) {
|
|
2749
|
+
if (fs8.existsSync(path8.join(root, "tsconfig.json"))) return "typescript";
|
|
2750
|
+
const srcDir = path8.join(root, "src");
|
|
2751
|
+
if (fs8.existsSync(srcDir)) {
|
|
2752
|
+
const tsFiles = findFiles(srcDir, [".ts", ".tsx"]);
|
|
2753
|
+
if (tsFiles.length > 0) return "typescript";
|
|
2754
|
+
}
|
|
2755
|
+
return "javascript";
|
|
2756
|
+
}
|
|
2757
|
+
function detectFeatures(root) {
|
|
2758
|
+
const features = [];
|
|
2759
|
+
const pkg = readJsonSafe(path8.join(root, "package.json"));
|
|
2760
|
+
if (!pkg) return features;
|
|
2761
|
+
const pkgDeps4 = pkg.dependencies ?? {};
|
|
2762
|
+
const pkgDevDeps4 = pkg.devDependencies ?? {};
|
|
2763
|
+
const deps = { ...pkgDeps4, ...pkgDevDeps4 };
|
|
2764
|
+
if (deps["@prisma/client"] || deps.prisma) features.push("Prisma ORM");
|
|
2765
|
+
if (deps.typeorm) features.push("TypeORM");
|
|
2766
|
+
if (deps["drizzle-orm"]) features.push("Drizzle ORM");
|
|
2767
|
+
if (deps.graphql || deps["@graphql-tools"]) features.push("GraphQL");
|
|
2768
|
+
if (deps.trpc || deps["@trpc/client"]) features.push("tRPC");
|
|
2769
|
+
if (deps["socket.io"] || deps.ws) features.push("WebSockets");
|
|
2770
|
+
if (deps["@reduxjs/toolkit"] || deps.redux) features.push("Redux");
|
|
2771
|
+
if (deps.zustand) features.push("Zustand");
|
|
2772
|
+
if (deps["react-router"] || deps["react-router-dom"]) features.push("React Router");
|
|
2773
|
+
if (deps["next-auth"] || deps["next-auth/react"]) features.push("NextAuth");
|
|
2774
|
+
if (deps["@tanstack/react-query"]) features.push("React Query");
|
|
2775
|
+
if (deps.jest || deps.vitest) features.push("Unit Testing");
|
|
2776
|
+
if (deps.cypress || deps.playwright) features.push("E2E Testing");
|
|
2777
|
+
if (deps.storybook || deps["@storybook/react"]) features.push("Storybook");
|
|
2778
|
+
if (deps.i18next || deps["react-i18next"]) features.push("i18n");
|
|
2779
|
+
return features;
|
|
2780
|
+
}
|
|
2781
|
+
function readJsonSafe(filePath) {
|
|
2782
|
+
try {
|
|
2783
|
+
if (fs8.existsSync(filePath)) {
|
|
2784
|
+
return JSON.parse(fs8.readFileSync(filePath, "utf-8"));
|
|
2785
|
+
}
|
|
2786
|
+
} catch {
|
|
2787
|
+
}
|
|
2788
|
+
return null;
|
|
2789
|
+
}
|
|
2790
|
+
function hasCSSModules(root) {
|
|
2791
|
+
const srcDir = path8.join(root, "src");
|
|
2792
|
+
if (!fs8.existsSync(srcDir)) return false;
|
|
2793
|
+
const files = findFiles(srcDir, [".module.css", ".module.scss", ".module.less"]);
|
|
2794
|
+
return files.length > 0;
|
|
2795
|
+
}
|
|
2796
|
+
function findFiles(dir, extensions) {
|
|
2797
|
+
const results = [];
|
|
2798
|
+
try {
|
|
2799
|
+
const entries = fs8.readdirSync(dir, { withFileTypes: true });
|
|
2800
|
+
for (const entry of entries) {
|
|
2801
|
+
const fullPath = path8.join(dir, entry.name);
|
|
2802
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
2803
|
+
results.push(...findFiles(fullPath, extensions));
|
|
2804
|
+
} else if (entry.isFile()) {
|
|
2805
|
+
if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
2806
|
+
results.push(fullPath);
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
} catch {
|
|
2811
|
+
}
|
|
2812
|
+
return results;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
// src/agents/projectConventions.ts
|
|
2816
|
+
async function handleProjectConventions(params) {
|
|
2817
|
+
const { forceRescan = false } = params;
|
|
2818
|
+
try {
|
|
2819
|
+
const conventions = await detectConventions(forceRescan);
|
|
2820
|
+
const conventionsRecord = conventions;
|
|
2821
|
+
sessionMemory.setConventions(conventionsRecord);
|
|
2822
|
+
sessionMemory.recordToolCall("project_conventions", { forceRescan });
|
|
2823
|
+
return formatConventionsOutput(conventionsRecord);
|
|
2824
|
+
} catch (err) {
|
|
2825
|
+
return `Error detecting conventions: ${err instanceof Error ? err.message : String(err)}`;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
function formatConventionsOutput(conventions) {
|
|
2829
|
+
const lines = [
|
|
2830
|
+
"\u{1F4CB} **Project Conventions Detected**",
|
|
2831
|
+
"\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501",
|
|
2832
|
+
"",
|
|
2833
|
+
`\u{1F3D7}\uFE0F **Framework:** ${conventions.framework || "unknown"}`,
|
|
2834
|
+
`\u{1F3AF} **Project Type:** ${conventions.projectType || "unknown"}`,
|
|
2835
|
+
`\u{1F9EA} **Test Runner:** ${conventions.testRunner || "unknown"}`,
|
|
2836
|
+
`\u{1F3A8} **Styling:** ${conventions.styling || "unknown"}`,
|
|
2837
|
+
`\u{1F4E6} **Package Manager:** ${conventions.packageManager || "unknown"}`,
|
|
2838
|
+
`\u{1F524} **Module System:** ${conventions.moduleSystem || "unknown"}`,
|
|
2839
|
+
`\u{1F4BB} **Language:** ${conventions.language || "unknown"}`,
|
|
2840
|
+
"",
|
|
2841
|
+
conventions.importAlias ? `\u{1F517} **Import Alias:** \`${conventions.importAlias}\`` : "\u{1F517} **Import Alias:** Not detected (use relative imports)",
|
|
2842
|
+
""
|
|
2843
|
+
];
|
|
2844
|
+
if (conventions.isMonorepo && Array.isArray(conventions.workspaces) && conventions.workspaces.length > 0) {
|
|
2845
|
+
lines.push(`\u{1F9E9} **Monorepo:** yes \u2014 ${conventions.workspaces.length} workspace(s)`);
|
|
2846
|
+
for (const ws of conventions.workspaces) {
|
|
2847
|
+
lines.push(` - \`${ws.path}\` (${ws.name}) \u2014 ${ws.framework}`);
|
|
2848
|
+
}
|
|
2849
|
+
lines.push("");
|
|
2850
|
+
}
|
|
2851
|
+
if (conventions.features && Array.isArray(conventions.features) && conventions.features.length > 0) {
|
|
2852
|
+
lines.push("\u2B50 **Key Features:**");
|
|
2853
|
+
for (const feature of conventions.features) {
|
|
2854
|
+
lines.push(` - ${feature}`);
|
|
2855
|
+
}
|
|
2856
|
+
lines.push("");
|
|
2857
|
+
}
|
|
2858
|
+
if (conventions.lintRules && Array.isArray(conventions.lintRules) && conventions.lintRules.length > 0) {
|
|
2859
|
+
lines.push("\u{1F4D0} **Lint/Format Tools:**");
|
|
2860
|
+
for (const rule of conventions.lintRules) {
|
|
2861
|
+
lines.push(` - ${rule}`);
|
|
2862
|
+
}
|
|
2863
|
+
lines.push("");
|
|
2864
|
+
}
|
|
2865
|
+
lines.push("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
2866
|
+
lines.push("\u{1F4A1} Use these conventions to keep code consistent.");
|
|
2867
|
+
lines.push("\u{1F4A1} Re-run with forceRescan: true after adding new dependencies.");
|
|
2868
|
+
return lines.join("\n");
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// src/tools/gitLog.ts
|
|
2872
|
+
import child_process from "child_process";
|
|
2873
|
+
async function handleGitLog(params) {
|
|
2874
|
+
const { maxCount = 10, filePath } = params;
|
|
2875
|
+
const root = getProjectRoot();
|
|
2876
|
+
try {
|
|
2877
|
+
let command = `git log -n ${maxCount} --oneline`;
|
|
2878
|
+
if (filePath) {
|
|
2879
|
+
command += ` -- "${filePath}"`;
|
|
2880
|
+
}
|
|
2881
|
+
const stdout = child_process.execSync(command, {
|
|
2882
|
+
cwd: root,
|
|
2883
|
+
encoding: "utf-8"
|
|
2884
|
+
});
|
|
2885
|
+
sessionMemory.recordToolCall("git_log", { maxCount, filePath });
|
|
2886
|
+
if (!stdout.trim()) {
|
|
2887
|
+
return `\u2139\uFE0F No commit history found${filePath ? ` for file "${filePath}"` : ""}.`;
|
|
2888
|
+
}
|
|
2889
|
+
return `\u{1F4DC} **Git Commit History**:
|
|
2890
|
+
|
|
2891
|
+
${stdout}`;
|
|
2892
|
+
} catch (err) {
|
|
2893
|
+
return `Error: Failed to get git log: ${err instanceof Error ? err.message : String(err)}`;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
// src/tools/gitDiff.ts
|
|
2898
|
+
import child_process2 from "child_process";
|
|
2899
|
+
async function handleGitDiff(params) {
|
|
2900
|
+
const { filePath, staged = false, contextLines = 3, baseRef, targetRef } = params;
|
|
2901
|
+
const root = getProjectRoot();
|
|
2902
|
+
try {
|
|
2903
|
+
let command = "git diff";
|
|
2904
|
+
if (staged) {
|
|
2905
|
+
command += " --cached";
|
|
2906
|
+
}
|
|
2907
|
+
command += " -U" + Math.max(1, Math.min(contextLines, 20));
|
|
2908
|
+
if (baseRef) {
|
|
2909
|
+
command += " " + baseRef;
|
|
2910
|
+
if (targetRef) {
|
|
2911
|
+
command += ".." + targetRef;
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
if (filePath) {
|
|
2915
|
+
command += ' -- "' + filePath + '"';
|
|
2916
|
+
}
|
|
2917
|
+
const stdout = child_process2.execSync(command, {
|
|
2918
|
+
cwd: root,
|
|
2919
|
+
encoding: "utf-8",
|
|
2920
|
+
maxBuffer: 2 * 1024 * 1024
|
|
2921
|
+
});
|
|
2922
|
+
sessionMemory.recordToolCall("git_diff", { filePath, staged, baseRef, targetRef });
|
|
2923
|
+
if (!stdout.trim()) {
|
|
2924
|
+
if (staged) {
|
|
2925
|
+
return "No staged changes found" + (filePath ? ' for "' + filePath + '".' : ".");
|
|
2926
|
+
}
|
|
2927
|
+
return "No uncommitted changes found" + (filePath ? ' for "' + filePath + '".' : ".");
|
|
2928
|
+
}
|
|
2929
|
+
const lines = stdout.split("\n");
|
|
2930
|
+
const result = [];
|
|
2931
|
+
let currentFile = "";
|
|
2932
|
+
let fileChanges = 0;
|
|
2933
|
+
let totalAdditions = 0;
|
|
2934
|
+
let totalDeletions = 0;
|
|
2935
|
+
result.push("**Git Diff**");
|
|
2936
|
+
if (staged) result.push("Staged changes");
|
|
2937
|
+
if (baseRef) result.push(baseRef + (targetRef ? ".." + targetRef : ""));
|
|
2938
|
+
if (filePath) result.push("File: " + filePath);
|
|
2939
|
+
result.push("");
|
|
2940
|
+
for (const line of lines) {
|
|
2941
|
+
const fileMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
2942
|
+
if (fileMatch) {
|
|
2943
|
+
if (currentFile && fileChanges > 0) {
|
|
2944
|
+
result.push(" " + fileChanges + " chunk(s) modified");
|
|
2945
|
+
result.push("");
|
|
2946
|
+
}
|
|
2947
|
+
currentFile = fileMatch[1];
|
|
2948
|
+
fileChanges = 0;
|
|
2949
|
+
result.push("--- " + currentFile + " ---");
|
|
2950
|
+
continue;
|
|
2951
|
+
}
|
|
2952
|
+
if (line.startsWith("new file mode")) {
|
|
2953
|
+
result.push(" [New file]");
|
|
2954
|
+
continue;
|
|
2955
|
+
}
|
|
2956
|
+
if (line.startsWith("deleted file mode")) {
|
|
2957
|
+
result.push(" [Deleted file]");
|
|
2958
|
+
continue;
|
|
2959
|
+
}
|
|
2960
|
+
if (line.startsWith("rename from") || line.startsWith("rename to")) {
|
|
2961
|
+
continue;
|
|
2962
|
+
}
|
|
2963
|
+
if (line.startsWith("index ")) {
|
|
2964
|
+
continue;
|
|
2965
|
+
}
|
|
2966
|
+
if (line.startsWith("--- ") || line.startsWith("+++ ")) {
|
|
2967
|
+
continue;
|
|
2968
|
+
}
|
|
2969
|
+
const chunkMatch = line.match(/^@@ -(\d+),?\d* \+(\d+),?\d* @@(.*)$/);
|
|
2970
|
+
if (chunkMatch) {
|
|
2971
|
+
fileChanges++;
|
|
2972
|
+
const desc = chunkMatch[3] ? " -- " + chunkMatch[3].trim() : "";
|
|
2973
|
+
result.push(" Chunk " + fileChanges + ": L" + chunkMatch[1] + " -> L" + chunkMatch[2] + desc);
|
|
2974
|
+
continue;
|
|
2975
|
+
}
|
|
2976
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
2977
|
+
totalAdditions++;
|
|
2978
|
+
result.push(" + " + line.substring(1).substring(0, 150));
|
|
2979
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
2980
|
+
totalDeletions++;
|
|
2981
|
+
result.push(" - " + line.substring(1).substring(0, 150));
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
if (currentFile && fileChanges > 0) {
|
|
2985
|
+
result.push(" " + fileChanges + " chunk(s) modified");
|
|
2986
|
+
}
|
|
2987
|
+
result.push("");
|
|
2988
|
+
result.push("-----------------------------");
|
|
2989
|
+
result.push("Summary: +" + totalAdditions + " / -" + totalDeletions + " lines across " + result.filter((l) => l.startsWith("--- ")).length + " file(s)");
|
|
2990
|
+
result.push("");
|
|
2991
|
+
result.push("Use precise_diff_editor to revert specific changes if needed.");
|
|
2992
|
+
return result.join("\n");
|
|
2993
|
+
} catch (err) {
|
|
2994
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2995
|
+
if (msg.includes("not a git repository")) {
|
|
2996
|
+
return "Not a git repository. Git diff requires an initialized git repo.\nRun `git init` to get started.";
|
|
2997
|
+
}
|
|
2998
|
+
return "Error: Failed to get git diff: " + msg;
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
// src/tools/projectStructure.ts
|
|
3003
|
+
import fs9 from "fs";
|
|
3004
|
+
import path9 from "path";
|
|
3005
|
+
var DEFAULT_IGNORE = [
|
|
3006
|
+
"node_modules",
|
|
3007
|
+
".git",
|
|
3008
|
+
".kuma",
|
|
3009
|
+
".agent-backups",
|
|
3010
|
+
"dist",
|
|
3011
|
+
".next",
|
|
3012
|
+
"build",
|
|
3013
|
+
"coverage",
|
|
3014
|
+
".cache",
|
|
3015
|
+
".turbo",
|
|
3016
|
+
".nyc_output",
|
|
3017
|
+
"__pycache__",
|
|
3018
|
+
".DS_Store",
|
|
3019
|
+
"*.log"
|
|
3020
|
+
];
|
|
3021
|
+
async function handleProjectStructure(params) {
|
|
3022
|
+
const {
|
|
3023
|
+
depth = 3,
|
|
3024
|
+
folderOnly = false,
|
|
3025
|
+
includePattern,
|
|
3026
|
+
excludePattern
|
|
3027
|
+
} = params;
|
|
3028
|
+
const root = getProjectRoot();
|
|
3029
|
+
const clampedDepth = Math.max(1, Math.min(depth, 6));
|
|
3030
|
+
sessionMemory.recordToolCall("project_structure", { depth: clampedDepth, folderOnly });
|
|
3031
|
+
try {
|
|
3032
|
+
const tree = buildTree(root, root, clampedDepth, 0, folderOnly, includePattern, excludePattern);
|
|
3033
|
+
const projectName = path9.basename(root);
|
|
3034
|
+
const lines = [
|
|
3035
|
+
"[Project Structure] - " + projectName,
|
|
3036
|
+
"Depth: " + clampedDepth + " | " + (folderOnly ? "Folders only" : "Files and folders"),
|
|
3037
|
+
"",
|
|
3038
|
+
projectName + "/",
|
|
3039
|
+
...tree,
|
|
3040
|
+
"",
|
|
3041
|
+
"Increase depth (max 6) for deeper structure.",
|
|
3042
|
+
"Use folderOnly: true for high-level overview."
|
|
3043
|
+
];
|
|
3044
|
+
return lines.join("\n");
|
|
3045
|
+
} catch (err) {
|
|
3046
|
+
return "Error building project structure: " + (err instanceof Error ? err.message : String(err));
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
function buildTree(root, currentDir, maxDepth, currentDepth, folderOnly, includePattern, excludePattern) {
|
|
3050
|
+
if (currentDepth >= maxDepth) return [];
|
|
3051
|
+
const lines = [];
|
|
3052
|
+
let entries = [];
|
|
3053
|
+
try {
|
|
3054
|
+
entries = fs9.readdirSync(currentDir, { withFileTypes: true });
|
|
3055
|
+
} catch {
|
|
3056
|
+
return lines;
|
|
3057
|
+
}
|
|
3058
|
+
entries.sort((a, b) => {
|
|
3059
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
3060
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
3061
|
+
return a.name.localeCompare(b.name);
|
|
3062
|
+
});
|
|
3063
|
+
const relativeDir = path9.relative(root, currentDir) || ".";
|
|
3064
|
+
const prefix = getPrefix(currentDepth);
|
|
3065
|
+
for (const entry of entries) {
|
|
3066
|
+
if (shouldIgnore(entry.name, relativeDir, root)) continue;
|
|
3067
|
+
const fullPath = path9.join(currentDir, entry.name);
|
|
3068
|
+
const relativePath = path9.relative(root, fullPath);
|
|
3069
|
+
if (includePattern && !entry.name.includes(includePattern) && !relativePath.includes(includePattern)) {
|
|
3070
|
+
if (!entry.isDirectory()) continue;
|
|
3071
|
+
}
|
|
3072
|
+
if (excludePattern && (entry.name.includes(excludePattern) || relativePath.includes(excludePattern))) {
|
|
3073
|
+
continue;
|
|
3074
|
+
}
|
|
3075
|
+
if (entry.isDirectory()) {
|
|
3076
|
+
lines.push(prefix + "[D] " + entry.name + "/");
|
|
3077
|
+
let subEntries = [];
|
|
3078
|
+
try {
|
|
3079
|
+
subEntries = fs9.readdirSync(fullPath, { withFileTypes: true });
|
|
3080
|
+
} catch {
|
|
3081
|
+
}
|
|
3082
|
+
const hasVisibleContent = subEntries.some(
|
|
3083
|
+
(e) => !shouldIgnore(e.name, path9.relative(root, fullPath), root)
|
|
3084
|
+
);
|
|
3085
|
+
if (hasVisibleContent) {
|
|
3086
|
+
const subLines = buildTree(root, fullPath, maxDepth, currentDepth + 1, folderOnly, includePattern, excludePattern);
|
|
3087
|
+
lines.push(...subLines);
|
|
3088
|
+
} else {
|
|
3089
|
+
if (currentDepth + 1 < maxDepth) {
|
|
3090
|
+
const childPrefix = getPrefix(currentDepth + 1);
|
|
3091
|
+
lines.push(childPrefix + "(empty)");
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
} else if (!folderOnly) {
|
|
3095
|
+
const size = getFileSize(fullPath);
|
|
3096
|
+
lines.push(prefix + "[F] " + entry.name + (size ? " (" + size + ")" : ""));
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
return lines;
|
|
3100
|
+
}
|
|
3101
|
+
function shouldIgnore(name, _relativeDir, _root) {
|
|
3102
|
+
if (name.startsWith(".") && name !== ".env" && name !== ".env.example") return true;
|
|
3103
|
+
if (DEFAULT_IGNORE.some((pattern) => {
|
|
3104
|
+
if (pattern.startsWith("*")) return name.endsWith(pattern.slice(1));
|
|
3105
|
+
return name === pattern;
|
|
3106
|
+
})) return true;
|
|
3107
|
+
return false;
|
|
3108
|
+
}
|
|
3109
|
+
function getPrefix(depth) {
|
|
3110
|
+
return " ".repeat(depth) + "|- ";
|
|
3111
|
+
}
|
|
3112
|
+
function getFileSize(fullPath) {
|
|
3113
|
+
try {
|
|
3114
|
+
const stat = fs9.statSync(fullPath);
|
|
3115
|
+
if (stat.size < 1024) return stat.size + "B";
|
|
3116
|
+
if (stat.size < 1024 * 1024) return (stat.size / 1024).toFixed(0) + "KB";
|
|
3117
|
+
const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
|
|
3118
|
+
return sizeMB + "MB";
|
|
3119
|
+
} catch {
|
|
3120
|
+
return null;
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
// src/tools/staticAnalysis.ts
|
|
3125
|
+
import { spawn as spawn2 } from "child_process";
|
|
3126
|
+
import fs10 from "fs";
|
|
3127
|
+
import path10 from "path";
|
|
3128
|
+
async function handleStaticAnalysis(params) {
|
|
3129
|
+
const { tool = "all", files, autoFix = false, timeout = 60 } = params;
|
|
3130
|
+
const root = getProjectRoot();
|
|
3131
|
+
sessionMemory.recordToolCall("static_analysis", { tool, files, autoFix });
|
|
3132
|
+
const availableTools = detectAvailableTools(root);
|
|
3133
|
+
if (availableTools.length === 0) {
|
|
3134
|
+
return formatNoToolsDetected();
|
|
3135
|
+
}
|
|
3136
|
+
const toolsToRun = tool === "all" ? availableTools : availableTools.filter((t) => t === tool);
|
|
3137
|
+
if (toolsToRun.length === 0) {
|
|
3138
|
+
return formatToolNotAvailable(tool, availableTools);
|
|
3139
|
+
}
|
|
3140
|
+
const allIssues = [];
|
|
3141
|
+
const results = [];
|
|
3142
|
+
for (const t of toolsToRun) {
|
|
3143
|
+
const output = await runTool(t, root, files, autoFix, timeout);
|
|
3144
|
+
if (output === null) {
|
|
3145
|
+
results.push({ tool: t, exitCode: -1, issues: [], summary: { errors: 0, warnings: 0, info: 0 } });
|
|
3146
|
+
continue;
|
|
3147
|
+
}
|
|
3148
|
+
const issues = parseToolOutput(t, output.stdout, output.stderr, root);
|
|
3149
|
+
const result = {
|
|
3150
|
+
tool: t,
|
|
3151
|
+
exitCode: output.exitCode,
|
|
3152
|
+
issues,
|
|
3153
|
+
summary: {
|
|
3154
|
+
errors: issues.filter((i) => i.severity === "error").length,
|
|
3155
|
+
warnings: issues.filter((i) => i.severity === "warning").length,
|
|
3156
|
+
info: issues.filter((i) => i.severity === "info").length
|
|
3157
|
+
}
|
|
3158
|
+
};
|
|
3159
|
+
results.push(result);
|
|
3160
|
+
allIssues.push(...issues);
|
|
3161
|
+
if (result.summary.errors > 0 || result.summary.warnings > 0) {
|
|
3162
|
+
const errorMsg = result.summary.errors > 0 ? result.summary.errors + " error(s)" : result.summary.warnings + " warning(s)";
|
|
3163
|
+
sessionMemory.addFailedFile("static_analysis:" + t, errorMsg + " found");
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
return formatResults2(results, allIssues, toolsToRun);
|
|
3167
|
+
}
|
|
3168
|
+
function detectAvailableTools(root) {
|
|
3169
|
+
const tools = [];
|
|
3170
|
+
const pkg = readPackageJson(root);
|
|
3171
|
+
const allDeps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
|
|
3172
|
+
const depNames = new Set(Object.keys(allDeps));
|
|
3173
|
+
const eslintConfigs = [
|
|
3174
|
+
".eslintrc",
|
|
3175
|
+
".eslintrc.js",
|
|
3176
|
+
".eslintrc.json",
|
|
3177
|
+
".eslintrc.yaml",
|
|
3178
|
+
".eslintrc.yml",
|
|
3179
|
+
"eslint.config.js",
|
|
3180
|
+
"eslint.config.mjs"
|
|
3181
|
+
];
|
|
3182
|
+
const hasEslintConfig = eslintConfigs.some((cfg) => fs10.existsSync(path10.join(root, cfg)));
|
|
3183
|
+
if (hasEslintConfig && depNames.has("eslint")) {
|
|
3184
|
+
tools.push("eslint");
|
|
3185
|
+
}
|
|
3186
|
+
if (fs10.existsSync(path10.join(root, "tsconfig.json")) && depNames.has("typescript")) {
|
|
3187
|
+
tools.push("tsc");
|
|
3188
|
+
}
|
|
3189
|
+
const prettierConfigs = [
|
|
3190
|
+
".prettierrc",
|
|
3191
|
+
".prettierrc.json",
|
|
3192
|
+
".prettierrc.js",
|
|
3193
|
+
".prettierrc.yaml",
|
|
3194
|
+
".prettierrc.toml",
|
|
3195
|
+
"prettier.config.js"
|
|
3196
|
+
];
|
|
3197
|
+
const hasPrettierConfig = prettierConfigs.some((cfg) => fs10.existsSync(path10.join(root, cfg)));
|
|
3198
|
+
if (hasPrettierConfig && depNames.has("prettier")) {
|
|
3199
|
+
tools.push("prettier");
|
|
3200
|
+
}
|
|
3201
|
+
if (fs10.existsSync(path10.join(root, "ruff.toml")) || fs10.existsSync(path10.join(root, ".ruff.toml"))) {
|
|
3202
|
+
tools.push("ruff");
|
|
3203
|
+
}
|
|
3204
|
+
return tools;
|
|
3205
|
+
}
|
|
3206
|
+
function readPackageJson(root) {
|
|
3207
|
+
try {
|
|
3208
|
+
const pkgPath = path10.join(root, "package.json");
|
|
3209
|
+
if (fs10.existsSync(pkgPath)) {
|
|
3210
|
+
return JSON.parse(fs10.readFileSync(pkgPath, "utf-8"));
|
|
3211
|
+
}
|
|
3212
|
+
} catch {
|
|
3213
|
+
}
|
|
3214
|
+
return null;
|
|
3215
|
+
}
|
|
3216
|
+
async function runTool(tool, cwd, files, autoFix, timeoutSeconds) {
|
|
3217
|
+
const cmd = buildToolCommand(tool, files, autoFix);
|
|
3218
|
+
if (!cmd) return null;
|
|
3219
|
+
return new Promise((resolve) => {
|
|
3220
|
+
const parts = cmd.split(" ");
|
|
3221
|
+
const command = parts[0];
|
|
3222
|
+
const args = parts.slice(1);
|
|
3223
|
+
const proc = spawn2(command, args, {
|
|
3224
|
+
cwd,
|
|
3225
|
+
shell: process.platform === "win32",
|
|
3226
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3227
|
+
});
|
|
3228
|
+
let stdout = "";
|
|
3229
|
+
let stderr = "";
|
|
3230
|
+
const timeoutId = setTimeout(() => {
|
|
3231
|
+
proc.kill("SIGTERM");
|
|
3232
|
+
if (process.platform === "win32") {
|
|
3233
|
+
try {
|
|
3234
|
+
spawn2("taskkill", ["/pid", String(proc.pid), "/f", "/t"], { stdio: "ignore" });
|
|
3235
|
+
} catch {
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
}, (timeoutSeconds ?? 60) * 1e3);
|
|
3239
|
+
proc.stdout?.on("data", (data) => {
|
|
3240
|
+
stdout += data.toString();
|
|
3241
|
+
});
|
|
3242
|
+
proc.stderr?.on("data", (data) => {
|
|
3243
|
+
stderr += data.toString();
|
|
3244
|
+
});
|
|
3245
|
+
proc.on("close", (code) => {
|
|
3246
|
+
clearTimeout(timeoutId);
|
|
3247
|
+
resolve({
|
|
3248
|
+
stdout,
|
|
3249
|
+
stderr,
|
|
3250
|
+
exitCode: code ?? -1
|
|
3251
|
+
});
|
|
3252
|
+
});
|
|
3253
|
+
proc.on("error", () => {
|
|
3254
|
+
clearTimeout(timeoutId);
|
|
3255
|
+
resolve(null);
|
|
3256
|
+
});
|
|
3257
|
+
});
|
|
3258
|
+
}
|
|
3259
|
+
function buildToolCommand(tool, files, autoFix) {
|
|
3260
|
+
const pm = detectPackageManagerPrefix();
|
|
3261
|
+
switch (tool) {
|
|
3262
|
+
case "eslint": {
|
|
3263
|
+
const fixFlag = autoFix ? " --fix" : "";
|
|
3264
|
+
const target = files ? files.join(" ") : ".";
|
|
3265
|
+
return pm + "eslint" + fixFlag + " " + target + " --format unix";
|
|
3266
|
+
}
|
|
3267
|
+
case "tsc": {
|
|
3268
|
+
return pm + "tsc --noEmit";
|
|
3269
|
+
}
|
|
3270
|
+
case "prettier": {
|
|
3271
|
+
const fixFlag = autoFix ? " --write" : " --check";
|
|
3272
|
+
const target = files ? files.join(" ") : ".";
|
|
3273
|
+
return pm + "prettier" + fixFlag + " " + target;
|
|
3274
|
+
}
|
|
3275
|
+
case "ruff": {
|
|
3276
|
+
const fixFlag = autoFix ? " --fix" : "";
|
|
3277
|
+
const target = files ? files.join(" ") : ".";
|
|
3278
|
+
return "ruff check" + fixFlag + " " + target;
|
|
3279
|
+
}
|
|
3280
|
+
default:
|
|
3281
|
+
return null;
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
function detectPackageManagerPrefix() {
|
|
3285
|
+
const root = getProjectRoot();
|
|
3286
|
+
if (fs10.existsSync(path10.join(root, "pnpm-lock.yaml"))) return "pnpm ";
|
|
3287
|
+
if (fs10.existsSync(path10.join(root, "yarn.lock"))) return "yarn ";
|
|
3288
|
+
return "npx --no-install ";
|
|
3289
|
+
}
|
|
3290
|
+
function parseToolOutput(tool, stdout, stderr, projectRoot) {
|
|
3291
|
+
switch (tool) {
|
|
3292
|
+
case "eslint":
|
|
3293
|
+
return parseEslintOutput(stdout, projectRoot);
|
|
3294
|
+
case "tsc":
|
|
3295
|
+
return parseTscOutput(stderr || stdout, projectRoot);
|
|
3296
|
+
case "prettier":
|
|
3297
|
+
return parsePrettierOutput(stdout, projectRoot);
|
|
3298
|
+
case "ruff":
|
|
3299
|
+
return parseRuffOutput(stdout, projectRoot);
|
|
3300
|
+
default:
|
|
3301
|
+
return [];
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
function resolveToolPath(filePath, projectRoot) {
|
|
3305
|
+
const trimmed = filePath.trim();
|
|
3306
|
+
return path10.isAbsolute(trimmed) ? path10.relative(projectRoot, trimmed) : trimmed.replace(/^\.\//, "");
|
|
3307
|
+
}
|
|
3308
|
+
function parseEslintOutput(output, projectRoot) {
|
|
3309
|
+
const issues = [];
|
|
3310
|
+
const lineRegex = /^(.+?):(\d+):(\d+):\s+(error|warning)\s+(.+?)(?:\s*\[([^\]]+)\])?\s*$/;
|
|
3311
|
+
for (const line of output.split("\n")) {
|
|
3312
|
+
const match = line.match(lineRegex);
|
|
3313
|
+
if (match) {
|
|
3314
|
+
const [, filePath, lineStr, colStr, severity, message, rule] = match;
|
|
3315
|
+
issues.push({
|
|
3316
|
+
file: resolveToolPath(filePath, projectRoot),
|
|
3317
|
+
line: parseInt(lineStr, 10),
|
|
3318
|
+
column: parseInt(colStr, 10),
|
|
3319
|
+
severity: severity === "error" ? "error" : "warning",
|
|
3320
|
+
message: message.trim(),
|
|
3321
|
+
rule: rule?.trim() || void 0,
|
|
3322
|
+
source: "eslint"
|
|
3323
|
+
});
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
return issues;
|
|
3327
|
+
}
|
|
3328
|
+
function parseTscOutput(output, projectRoot) {
|
|
3329
|
+
const issues = [];
|
|
3330
|
+
const lineRegex = /^(.+)\((\d+),(\d+)\):\s+(error|warning)\s+(TS\d+):\s+(.+)$/;
|
|
3331
|
+
for (const line of output.split("\n")) {
|
|
3332
|
+
let match = line.match(lineRegex);
|
|
3333
|
+
if (match) {
|
|
3334
|
+
const [, filePath, lineStr, colStr, _severity, code, message] = match;
|
|
3335
|
+
issues.push({
|
|
3336
|
+
file: resolveToolPath(filePath, projectRoot),
|
|
3337
|
+
line: parseInt(lineStr, 10) || 1,
|
|
3338
|
+
column: parseInt(colStr, 10) || 1,
|
|
3339
|
+
severity: "error",
|
|
3340
|
+
message: message.trim(),
|
|
3341
|
+
rule: code.trim(),
|
|
3342
|
+
source: "tsc"
|
|
3343
|
+
});
|
|
3344
|
+
continue;
|
|
3345
|
+
}
|
|
3346
|
+
const fallbackRegex = /^(.+)\((\d+),(\d+)\):\s+(.+)$/;
|
|
3347
|
+
match = line.match(fallbackRegex);
|
|
3348
|
+
if (match) {
|
|
3349
|
+
const [, filePath, lineStr, colStr, message] = match;
|
|
3350
|
+
issues.push({
|
|
3351
|
+
file: resolveToolPath(filePath, projectRoot),
|
|
3352
|
+
line: parseInt(lineStr, 10) || 1,
|
|
3353
|
+
column: parseInt(colStr, 10) || 1,
|
|
3354
|
+
severity: "error",
|
|
3355
|
+
message: message.trim(),
|
|
3356
|
+
source: "tsc"
|
|
3357
|
+
});
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
return issues;
|
|
3361
|
+
}
|
|
3362
|
+
function parsePrettierOutput(output, projectRoot) {
|
|
3363
|
+
const issues = [];
|
|
3364
|
+
const lineRegex = /^(.+?)(?::(\d+):(\d+))?\s+\[error\]\s*(.+)?$/;
|
|
3365
|
+
for (const line of output.split("\n")) {
|
|
3366
|
+
const match = line.match(lineRegex);
|
|
3367
|
+
if (match) {
|
|
3368
|
+
const filePath = match[1].trim();
|
|
3369
|
+
if (!filePath || filePath.startsWith("[")) continue;
|
|
3370
|
+
issues.push({
|
|
3371
|
+
file: resolveToolPath(filePath, projectRoot),
|
|
3372
|
+
line: match[2] ? parseInt(match[2], 10) : 1,
|
|
3373
|
+
column: match[3] ? parseInt(match[3], 10) : 1,
|
|
3374
|
+
severity: "warning",
|
|
3375
|
+
message: match[4]?.trim() || "Formatting issue",
|
|
3376
|
+
rule: "prettier",
|
|
3377
|
+
source: "prettier"
|
|
3378
|
+
});
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
return issues;
|
|
3382
|
+
}
|
|
3383
|
+
function parseRuffOutput(output, projectRoot) {
|
|
3384
|
+
const issues = [];
|
|
3385
|
+
const lineRegex = /^(.+?):(\d+):(\d+):\s+(\w+)\s+(.+)$/;
|
|
3386
|
+
for (const line of output.split("\n")) {
|
|
3387
|
+
const match = line.match(lineRegex);
|
|
3388
|
+
if (match) {
|
|
3389
|
+
const [, filePath, lineStr, colStr, rule, message] = match;
|
|
3390
|
+
issues.push({
|
|
3391
|
+
file: resolveToolPath(filePath, projectRoot),
|
|
3392
|
+
line: parseInt(lineStr, 10) || 1,
|
|
3393
|
+
column: parseInt(colStr, 10) || 1,
|
|
3394
|
+
severity: "warning",
|
|
3395
|
+
message: message.trim(),
|
|
3396
|
+
rule: rule.trim(),
|
|
3397
|
+
source: "ruff"
|
|
3398
|
+
});
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
return issues;
|
|
3402
|
+
}
|
|
3403
|
+
function formatResults2(results, allIssues, toolsRun) {
|
|
3404
|
+
const totalErrors = allIssues.filter((i) => i.severity === "error").length;
|
|
3405
|
+
const totalWarnings = allIssues.filter((i) => i.severity === "warning").length;
|
|
3406
|
+
const totalInfo = allIssues.filter((i) => i.severity === "info").length;
|
|
3407
|
+
const toolsStr = toolsRun.join(", ");
|
|
3408
|
+
const hasIssues = totalErrors > 0 || totalWarnings > 0 || totalInfo > 0;
|
|
3409
|
+
const lines = [
|
|
3410
|
+
"**Static Analysis**",
|
|
3411
|
+
"Tools: " + toolsStr,
|
|
3412
|
+
"",
|
|
3413
|
+
"Summary: " + totalErrors + " error(s), " + totalWarnings + " warning(s), " + totalInfo + " info",
|
|
3414
|
+
""
|
|
3415
|
+
];
|
|
3416
|
+
if (!hasIssues) {
|
|
3417
|
+
const failedTools = results.filter((r) => r.exitCode !== 0);
|
|
3418
|
+
if (failedTools.length > 0) {
|
|
3419
|
+
lines.push("Tool(s) executed but reported no parseable issues.");
|
|
3420
|
+
lines.push("Failed tools: " + failedTools.map((t) => t.tool + " (exit: " + t.exitCode + ")").join(", "));
|
|
3421
|
+
lines.push("");
|
|
3422
|
+
lines.push("The tool may have encountered an error. Run it manually to see full output.");
|
|
3423
|
+
} else {
|
|
3424
|
+
lines.push("All checks passed \u2014 no issues found.");
|
|
3425
|
+
}
|
|
3426
|
+
lines.push("");
|
|
3427
|
+
lines.push("Use precise_diff_editor or batch_file_writer for the next changes.");
|
|
3428
|
+
return lines.join("\n");
|
|
3429
|
+
}
|
|
3430
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
3431
|
+
for (const issue of allIssues) {
|
|
3432
|
+
const existing = byFile.get(issue.file) ?? [];
|
|
3433
|
+
existing.push(issue);
|
|
3434
|
+
byFile.set(issue.file, existing);
|
|
3435
|
+
}
|
|
3436
|
+
for (const [file, fileIssues] of byFile) {
|
|
3437
|
+
lines.push("--- " + file + " ---");
|
|
3438
|
+
for (const issue of fileIssues) {
|
|
3439
|
+
const severityTag = issue.severity === "error" ? "[ERROR]" : issue.severity === "warning" ? "[WARN]" : "[INFO]";
|
|
3440
|
+
const loc = "L" + issue.line + ":" + issue.column;
|
|
3441
|
+
const ruleStr = issue.rule ? " (" + issue.rule + ")" : "";
|
|
3442
|
+
lines.push(" " + severityTag + " " + loc + " \u2014 " + issue.message + ruleStr);
|
|
3443
|
+
}
|
|
3444
|
+
lines.push("");
|
|
3445
|
+
}
|
|
3446
|
+
lines.push("--- Per Tool ---");
|
|
3447
|
+
for (const result of results) {
|
|
3448
|
+
lines.push(
|
|
3449
|
+
" " + result.tool + ": " + result.summary.errors + "E / " + result.summary.warnings + "W / " + result.summary.info + "I (exit: " + result.exitCode + ")"
|
|
3450
|
+
);
|
|
3451
|
+
}
|
|
3452
|
+
lines.push("");
|
|
3453
|
+
lines.push("Use smart_file_picker to open a specific file and fix issues.");
|
|
3454
|
+
lines.push("Re-run static_analysis to verify fixes.");
|
|
3455
|
+
return lines.join("\n");
|
|
3456
|
+
}
|
|
3457
|
+
function formatNoToolsDetected() {
|
|
3458
|
+
return [
|
|
3459
|
+
"**Static Analysis**",
|
|
3460
|
+
"",
|
|
3461
|
+
"No linters or checkers detected for this project.",
|
|
3462
|
+
"",
|
|
3463
|
+
"Detected tools include:",
|
|
3464
|
+
" - ESLint (check .eslintrc or eslint.config.*)",
|
|
3465
|
+
" - TypeScript (check tsconfig.json)",
|
|
3466
|
+
" - Prettier (check .prettierrc)",
|
|
3467
|
+
" - Ruff (check ruff.toml)",
|
|
3468
|
+
"",
|
|
3469
|
+
"Install and configure a linter to use this tool.",
|
|
3470
|
+
"Then run project_conventions to refresh detection."
|
|
3471
|
+
].join("\n");
|
|
3472
|
+
}
|
|
3473
|
+
function formatToolNotAvailable(requested, available) {
|
|
3474
|
+
return [
|
|
3475
|
+
"**Static Analysis**",
|
|
3476
|
+
"",
|
|
3477
|
+
'Tool "' + requested + '" is not available for this project.',
|
|
3478
|
+
"",
|
|
3479
|
+
"Available tools: " + (available.length > 0 ? available.join(", ") : "none"),
|
|
3480
|
+
"",
|
|
3481
|
+
"Run static_analysis with tool: 'all' (default) to run all available tools."
|
|
3482
|
+
].join("\n");
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
// src/tools/kumaReflect.ts
|
|
3486
|
+
import { execSync as execSync2 } from "child_process";
|
|
3487
|
+
async function handleReflect(params) {
|
|
3488
|
+
const summary = sessionMemory.getSummary();
|
|
3489
|
+
const goal = params.goal || summary.currentGoal || "";
|
|
3490
|
+
const modifiedFiles = sessionMemory.getModifiedFiles();
|
|
3491
|
+
const toolCalls = sessionMemory.getToolCallHistory(50);
|
|
3492
|
+
const failedFiles = sessionMemory.getFailedFiles();
|
|
3493
|
+
const loop = sessionMemory.detectLoop();
|
|
3494
|
+
const unresolved = [];
|
|
3495
|
+
for (const f of failedFiles) {
|
|
3496
|
+
for (const ff of f.failures) {
|
|
3497
|
+
if (!ff.resolved) {
|
|
3498
|
+
unresolved.push({ task: f.task, error: ff.error.substring(0, 200) });
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
const hasRunTests = toolCalls.some(
|
|
3503
|
+
(c) => c.toolName === "execute_safe_test"
|
|
3504
|
+
);
|
|
3505
|
+
let gitStat = "";
|
|
3506
|
+
try {
|
|
3507
|
+
const root = getProjectRoot();
|
|
3508
|
+
gitStat = execSync2("git diff --stat", {
|
|
3509
|
+
cwd: root,
|
|
3510
|
+
encoding: "utf-8",
|
|
3511
|
+
timeout: 5e3
|
|
3512
|
+
}).trim();
|
|
3513
|
+
} catch {
|
|
3514
|
+
}
|
|
3515
|
+
const drifts = [];
|
|
3516
|
+
if (modifiedFiles.length > 0 && !hasRunTests) {
|
|
3517
|
+
drifts.push(`${modifiedFiles.length} file(s) edited but no test run`);
|
|
3518
|
+
}
|
|
3519
|
+
if (loop.isLooping) {
|
|
3520
|
+
drifts.push(loop.message);
|
|
3521
|
+
}
|
|
3522
|
+
if (unresolved.length > 0) {
|
|
3523
|
+
drifts.push(`${unresolved.length} unresolved failure(s)`);
|
|
3524
|
+
}
|
|
3525
|
+
if (gitStat) {
|
|
3526
|
+
drifts.push(`Git diff: ${gitStat}`);
|
|
3527
|
+
}
|
|
3528
|
+
const ladderViolations = [];
|
|
3529
|
+
const editCalls = toolCalls.filter(
|
|
3530
|
+
(c) => c.toolName === "precise_diff_editor" || c.toolName === "batch_file_writer"
|
|
3531
|
+
).length;
|
|
3532
|
+
if (editCalls > 5) {
|
|
3533
|
+
ladderViolations.push(
|
|
3534
|
+
`${editCalls} file ops in a row \u2014 consider if all are needed`
|
|
3535
|
+
);
|
|
3536
|
+
}
|
|
3537
|
+
if (modifiedFiles.length > 5 && !hasRunTests) {
|
|
3538
|
+
ladderViolations.push(
|
|
3539
|
+
`${modifiedFiles.length} files modified without verification`
|
|
3540
|
+
);
|
|
3541
|
+
}
|
|
3542
|
+
const onTrack = !loop.isLooping && unresolved.length === 0 && (modifiedFiles.length === 0 || hasRunTests);
|
|
3543
|
+
let suggestion;
|
|
3544
|
+
if (!goal) {
|
|
3545
|
+
suggestion = "No goal set \u2014 use goal parameter or setGoal";
|
|
3546
|
+
} else if (loop.isLooping) {
|
|
3547
|
+
suggestion = "Switch approach \u2014 current tool is not making progress";
|
|
3548
|
+
} else if (unresolved.length > 0) {
|
|
3549
|
+
suggestion = "Fix unresolved failures before continuing";
|
|
3550
|
+
} else if (modifiedFiles.length > 0 && !hasRunTests) {
|
|
3551
|
+
suggestion = "Run tests to verify changes";
|
|
3552
|
+
} else if (modifiedFiles.length === 0 && !toolCalls.some((c) => ["smart_file_picker", "smart_grep"].includes(c.toolName))) {
|
|
3553
|
+
suggestion = "Start by exploring what exists before writing code";
|
|
3554
|
+
} else if (editCalls > 10) {
|
|
3555
|
+
suggestion = "Consider if refactoring can be simplified \u2014 fewer files = fewer bugs";
|
|
3556
|
+
} else {
|
|
3557
|
+
suggestion = "On track";
|
|
3558
|
+
}
|
|
3559
|
+
return JSON.stringify(
|
|
3560
|
+
{
|
|
3561
|
+
onTrack,
|
|
3562
|
+
...drifts.length > 0 ? { drift: drifts.join("; ") } : {},
|
|
3563
|
+
...ladderViolations.length > 0 ? { ladderViolations } : {},
|
|
3564
|
+
suggestion,
|
|
3565
|
+
stats: {
|
|
3566
|
+
goal,
|
|
3567
|
+
modifiedFiles: modifiedFiles.length,
|
|
3568
|
+
toolCalls: toolCalls.length,
|
|
3569
|
+
unresolvedFailures: unresolved.length,
|
|
3570
|
+
hasLoop: loop.isLooping,
|
|
3571
|
+
hasRunTests
|
|
3572
|
+
}
|
|
3573
|
+
},
|
|
3574
|
+
null,
|
|
3575
|
+
2
|
|
3576
|
+
);
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
// src/engine/lspClient.ts
|
|
3580
|
+
import { spawn as spawn3 } from "child_process";
|
|
3581
|
+
import path11 from "path";
|
|
3582
|
+
import fs11 from "fs";
|
|
3583
|
+
var LSPClient = class {
|
|
3584
|
+
process = null;
|
|
3585
|
+
requestId = 0;
|
|
3586
|
+
pending = /* @__PURE__ */ new Map();
|
|
3587
|
+
buffer = Buffer.alloc(0);
|
|
3588
|
+
initialized = false;
|
|
3589
|
+
initPromise = null;
|
|
3590
|
+
openDocuments = /* @__PURE__ */ new Set();
|
|
3591
|
+
_isAvailable = true;
|
|
3592
|
+
/** Check whether LSP is available (binary installed) */
|
|
3593
|
+
isAvailable() {
|
|
3594
|
+
return this._isAvailable;
|
|
3595
|
+
}
|
|
3596
|
+
async ensureInitialized() {
|
|
3597
|
+
if (this._isAvailable !== void 0 && !this._isAvailable) return;
|
|
3598
|
+
if (this.initialized && this.process) return;
|
|
3599
|
+
if (this.initPromise) {
|
|
3600
|
+
try {
|
|
3601
|
+
await this.initPromise;
|
|
3602
|
+
return;
|
|
3603
|
+
} catch {
|
|
3604
|
+
this.initPromise = null;
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
this.initPromise = this.initialize();
|
|
3608
|
+
return this.initPromise;
|
|
3609
|
+
}
|
|
3610
|
+
async initialize() {
|
|
3611
|
+
const root = getProjectRoot();
|
|
3612
|
+
const lspBinary = this.resolveLspBinary(root);
|
|
3613
|
+
if (!lspBinary) {
|
|
3614
|
+
this._isAvailable = false;
|
|
3615
|
+
this.initialized = false;
|
|
3616
|
+
this.initPromise = null;
|
|
3617
|
+
console.error(`[LSP] typescript-language-server not found. LSP features will fallback to regex.`);
|
|
3618
|
+
return;
|
|
3619
|
+
}
|
|
3620
|
+
const tsconfigPath = path11.join(root, "tsconfig.json");
|
|
3621
|
+
if (!fs11.existsSync(tsconfigPath)) {
|
|
3622
|
+
console.error(`[LSP] Warning: tsconfig.json not found at "${root}". Running in implicit project mode.`);
|
|
3623
|
+
}
|
|
3624
|
+
this.process = spawn3(lspBinary, ["--stdio", "--log-level=4"], {
|
|
3625
|
+
cwd: root,
|
|
3626
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3627
|
+
env: { ...process.env }
|
|
3628
|
+
});
|
|
3629
|
+
this.process.stdout?.on("data", (data) => {
|
|
3630
|
+
this.buffer = Buffer.concat([this.buffer, data]);
|
|
3631
|
+
this.processBuffer();
|
|
3632
|
+
});
|
|
3633
|
+
this.process.stderr?.on("data", (data) => {
|
|
3634
|
+
console.error(`[LSP] ${data.toString().trim()}`);
|
|
3635
|
+
});
|
|
3636
|
+
this.process.on("exit", (code) => {
|
|
3637
|
+
console.error(`[LSP] Process exited with code ${code}`);
|
|
3638
|
+
this.initialized = false;
|
|
3639
|
+
this.process = null;
|
|
3640
|
+
this.initPromise = null;
|
|
3641
|
+
this.openDocuments.clear();
|
|
3642
|
+
this.buffer = Buffer.alloc(0);
|
|
3643
|
+
for (const [, pending] of this.pending) {
|
|
3644
|
+
pending.reject(new Error(`LSP server exited (code ${code})`));
|
|
3645
|
+
}
|
|
3646
|
+
this.pending.clear();
|
|
3647
|
+
});
|
|
3648
|
+
this.process.on("error", (err) => {
|
|
3649
|
+
console.error(`[LSP] Process error: ${err.message}. LSP features will fallback to regex.`);
|
|
3650
|
+
this._isAvailable = false;
|
|
3651
|
+
this.process = null;
|
|
3652
|
+
this.initialized = false;
|
|
3653
|
+
this.initPromise = null;
|
|
3654
|
+
this.openDocuments.clear();
|
|
3655
|
+
for (const [, pending] of this.pending) {
|
|
3656
|
+
pending.reject(new Error(`LSP server error: ${err.message}`));
|
|
3657
|
+
}
|
|
3658
|
+
this.pending.clear();
|
|
3659
|
+
});
|
|
3660
|
+
const rootUri = this.toUri(root);
|
|
3661
|
+
const initResult = await this.sendRequestRaw("initialize", {
|
|
3662
|
+
processId: null,
|
|
3663
|
+
rootPath: root,
|
|
3664
|
+
rootUri,
|
|
3665
|
+
capabilities: {
|
|
3666
|
+
textDocument: {
|
|
3667
|
+
references: {},
|
|
3668
|
+
definition: {},
|
|
3669
|
+
rename: { prepareSupport: true },
|
|
3670
|
+
hover: {
|
|
3671
|
+
contentFormat: ["markdown", "plaintext"]
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
});
|
|
3676
|
+
console.error(`[LSP] Initialized. Server capabilities: ${Object.keys(initResult.capabilities ?? {}).length} items`);
|
|
3677
|
+
this.sendNotification("initialized", {});
|
|
3678
|
+
this.initialized = true;
|
|
3679
|
+
}
|
|
3680
|
+
/** Resolve the typescript-language-server binary from local or global install */
|
|
3681
|
+
resolveLspBinary(projectRoot) {
|
|
3682
|
+
const candidates = [
|
|
3683
|
+
path11.join(projectRoot, "node_modules", ".bin", "typescript-language-server"),
|
|
3684
|
+
path11.join(projectRoot, "..", "node_modules", ".bin", "typescript-language-server")
|
|
3685
|
+
];
|
|
3686
|
+
for (const candidate of candidates) {
|
|
3687
|
+
if (fs11.existsSync(candidate)) {
|
|
3688
|
+
return candidate;
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
try {
|
|
3692
|
+
const envPath = process.env.PATH ?? "";
|
|
3693
|
+
for (const dir of envPath.split(path11.delimiter)) {
|
|
3694
|
+
const binPath = path11.join(dir, "typescript-language-server");
|
|
3695
|
+
if (fs11.existsSync(binPath)) {
|
|
3696
|
+
return binPath;
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
} catch {
|
|
3700
|
+
}
|
|
3701
|
+
return null;
|
|
3702
|
+
}
|
|
3703
|
+
toUri(filePath) {
|
|
3704
|
+
return `file://${filePath}`;
|
|
3705
|
+
}
|
|
3706
|
+
fromUri(uri) {
|
|
3707
|
+
return uri.replace(/^file:\/\//, "");
|
|
3708
|
+
}
|
|
3709
|
+
/** Open a document so the LSP server knows about it */
|
|
3710
|
+
async openDocument(filePath) {
|
|
3711
|
+
await this.ensureInitialized();
|
|
3712
|
+
if (this.openDocuments.has(filePath)) return;
|
|
3713
|
+
this.openDocuments.add(filePath);
|
|
3714
|
+
let content;
|
|
3715
|
+
try {
|
|
3716
|
+
content = fs11.readFileSync(filePath, "utf-8");
|
|
3717
|
+
} catch {
|
|
3718
|
+
content = "";
|
|
3719
|
+
}
|
|
3720
|
+
this.sendNotification("textDocument/didOpen", {
|
|
3721
|
+
textDocument: {
|
|
3722
|
+
uri: this.toUri(filePath),
|
|
3723
|
+
languageId: "typescript",
|
|
3724
|
+
version: 1,
|
|
3725
|
+
text: content
|
|
3726
|
+
}
|
|
3727
|
+
});
|
|
3728
|
+
}
|
|
3729
|
+
// ============================================================
|
|
3730
|
+
// LSP METHODS
|
|
3731
|
+
// ============================================================
|
|
3732
|
+
/** Find all references to a symbol at a position */
|
|
3733
|
+
async findReferences(filePath, line, character) {
|
|
3734
|
+
await this.ensureInitialized();
|
|
3735
|
+
await this.openDocument(filePath);
|
|
3736
|
+
const result = await this.sendRequestRaw("textDocument/references", {
|
|
3737
|
+
textDocument: { uri: this.toUri(filePath) },
|
|
3738
|
+
position: { line, character },
|
|
3739
|
+
context: { includeDeclaration: true }
|
|
3740
|
+
});
|
|
3741
|
+
if (!result || !Array.isArray(result)) return [];
|
|
3742
|
+
return result.map((ref) => ({
|
|
3743
|
+
filePath: this.fromUri(ref.uri),
|
|
3744
|
+
line: ref.range.start.line,
|
|
3745
|
+
character: ref.range.start.character,
|
|
3746
|
+
lineContent: ""
|
|
3747
|
+
}));
|
|
3748
|
+
}
|
|
3749
|
+
/** Get the definition location of a symbol */
|
|
3750
|
+
async goToDefinition(filePath, line, character) {
|
|
3751
|
+
await this.ensureInitialized();
|
|
3752
|
+
await this.openDocument(filePath);
|
|
3753
|
+
const result = await this.sendRequestRaw("textDocument/definition", {
|
|
3754
|
+
textDocument: { uri: this.toUri(filePath) },
|
|
3755
|
+
position: { line, character }
|
|
3756
|
+
});
|
|
3757
|
+
if (!result) return null;
|
|
3758
|
+
const loc = Array.isArray(result) ? result[0] : result;
|
|
3759
|
+
if (!loc) return null;
|
|
3760
|
+
return {
|
|
3761
|
+
uri: loc.uri,
|
|
3762
|
+
filePath: this.fromUri(loc.uri),
|
|
3763
|
+
line: loc.range.start.line,
|
|
3764
|
+
character: loc.range.start.character
|
|
3765
|
+
};
|
|
3766
|
+
}
|
|
3767
|
+
/** Rename a symbol across all files */
|
|
3768
|
+
async renameSymbol(filePath, line, character, newName) {
|
|
3769
|
+
await this.ensureInitialized();
|
|
3770
|
+
await this.openDocument(filePath);
|
|
3771
|
+
try {
|
|
3772
|
+
await this.sendRequestRaw("textDocument/prepareRename", {
|
|
3773
|
+
textDocument: { uri: this.toUri(filePath) },
|
|
3774
|
+
position: { line, character }
|
|
3775
|
+
});
|
|
3776
|
+
const result = await this.sendRequestRaw("textDocument/rename", {
|
|
3777
|
+
textDocument: { uri: this.toUri(filePath) },
|
|
3778
|
+
position: { line, character },
|
|
3779
|
+
newName
|
|
3780
|
+
});
|
|
3781
|
+
if (!result) {
|
|
3782
|
+
return { changes: [], success: false, error: "No rename result returned" };
|
|
3783
|
+
}
|
|
3784
|
+
const changes = [];
|
|
3785
|
+
const rawChanges = result.changes ?? {};
|
|
3786
|
+
for (const [uri, edits] of Object.entries(rawChanges)) {
|
|
3787
|
+
changes.push({
|
|
3788
|
+
filePath: this.fromUri(uri),
|
|
3789
|
+
edits: edits.map((edit) => ({
|
|
3790
|
+
line: edit.range.start.line,
|
|
3791
|
+
character: edit.range.start.character,
|
|
3792
|
+
endLine: edit.range.end.line,
|
|
3793
|
+
endCharacter: edit.range.end.character,
|
|
3794
|
+
newText: edit.newText
|
|
3795
|
+
}))
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
return { changes, success: true };
|
|
3799
|
+
} catch (err) {
|
|
3800
|
+
return {
|
|
3801
|
+
changes: [],
|
|
3802
|
+
success: false,
|
|
3803
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3804
|
+
};
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
/** Get type information at a position */
|
|
3808
|
+
async getTypeInfo(filePath, line, character) {
|
|
3809
|
+
await this.ensureInitialized();
|
|
3810
|
+
await this.openDocument(filePath);
|
|
3811
|
+
const result = await this.sendRequestRaw("textDocument/hover", {
|
|
3812
|
+
textDocument: { uri: this.toUri(filePath) },
|
|
3813
|
+
position: { line, character }
|
|
3814
|
+
});
|
|
3815
|
+
if (!result) return null;
|
|
3816
|
+
let contents = "";
|
|
3817
|
+
if (typeof result.contents === "string") {
|
|
3818
|
+
contents = result.contents;
|
|
3819
|
+
} else if (Array.isArray(result.contents)) {
|
|
3820
|
+
contents = result.contents.map((c) => c.value ?? c).join("\n---\n");
|
|
3821
|
+
} else if (result.contents && typeof result.contents === "object" && "value" in result.contents) {
|
|
3822
|
+
contents = result.contents.value;
|
|
3823
|
+
} else {
|
|
3824
|
+
contents = JSON.stringify(result.contents);
|
|
3825
|
+
}
|
|
3826
|
+
return {
|
|
3827
|
+
contents,
|
|
3828
|
+
range: result.range
|
|
3829
|
+
};
|
|
3830
|
+
}
|
|
3831
|
+
/** Clean shutdown */
|
|
3832
|
+
async shutdown() {
|
|
3833
|
+
if (!this.process) return;
|
|
3834
|
+
try {
|
|
3835
|
+
await this.sendRequestRaw("shutdown", {});
|
|
3836
|
+
this.sendNotification("exit", {});
|
|
3837
|
+
} catch {
|
|
3838
|
+
}
|
|
3839
|
+
this.process.kill();
|
|
3840
|
+
this.process = null;
|
|
3841
|
+
this.initialized = false;
|
|
3842
|
+
this.initPromise = null;
|
|
3843
|
+
this.openDocuments.clear();
|
|
3844
|
+
}
|
|
3845
|
+
// ============================================================
|
|
3846
|
+
// JSON-RPC LOW-LEVEL
|
|
3847
|
+
// ============================================================
|
|
3848
|
+
sendRequestRaw(method, params) {
|
|
3849
|
+
return new Promise((resolve, reject) => {
|
|
3850
|
+
const id = ++this.requestId;
|
|
3851
|
+
this.pending.set(id, { resolve, reject });
|
|
3852
|
+
const message = JSON.stringify({
|
|
3853
|
+
jsonrpc: "2.0",
|
|
3854
|
+
id,
|
|
3855
|
+
method,
|
|
3856
|
+
params
|
|
3857
|
+
});
|
|
3858
|
+
if (!this.process?.stdin?.writable) {
|
|
3859
|
+
this.pending.delete(id);
|
|
3860
|
+
reject(new Error("LSP server not running"));
|
|
3861
|
+
return;
|
|
3862
|
+
}
|
|
3863
|
+
const header = `Content-Length: ${Buffer.byteLength(message, "utf-8")}\r
|
|
3864
|
+
\r
|
|
3865
|
+
`;
|
|
3866
|
+
this.process.stdin.write(header + message);
|
|
3867
|
+
});
|
|
3868
|
+
}
|
|
3869
|
+
sendNotification(method, params) {
|
|
3870
|
+
const message = JSON.stringify({
|
|
3871
|
+
jsonrpc: "2.0",
|
|
3872
|
+
method,
|
|
3873
|
+
params
|
|
3874
|
+
});
|
|
3875
|
+
if (!this.process?.stdin?.writable) {
|
|
3876
|
+
console.error(`[LSP] Cannot send notification ${method}: server not running`);
|
|
3877
|
+
return;
|
|
3878
|
+
}
|
|
3879
|
+
const header = `Content-Length: ${Buffer.byteLength(message, "utf-8")}\r
|
|
3880
|
+
\r
|
|
3881
|
+
`;
|
|
3882
|
+
this.process.stdin.write(header + message);
|
|
3883
|
+
}
|
|
3884
|
+
/** Parse LSP buffer. Messages are in format: Content-Length: N\r\n\r\n<JSON> */
|
|
3885
|
+
processBuffer() {
|
|
3886
|
+
const headerEnd = "\r\n\r\n";
|
|
3887
|
+
while (true) {
|
|
3888
|
+
const headerIdx = this.buffer.indexOf(headerEnd);
|
|
3889
|
+
if (headerIdx === -1) break;
|
|
3890
|
+
const header = this.buffer.subarray(0, headerIdx).toString("utf-8");
|
|
3891
|
+
const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
|
|
3892
|
+
if (!lengthMatch) {
|
|
3893
|
+
this.buffer = this.buffer.subarray(headerIdx + 4);
|
|
3894
|
+
continue;
|
|
3895
|
+
}
|
|
3896
|
+
const contentLength = parseInt(lengthMatch[1], 10);
|
|
3897
|
+
const messageStart = headerIdx + 4;
|
|
3898
|
+
const messageEnd = messageStart + contentLength;
|
|
3899
|
+
if (this.buffer.length < messageEnd) break;
|
|
3900
|
+
const content = this.buffer.subarray(messageStart, messageEnd).toString("utf-8");
|
|
3901
|
+
this.buffer = this.buffer.subarray(messageEnd);
|
|
3902
|
+
try {
|
|
3903
|
+
const msg = JSON.parse(content);
|
|
3904
|
+
this.handleMessage(msg);
|
|
3905
|
+
} catch (err) {
|
|
3906
|
+
console.error(`[LSP] Failed to parse message: ${err}`);
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
handleMessage(msg) {
|
|
3911
|
+
if (msg.id && typeof msg.id === "number") {
|
|
3912
|
+
const pending = this.pending.get(msg.id);
|
|
3913
|
+
if (!pending) {
|
|
3914
|
+
console.error(`[LSP] No pending request for id ${msg.id}`);
|
|
3915
|
+
return;
|
|
3916
|
+
}
|
|
3917
|
+
this.pending.delete(msg.id);
|
|
3918
|
+
if (msg.error) {
|
|
3919
|
+
pending.reject(new Error(JSON.stringify(msg.error)));
|
|
3920
|
+
} else {
|
|
3921
|
+
pending.resolve(msg.result);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
if (!msg.id) {
|
|
3925
|
+
if (msg.method === "textDocument/publishDiagnostics") {
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
};
|
|
3930
|
+
var lspClient = new LSPClient();
|
|
3931
|
+
|
|
3932
|
+
// src/tools/lspTools.ts
|
|
3933
|
+
import fs12 from "fs";
|
|
3934
|
+
import path12 from "path";
|
|
3935
|
+
async function handleFindReferences(params) {
|
|
3936
|
+
const { filePath, line, character } = params;
|
|
3937
|
+
const validation = validateFilePath(filePath);
|
|
3938
|
+
if (!validation.valid) {
|
|
3939
|
+
return `Error: ${validation.error.message}`;
|
|
3940
|
+
}
|
|
3941
|
+
const resolvedPath = validation.resolvedPath;
|
|
3942
|
+
if (!fs12.existsSync(resolvedPath)) {
|
|
3943
|
+
return `Error: File not found: "${filePath}".`;
|
|
3944
|
+
}
|
|
3945
|
+
if (!lspClient.isAvailable()) {
|
|
3946
|
+
sessionMemory.recordToolCall("lsp_query", { action: "refs", filePath, line, character, fallback: "regex" });
|
|
3947
|
+
const symbolName = extractSymbolAtPosition(resolvedPath, line, character);
|
|
3948
|
+
if (!symbolName) {
|
|
3949
|
+
return `\u26A0\uFE0F LSP unavailable and cannot read symbol at that position for fallback grep.
|
|
3950
|
+
|
|
3951
|
+
\u{1F4A1} Install typescript-language-server: npm install typescript-language-server --save-dev`;
|
|
3952
|
+
}
|
|
3953
|
+
return fallbackGrepReferences(symbolName, resolvedPath, line, character);
|
|
3954
|
+
}
|
|
3955
|
+
try {
|
|
3956
|
+
const references = await lspClient.findReferences(resolvedPath, line, character);
|
|
3957
|
+
if (references.length === 0) {
|
|
3958
|
+
return `\u{1F50D} **Find References** \u2014 "${filePath}:${line + 1}:${character + 1}"
|
|
3959
|
+
\u26A0\uFE0F No references found for symbol at this position.`;
|
|
3960
|
+
}
|
|
3961
|
+
const enrichedRefs = references.map((ref) => {
|
|
3962
|
+
let lineContent = "";
|
|
3963
|
+
try {
|
|
3964
|
+
const content = fs12.readFileSync(ref.filePath, "utf-8");
|
|
3965
|
+
const lines2 = content.split("\n");
|
|
3966
|
+
lineContent = lines2[ref.line]?.trim() ?? "";
|
|
3967
|
+
} catch {
|
|
3968
|
+
}
|
|
3969
|
+
return { ...ref, lineContent };
|
|
3970
|
+
});
|
|
3971
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3972
|
+
for (const ref of enrichedRefs) {
|
|
3973
|
+
const existing = grouped.get(ref.filePath) ?? [];
|
|
3974
|
+
existing.push(ref);
|
|
3975
|
+
grouped.set(ref.filePath, existing);
|
|
3976
|
+
}
|
|
3977
|
+
const projectRoot = getProjectRoot();
|
|
3978
|
+
const lines = [
|
|
3979
|
+
`\u{1F50D} **Find References** \u2014 ${enrichedRefs.length} references found`,
|
|
3980
|
+
`\u{1F4CD} File: ${path12.relative(projectRoot, resolvedPath)}:${line + 1}:${character + 1}`,
|
|
3981
|
+
""
|
|
3982
|
+
];
|
|
3983
|
+
for (const [file, refs] of grouped) {
|
|
3984
|
+
const relPath = path12.relative(projectRoot, file);
|
|
3985
|
+
lines.push(`**\u{1F4C4} ${relPath}:**`);
|
|
3986
|
+
for (const ref of refs) {
|
|
3987
|
+
const loc = `L${ref.line + 1}:${ref.character + 1}`;
|
|
3988
|
+
lines.push(` \u2514 ${loc} \u2014 ${ref.lineContent.substring(0, 120)}`);
|
|
3989
|
+
}
|
|
3990
|
+
lines.push("");
|
|
3991
|
+
}
|
|
3992
|
+
lines.push("\u{1F4A1} Use smart_file_picker to read specific files.");
|
|
3993
|
+
return lines.join("\n");
|
|
3994
|
+
} catch (err) {
|
|
3995
|
+
return `Error finding references: ${err instanceof Error ? err.message : String(err)}`;
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
async function handleGoToDefinition(params) {
|
|
3999
|
+
const { filePath, line, character } = params;
|
|
4000
|
+
const validation = validateFilePath(filePath);
|
|
4001
|
+
if (!validation.valid) {
|
|
4002
|
+
return `Error: ${validation.error.message}`;
|
|
4003
|
+
}
|
|
4004
|
+
const resolvedPath = validation.resolvedPath;
|
|
4005
|
+
if (!fs12.existsSync(resolvedPath)) {
|
|
4006
|
+
return `Error: File not found: "${filePath}".`;
|
|
4007
|
+
}
|
|
4008
|
+
if (!lspClient.isAvailable()) {
|
|
4009
|
+
sessionMemory.recordToolCall("lsp_query", { action: "def", filePath, line, character, fallback: "regex" });
|
|
4010
|
+
const symbolName = extractSymbolAtPosition(resolvedPath, line, character);
|
|
4011
|
+
if (!symbolName) {
|
|
4012
|
+
return `\u26A0\uFE0F LSP unavailable and cannot read symbol at that position for fallback.
|
|
4013
|
+
|
|
4014
|
+
\u{1F4A1} Install typescript-language-server: npm install typescript-language-server --save-dev`;
|
|
4015
|
+
}
|
|
4016
|
+
return fallbackGrepDefinition(symbolName);
|
|
4017
|
+
}
|
|
4018
|
+
try {
|
|
4019
|
+
const definition = await lspClient.goToDefinition(resolvedPath, line, character);
|
|
4020
|
+
if (!definition) {
|
|
4021
|
+
return `\u{1F50D} **Go to Definition** \u2014 "${filePath}:${line + 1}:${character + 1}"
|
|
4022
|
+
\u26A0\uFE0F Cannot find definition for symbol at this position.`;
|
|
4023
|
+
}
|
|
4024
|
+
const projectRoot = getProjectRoot();
|
|
4025
|
+
const relPath = path12.relative(projectRoot, definition.filePath);
|
|
4026
|
+
let lineContent = "";
|
|
4027
|
+
try {
|
|
4028
|
+
const content = fs12.readFileSync(definition.filePath, "utf-8");
|
|
4029
|
+
const lines2 = content.split("\n");
|
|
4030
|
+
lineContent = lines2[definition.line]?.trim() ?? "";
|
|
4031
|
+
} catch {
|
|
4032
|
+
}
|
|
4033
|
+
const lines = [
|
|
4034
|
+
`\u{1F4CD} **Go to Definition**`,
|
|
4035
|
+
`\u{1F4C4} File: \`${relPath}\``,
|
|
4036
|
+
`\u{1F4CF} Line: ${definition.line + 1}:${definition.character + 1}`,
|
|
4037
|
+
`\u2514 ${lineContent}`,
|
|
4038
|
+
"",
|
|
4039
|
+
`\u{1F4A1} Use smart_file_picker(${JSON.stringify({
|
|
4040
|
+
filePath: relPath,
|
|
4041
|
+
startLine: Math.max(1, definition.line + 1 - 5),
|
|
4042
|
+
endLine: definition.line + 1 + 5
|
|
4043
|
+
})}) to read context around the definition.`
|
|
4044
|
+
];
|
|
4045
|
+
return lines.join("\n");
|
|
4046
|
+
} catch (err) {
|
|
4047
|
+
return `Error finding definition: ${err instanceof Error ? err.message : String(err)}`;
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
async function handleRenameSymbol(params) {
|
|
4051
|
+
const { filePath, line, character, newName } = params;
|
|
4052
|
+
if (!newName || newName.trim().length === 0) {
|
|
4053
|
+
return "Error: Parameter 'newName' must not be empty.";
|
|
4054
|
+
}
|
|
4055
|
+
const validation = validateFilePath(filePath);
|
|
4056
|
+
if (!validation.valid) {
|
|
4057
|
+
return `Error: ${validation.error.message}`;
|
|
4058
|
+
}
|
|
4059
|
+
const resolvedPath = validation.resolvedPath;
|
|
4060
|
+
if (!fs12.existsSync(resolvedPath)) {
|
|
4061
|
+
return `Error: File not found: "${filePath}".`;
|
|
4062
|
+
}
|
|
4063
|
+
if (!lspClient.isAvailable()) {
|
|
4064
|
+
sessionMemory.recordToolCall("lsp_query", { action: "rename", filePath, line, character, newName, fallback: "none" });
|
|
4065
|
+
return `\u26A0\uFE0F **Rename Symbol** unavailable without LSP server.
|
|
4066
|
+
Rename requires typescript-language-server to track references across all files.
|
|
4067
|
+
\u{1F4A1} Install: npm install typescript-language-server --save-dev
|
|
4068
|
+
|
|
4069
|
+
Meanwhile, use smart_grep to find all references, then precise_diff_editor for manual editing.`;
|
|
4070
|
+
}
|
|
4071
|
+
try {
|
|
4072
|
+
const result = await lspClient.renameSymbol(resolvedPath, line, character, newName);
|
|
4073
|
+
if (!result.success) {
|
|
4074
|
+
return `\u274C **Rename Symbol** failed: ${result.error ?? "Unknown error"}
|
|
4075
|
+
\`\`\`
|
|
4076
|
+
Make sure:
|
|
4077
|
+
1. Position (line: ${line + 1}, character: ${character + 1}) is exactly on the symbol you want to rename
|
|
4078
|
+
2. The symbol is valid for renaming
|
|
4079
|
+
\`\`\``;
|
|
4080
|
+
}
|
|
4081
|
+
if (result.changes.length === 0) {
|
|
4082
|
+
return "\u26A0\uFE0F No changes needed.";
|
|
4083
|
+
}
|
|
4084
|
+
const projectRoot = getProjectRoot();
|
|
4085
|
+
let totalEdits = 0;
|
|
4086
|
+
const fileChanges = [];
|
|
4087
|
+
for (const change of result.changes) {
|
|
4088
|
+
try {
|
|
4089
|
+
const content = fs12.readFileSync(change.filePath, "utf-8");
|
|
4090
|
+
const lines2 = content.split("\n");
|
|
4091
|
+
const sortedEdits = [...change.edits].sort((a, b) => {
|
|
4092
|
+
if (b.line !== a.line) return b.line - a.line;
|
|
4093
|
+
return b.character - a.character;
|
|
4094
|
+
});
|
|
4095
|
+
for (const edit of sortedEdits) {
|
|
4096
|
+
const lineStr = lines2[edit.line];
|
|
4097
|
+
if (lineStr) {
|
|
4098
|
+
const before = lineStr.substring(0, edit.character);
|
|
4099
|
+
const after = lineStr.substring(edit.endCharacter);
|
|
4100
|
+
lines2[edit.line] = before + edit.newText + after;
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
fs12.writeFileSync(change.filePath, lines2.join("\n"), "utf-8");
|
|
4104
|
+
totalEdits += change.edits.length;
|
|
4105
|
+
fileChanges.push({
|
|
4106
|
+
filePath: path12.relative(projectRoot, change.filePath),
|
|
4107
|
+
editCount: change.edits.length
|
|
4108
|
+
});
|
|
4109
|
+
} catch (err) {
|
|
4110
|
+
console.error(`[Rename] Failed to apply edits to ${change.filePath}: ${err}`);
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
const lines = [
|
|
4114
|
+
`\u270F\uFE0F **Rename Symbol** \u2705 Success \u2014 ${newName}`,
|
|
4115
|
+
`\u{1F4CA} ${totalEdits} changes in ${fileChanges.length} files:`,
|
|
4116
|
+
"",
|
|
4117
|
+
...fileChanges.map((f) => ` \u{1F4C4} \`${f.filePath}\` \u2014 ${f.editCount} edit`),
|
|
4118
|
+
"",
|
|
4119
|
+
`\u{1F4A1} Run execute_safe_test({task: "typecheck"}) to verify.`
|
|
4120
|
+
];
|
|
4121
|
+
return lines.join("\n");
|
|
4122
|
+
} catch (err) {
|
|
4123
|
+
return `Error renaming symbol: ${err instanceof Error ? err.message : String(err)}`;
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
4126
|
+
async function handleGetTypeInfo(params) {
|
|
4127
|
+
const { filePath, line, character } = params;
|
|
4128
|
+
const validation = validateFilePath(filePath);
|
|
4129
|
+
if (!validation.valid) {
|
|
4130
|
+
return `Error: ${validation.error.message}`;
|
|
4131
|
+
}
|
|
4132
|
+
const resolvedPath = validation.resolvedPath;
|
|
4133
|
+
if (!fs12.existsSync(resolvedPath)) {
|
|
4134
|
+
return `Error: File not found: "${filePath}".`;
|
|
4135
|
+
}
|
|
4136
|
+
if (!lspClient.isAvailable()) {
|
|
4137
|
+
sessionMemory.recordToolCall("lsp_query", { action: "type", filePath, line, character, fallback: "line-context" });
|
|
4138
|
+
try {
|
|
4139
|
+
const fileContent = fs12.readFileSync(resolvedPath, "utf-8");
|
|
4140
|
+
const fileLines = fileContent.split("\n");
|
|
4141
|
+
const targetLine = fileLines[line];
|
|
4142
|
+
if (!targetLine) {
|
|
4143
|
+
return `\u26A0\uFE0F **Type Info** \u2014 Line ${line + 1} does not exist in "${filePath}".`;
|
|
4144
|
+
}
|
|
4145
|
+
const symbolName = extractSymbolAtPosition(resolvedPath, line, character);
|
|
4146
|
+
const projectRoot = getProjectRoot();
|
|
4147
|
+
const relPath = path12.relative(projectRoot, resolvedPath);
|
|
4148
|
+
const contextStart = Math.max(0, line - 3);
|
|
4149
|
+
const contextEnd = Math.min(fileLines.length, line + 4);
|
|
4150
|
+
const contextLines = [];
|
|
4151
|
+
for (let i = contextStart; i < contextEnd; i++) {
|
|
4152
|
+
const prefix = i === line ? "\u2192" : " ";
|
|
4153
|
+
const marker = i === line && symbolName ? " ".repeat(character) + "^".repeat(Math.min(symbolName.length, 20)) : "";
|
|
4154
|
+
contextLines.push(`${prefix} L${i + 1}: ${fileLines[i].replace(/\t/g, " ")}`);
|
|
4155
|
+
if (marker) {
|
|
4156
|
+
contextLines.push(` ${marker}`);
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
4159
|
+
const resultLines = [
|
|
4160
|
+
`\u{1F4CB} **Type Info** (fallback \u2014 LSP unavailable)`,
|
|
4161
|
+
`\u{1F4C4} \`${relPath}:${line + 1}:${character + 1}\``,
|
|
4162
|
+
symbolName ? `\u{1F524} **Symbol:** \`${symbolName}\`` : `\u26A0\uFE0F Could not extract symbol at this position.`,
|
|
4163
|
+
"",
|
|
4164
|
+
"\u{1F4CE} Context:",
|
|
4165
|
+
"```",
|
|
4166
|
+
...contextLines,
|
|
4167
|
+
"```",
|
|
4168
|
+
"",
|
|
4169
|
+
`\u26A0\uFE0F Full type info requires typescript-language-server.`,
|
|
4170
|
+
`\u{1F4A1} Install: npm install typescript-language-server --save-dev`,
|
|
4171
|
+
`\u{1F4A1} Use smart_grep or smart_file_picker to read the file for more context.`
|
|
4172
|
+
];
|
|
4173
|
+
return resultLines.join("\n");
|
|
4174
|
+
} catch (err) {
|
|
4175
|
+
return `\u26A0\uFE0F **Type Info** unavailable without LSP server, and could not read file for fallback: ${err instanceof Error ? err.message : String(err)}`;
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
try {
|
|
4179
|
+
const hoverInfo = await lspClient.getTypeInfo(resolvedPath, line, character);
|
|
4180
|
+
if (!hoverInfo || !hoverInfo.contents) {
|
|
4181
|
+
return `\u{1F4CB} **Type Info** \u2014 "${filePath}:${line + 1}:${character + 1}"
|
|
4182
|
+
\u26A0\uFE0F No type info for this position.`;
|
|
4183
|
+
}
|
|
4184
|
+
const projectRoot = getProjectRoot();
|
|
4185
|
+
const relPath = path12.relative(projectRoot, resolvedPath);
|
|
4186
|
+
const lines = [
|
|
4187
|
+
`\u{1F4CB} **Type Info** \u2014 \`${relPath}:${line + 1}:${character + 1}\``,
|
|
4188
|
+
"",
|
|
4189
|
+
"```typescript",
|
|
4190
|
+
hoverInfo.contents,
|
|
4191
|
+
"```"
|
|
4192
|
+
];
|
|
4193
|
+
if (hoverInfo.range) {
|
|
4194
|
+
const r = hoverInfo.range;
|
|
4195
|
+
lines.push(
|
|
4196
|
+
"",
|
|
4197
|
+
`\u{1F4CD} Range: L${r.start.line + 1}:${r.start.character + 1} \u2014 L${r.end.line + 1}:${r.end.character + 1}`
|
|
4198
|
+
);
|
|
4199
|
+
}
|
|
4200
|
+
return lines.join("\n");
|
|
4201
|
+
} catch (err) {
|
|
4202
|
+
return `Error getting type info: ${err instanceof Error ? err.message : String(err)}`;
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
async function handleLspQuery(params) {
|
|
4206
|
+
const { filePath, line, character, action, newName } = params;
|
|
4207
|
+
if (action === "def") {
|
|
4208
|
+
return handleGoToDefinition({ filePath, line, character });
|
|
4209
|
+
}
|
|
4210
|
+
if (action === "refs") {
|
|
4211
|
+
return handleFindReferences({ filePath, line, character });
|
|
4212
|
+
}
|
|
4213
|
+
if (action === "type") {
|
|
4214
|
+
return handleGetTypeInfo({ filePath, line, character });
|
|
4215
|
+
}
|
|
4216
|
+
if (action === "rename") {
|
|
4217
|
+
if (!newName || newName.trim().length === 0) {
|
|
4218
|
+
return "Error: Parameter 'newName' required for rename action.";
|
|
4219
|
+
}
|
|
4220
|
+
return handleRenameSymbol({ filePath, line, character, newName });
|
|
4221
|
+
}
|
|
4222
|
+
return `Error: Action "${action}" not supported.`;
|
|
4223
|
+
}
|
|
4224
|
+
function extractSymbolAtPosition(filePath, line, character) {
|
|
4225
|
+
try {
|
|
4226
|
+
const content = fs12.readFileSync(filePath, "utf-8");
|
|
4227
|
+
const lines = content.split("\n");
|
|
4228
|
+
const targetLine = lines[line];
|
|
4229
|
+
if (!targetLine) return null;
|
|
4230
|
+
const before = targetLine.slice(0, character);
|
|
4231
|
+
const after = targetLine.slice(character);
|
|
4232
|
+
const leftMatch = before.match(/(\w+)$/);
|
|
4233
|
+
const rightMatch = after.match(/^(\w+)/);
|
|
4234
|
+
const left = leftMatch ? leftMatch[1] : "";
|
|
4235
|
+
const right = rightMatch ? rightMatch[1] : "";
|
|
4236
|
+
const symbol = left + right;
|
|
4237
|
+
return symbol.length > 0 ? symbol : null;
|
|
4238
|
+
} catch {
|
|
4239
|
+
return null;
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
async function fallbackGrepReferences(symbolName, _filePath, _line, _character) {
|
|
4243
|
+
try {
|
|
4244
|
+
const { default: fg2 } = await import("fast-glob");
|
|
4245
|
+
const root = getProjectRoot();
|
|
4246
|
+
const tsFiles = await fg2(["**/*.{ts,tsx,js,jsx}"], {
|
|
4247
|
+
cwd: root,
|
|
4248
|
+
ignore: ["node_modules/**", "dist/**", ".git/**"],
|
|
4249
|
+
onlyFiles: true,
|
|
4250
|
+
absolute: true
|
|
4251
|
+
});
|
|
4252
|
+
const results = [];
|
|
4253
|
+
const escapedSymbol = symbolName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4254
|
+
const regex = new RegExp(`\\b${escapedSymbol}\\b`, "g");
|
|
4255
|
+
for (const file of tsFiles.slice(0, 100)) {
|
|
4256
|
+
try {
|
|
4257
|
+
const content = fs12.readFileSync(file, "utf-8");
|
|
4258
|
+
const lines2 = content.split("\n");
|
|
4259
|
+
for (let i = 0; i < lines2.length; i++) {
|
|
4260
|
+
if (regex.test(lines2[i])) {
|
|
4261
|
+
results.push({ file, line: i + 1, content: lines2[i].trim().substring(0, 120) });
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
if (results.length >= 50) break;
|
|
4265
|
+
} catch {
|
|
4266
|
+
continue;
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
if (results.length === 0) {
|
|
4270
|
+
return `\u{1F50D} **Find References** (regex fallback) \u2014 "${symbolName}"
|
|
4271
|
+
\u26A0\uFE0F No references found. Symbol may not be used in other files.`;
|
|
4272
|
+
}
|
|
4273
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
4274
|
+
for (const r of results) {
|
|
4275
|
+
const existing = grouped.get(r.file) ?? [];
|
|
4276
|
+
existing.push(r);
|
|
4277
|
+
grouped.set(r.file, existing);
|
|
4278
|
+
}
|
|
4279
|
+
const projectRoot = getProjectRoot();
|
|
4280
|
+
const lines = [
|
|
4281
|
+
`\u{1F50D} **Find References** (regex fallback) \u2014 ${results.length} references found`,
|
|
4282
|
+
`\u{1F4CD} Symbol: "${symbolName}"`,
|
|
4283
|
+
`\u26A0\uFE0F Regex results may be less accurate than LSP (includes comments/strings).`,
|
|
4284
|
+
""
|
|
4285
|
+
];
|
|
4286
|
+
for (const [file, refs] of grouped) {
|
|
4287
|
+
const relPath = path12.relative(projectRoot, file);
|
|
4288
|
+
lines.push(`**\u{1F4C4} ${relPath}:**`);
|
|
4289
|
+
for (const ref of refs) {
|
|
4290
|
+
lines.push(` \u2514 L${ref.line} \u2014 ${ref.content}`);
|
|
4291
|
+
}
|
|
4292
|
+
lines.push("");
|
|
4293
|
+
}
|
|
4294
|
+
lines.push("\u{1F4A1} Install typescript-language-server for more accurate results.");
|
|
4295
|
+
return lines.join("\n");
|
|
4296
|
+
} catch (err) {
|
|
4297
|
+
return `Error in fallback grep references: ${err instanceof Error ? err.message : String(err)}`;
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
async function fallbackGrepDefinition(symbolName) {
|
|
4301
|
+
try {
|
|
4302
|
+
const { default: fg2 } = await import("fast-glob");
|
|
4303
|
+
const root = getProjectRoot();
|
|
4304
|
+
const tsFiles = await fg2(["**/*.{ts,tsx,js,jsx}"], {
|
|
4305
|
+
cwd: root,
|
|
4306
|
+
ignore: ["node_modules/**", "dist/**", ".git/**"],
|
|
4307
|
+
onlyFiles: true,
|
|
4308
|
+
absolute: true
|
|
4309
|
+
});
|
|
4310
|
+
const escapedSymbol = symbolName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4311
|
+
const declPatterns = [
|
|
4312
|
+
new RegExp(`^(export\\s+)?(async\\s+)?function\\s+${escapedSymbol}\\b`),
|
|
4313
|
+
new RegExp(`^(export\\s+)?(default\\s+)?(abstract\\s+)?class\\s+${escapedSymbol}\\b`),
|
|
4314
|
+
new RegExp(`^(export\\s+)?interface\\s+${escapedSymbol}\\b`),
|
|
4315
|
+
new RegExp(`^(export\\s+)?type\\s+${escapedSymbol}\\b`),
|
|
4316
|
+
new RegExp(`^(export\\s+)?(const|let|var)\\s+${escapedSymbol}\\b`),
|
|
4317
|
+
new RegExp(`^(export\\s+)?enum\\s+${escapedSymbol}\\b`)
|
|
4318
|
+
];
|
|
4319
|
+
for (const file of tsFiles) {
|
|
4320
|
+
try {
|
|
4321
|
+
const content = fs12.readFileSync(file, "utf-8");
|
|
4322
|
+
const lines = content.split("\n");
|
|
4323
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4324
|
+
const trimmed = lines[i].trim();
|
|
4325
|
+
for (const pattern of declPatterns) {
|
|
4326
|
+
if (pattern.test(trimmed)) {
|
|
4327
|
+
const projectRoot = getProjectRoot();
|
|
4328
|
+
const relPath = path12.relative(projectRoot, file);
|
|
4329
|
+
return [
|
|
4330
|
+
`\u{1F4CD} **Go to Definition** (regex fallback)`,
|
|
4331
|
+
`\u{1F4C4} File: \`${relPath}\``,
|
|
4332
|
+
`\u{1F4CF} Line: ${i + 1}`,
|
|
4333
|
+
`\u2514 ${trimmed.substring(0, 120)}`,
|
|
4334
|
+
"",
|
|
4335
|
+
`\u{1F4A1} Install typescript-language-server for more accurate results.`
|
|
4336
|
+
].join("\n");
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
}
|
|
4340
|
+
} catch {
|
|
4341
|
+
continue;
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
return `\u{1F4CD} **Go to Definition** (regex fallback) \u2014 "${symbolName}"
|
|
4345
|
+
\u26A0\uFE0F Cannot find definition.
|
|
4346
|
+
\u{1F4A1} Install typescript-language-server for more accurate results.`;
|
|
4347
|
+
} catch (err) {
|
|
4348
|
+
return `Error in fallback grep definition: ${err instanceof Error ? err.message : String(err)}`;
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
// src/manifest.ts
|
|
4353
|
+
var MEMORY_TOPICS = ["decisions", "glossary", "architecture", "conventions", "known-issues"];
|
|
4354
|
+
function registerAllTools(server) {
|
|
4355
|
+
server.tool(
|
|
4356
|
+
"smart_grep",
|
|
4357
|
+
"Narrow down to the specific code you need. Locates functions or text patterns \u2014 returns filename, line number, and 3 lines of context.",
|
|
4358
|
+
{
|
|
4359
|
+
query: z.string().min(1).describe("Regex pattern to search for"),
|
|
4360
|
+
targetFolder: z.string().optional().describe("Target folder (default: project root, max depth 3)"),
|
|
4361
|
+
maxResults: z.number().min(1).max(100).optional().default(30).describe("Max results to return"),
|
|
4362
|
+
extensions: z.array(z.string()).optional().describe("Filter results by file extensions (e.g. ['ts', 'js'])")
|
|
4363
|
+
},
|
|
4364
|
+
async (params) => {
|
|
4365
|
+
try {
|
|
4366
|
+
const result = await handleSmartGrep(params);
|
|
4367
|
+
return { content: [{ type: "text", text: result }] };
|
|
4368
|
+
} catch (err) {
|
|
4369
|
+
return { content: [{ type: "text", text: `Error in smart_grep: ${err}` }], isError: true };
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
4372
|
+
);
|
|
4373
|
+
server.tool(
|
|
4374
|
+
"smart_file_picker",
|
|
4375
|
+
"Read only what you need. Opens a file with smart chunking \u2014 signatures + imports for large files, full content for small ones.",
|
|
4376
|
+
{
|
|
4377
|
+
filePath: z.string().min(1).describe("Path to the file to read"),
|
|
4378
|
+
startLine: z.number().min(1).optional().describe("Start line (1-indexed)"),
|
|
4379
|
+
endLine: z.number().min(1).optional().describe("End line (1-indexed)"),
|
|
4380
|
+
chunkStrategy: z.enum(["full", "smart", "outline"]).optional().default("smart").describe("Read strategy: full=entire file, smart=signatures+imports, outline=exported symbols only")
|
|
4381
|
+
},
|
|
4382
|
+
async (params) => {
|
|
4383
|
+
try {
|
|
4384
|
+
const result = await handleSmartFilePicker(params);
|
|
4385
|
+
return { content: [{ type: "text", text: result }] };
|
|
4386
|
+
} catch (err) {
|
|
4387
|
+
return { content: [{ type: "text", text: `Error in smart_file_picker: ${err}` }], isError: true };
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
);
|
|
4391
|
+
server.tool(
|
|
4392
|
+
"precise_diff_editor",
|
|
4393
|
+
"Edit code with safety net. Search-and-replace with fuzzy fallback + automatic versioned backup. Use action:'rollback' to undo edits.",
|
|
4394
|
+
{
|
|
4395
|
+
filePath: z.string().min(1).describe("Path to the file to edit"),
|
|
4396
|
+
action: z.enum(["rollback"]).optional().describe("Set to 'rollback' to restore from backup"),
|
|
4397
|
+
edits: z.array(z.object({
|
|
4398
|
+
searchBlock: z.string().min(1).describe("Code block to replace (MUST be exact or fuzzy match)"),
|
|
4399
|
+
replaceBlock: z.string().describe("Replacement code block"),
|
|
4400
|
+
allowMultiple: z.boolean().optional().default(false).describe("Allow multiple replacements"),
|
|
4401
|
+
fuzzyThreshold: z.number().min(0).max(1).optional().default(0.85).describe("Fuzzy match threshold (0.0-1.0)")
|
|
4402
|
+
})).min(1).max(10).optional().describe("Array of edits (max 10)"),
|
|
4403
|
+
dryRun: z.boolean().optional().default(false).describe("Preview changes without writing to disk"),
|
|
4404
|
+
version: z.union([z.number().min(1), z.literal("list")]).optional().describe("Backup version to restore (1=newest, omit=latest, 'list'=show versions)")
|
|
4405
|
+
},
|
|
4406
|
+
async (params) => {
|
|
4407
|
+
try {
|
|
4408
|
+
const result = await handlePreciseDiffEditor(params);
|
|
4409
|
+
return { content: [{ type: "text", text: result }] };
|
|
4410
|
+
} catch (err) {
|
|
4411
|
+
return { content: [{ type: "text", text: `Error in precise_diff_editor: ${err}` }], isError: true };
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
);
|
|
4415
|
+
server.tool(
|
|
4416
|
+
"batch_file_writer",
|
|
4417
|
+
"Create files in batch (up to 15). Before creating many, question whether each one needs to exist or could be merged into an existing module.",
|
|
4418
|
+
{
|
|
4419
|
+
files: z.array(z.object({
|
|
4420
|
+
filePath: z.string().min(1).describe("File path to create"),
|
|
4421
|
+
content: z.string().describe("File content"),
|
|
4422
|
+
instructions: z.string().min(1).describe("Reason for creating the file")
|
|
4423
|
+
})).min(1).max(15).describe("Array of files to create (max 15)")
|
|
4424
|
+
},
|
|
4425
|
+
async (params) => {
|
|
4426
|
+
try {
|
|
4427
|
+
const result = await handleBatchFileWriter(params);
|
|
4428
|
+
return { content: [{ type: "text", text: result }] };
|
|
4429
|
+
} catch (err) {
|
|
4430
|
+
return { content: [{ type: "text", text: `Error in batch_file_writer: ${err}` }], isError: true };
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
);
|
|
4434
|
+
server.tool(
|
|
4435
|
+
"execute_safe_test",
|
|
4436
|
+
"Run tests, lint, or typecheck with timeout protection and circuit breaker. Use after every edit to verify you didn't break anything.",
|
|
4437
|
+
{
|
|
4438
|
+
task: z.enum(["test", "build", "lint", "typecheck", "custom"]).describe("Task to execute"),
|
|
4439
|
+
customCommand: z.string().optional().describe("Custom command (only if task='custom')"),
|
|
4440
|
+
timeout: z.number().min(5).max(180).optional().default(60).describe("Timeout in seconds (default: 60s, max: 180s)")
|
|
4441
|
+
},
|
|
4442
|
+
async (params) => {
|
|
4443
|
+
try {
|
|
4444
|
+
const result = await handleSafeTerminalExec(params);
|
|
4445
|
+
return { content: [{ type: "text", text: result }] };
|
|
4446
|
+
} catch (err) {
|
|
4447
|
+
return { content: [{ type: "text", text: `Error in execute_safe_test: ${err}` }], isError: true };
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
);
|
|
4451
|
+
server.tool(
|
|
4452
|
+
"code_reviewer",
|
|
4453
|
+
"Senior code review that catches what the writer missed. Supports focus modes: correctness, conventions, security, performance, and over-engineering.",
|
|
4454
|
+
{
|
|
4455
|
+
files: z.array(z.string().min(1)).max(10).optional().describe("Files to review (auto-detects via git diff if omitted)"),
|
|
4456
|
+
focus: z.enum(["correctness", "conventions", "security", "performance", "over-engineering"]).optional().default("correctness").describe("Review focus"),
|
|
4457
|
+
customCriteria: z.string().optional().describe("Custom review criteria"),
|
|
4458
|
+
format: z.enum(["text", "json"]).optional().default("text").describe("Output format")
|
|
4459
|
+
},
|
|
4460
|
+
async (params) => {
|
|
4461
|
+
try {
|
|
4462
|
+
const result = await handleCodeReviewer(params);
|
|
4463
|
+
return { content: [{ type: "text", text: result }] };
|
|
4464
|
+
} catch (err) {
|
|
4465
|
+
return { content: [{ type: "text", text: `Error in code_reviewer: ${err}` }], isError: true };
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
);
|
|
4469
|
+
server.tool(
|
|
4470
|
+
"project_conventions",
|
|
4471
|
+
"Know your stack before you code. Detects framework, test runner, package manager, import alias, monorepo workspaces.",
|
|
4472
|
+
{
|
|
4473
|
+
forceRescan: z.boolean().optional().default(false).describe("Force rescan (ignore cache)")
|
|
4474
|
+
},
|
|
4475
|
+
async (params) => {
|
|
4476
|
+
try {
|
|
4477
|
+
const result = await handleProjectConventions(params);
|
|
4478
|
+
return { content: [{ type: "text", text: result }] };
|
|
4479
|
+
} catch (err) {
|
|
4480
|
+
return { content: [{ type: "text", text: `Error in project_conventions: ${err}` }], isError: true };
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
);
|
|
4484
|
+
server.tool(
|
|
4485
|
+
"get_session_memory",
|
|
4486
|
+
"Check what you've done this session: modified files, unresolved failures, tool history, memories. Accepts optional topic to load a specific memory file.",
|
|
4487
|
+
{
|
|
4488
|
+
topic: z.enum(MEMORY_TOPICS).optional().describe("Load a specific memory topic (decisions, glossary, architecture, conventions, known-issues)")
|
|
4489
|
+
},
|
|
4490
|
+
async (params) => {
|
|
4491
|
+
try {
|
|
4492
|
+
const memory = getSessionMemory(params.topic);
|
|
4493
|
+
return { content: [{ type: "text", text: JSON.stringify(memory, null, 2) }] };
|
|
4494
|
+
} catch (err) {
|
|
4495
|
+
return { content: [{ type: "text", text: `Error getting session memory: ${err}` }], isError: true };
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
);
|
|
4499
|
+
server.tool(
|
|
4500
|
+
"lsp_query",
|
|
4501
|
+
"Jump to definition, find references, inspect types, or rename symbols. Uses TypeScript Language Server \u2014 falls back to regex when LSP is unavailable.",
|
|
4502
|
+
{
|
|
4503
|
+
filePath: z.string().min(1).describe("Path to the file containing the symbol"),
|
|
4504
|
+
line: z.number().min(0).describe("Line number (0-indexed)"),
|
|
4505
|
+
character: z.number().min(0).describe("Character position (0-indexed)"),
|
|
4506
|
+
action: z.enum(["def", "refs", "type", "rename"]).describe("Action: def/go to definition, refs/find references, type/get type info, rename/rename symbol"),
|
|
4507
|
+
newName: z.string().optional().describe("New name (required for action:'rename')")
|
|
4508
|
+
},
|
|
4509
|
+
async (params) => {
|
|
4510
|
+
try {
|
|
4511
|
+
const result = await handleLspQuery(params);
|
|
4512
|
+
return { content: [{ type: "text", text: result }] };
|
|
4513
|
+
} catch (err) {
|
|
4514
|
+
return { content: [{ type: "text", text: `Error in lsp_query: ${err}` }], isError: true };
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
);
|
|
4518
|
+
server.tool(
|
|
4519
|
+
"git_log",
|
|
4520
|
+
"Gets structured git commit history for the project or a specific file.",
|
|
4521
|
+
{
|
|
4522
|
+
maxCount: z.number().min(1).max(100).optional().default(10).describe("Max number of commits to return"),
|
|
4523
|
+
filePath: z.string().optional().describe("Filter history to a specific file")
|
|
4524
|
+
},
|
|
4525
|
+
async (params) => {
|
|
4526
|
+
try {
|
|
4527
|
+
const result = await handleGitLog(params);
|
|
4528
|
+
return { content: [{ type: "text", text: result }] };
|
|
4529
|
+
} catch (err) {
|
|
4530
|
+
return { content: [{ type: "text", text: `Error in git_log: ${err}` }], isError: true };
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
);
|
|
4534
|
+
server.tool(
|
|
4535
|
+
"kuma_reflect",
|
|
4536
|
+
"Reflect on your current session: on-track/off-track detection, drift warnings, loop detection, and a concrete suggestion for the next action.",
|
|
4537
|
+
{
|
|
4538
|
+
goal: z.string().optional().describe("Optional goal to check against (falls back to session goal)")
|
|
4539
|
+
},
|
|
4540
|
+
async (params) => {
|
|
4541
|
+
try {
|
|
4542
|
+
const result = await handleReflect(params);
|
|
4543
|
+
return { content: [{ type: "text", text: result }] };
|
|
4544
|
+
} catch (err) {
|
|
4545
|
+
return { content: [{ type: "text", text: `Error in kuma_reflect: ${err}` }], isError: true };
|
|
4546
|
+
}
|
|
4547
|
+
}
|
|
4548
|
+
);
|
|
4549
|
+
server.tool(
|
|
4550
|
+
"write_memory",
|
|
4551
|
+
"Persist project knowledge to `.kuma/memories/`. Use for decisions (ADR) and glossary (domain terms). Auto-generated topics (architecture, conventions, known-issues) update automatically.",
|
|
4552
|
+
{
|
|
4553
|
+
topic: z.enum(["decisions", "glossary"]).describe("Memory topic: 'decisions' (ADR) or 'glossary' (domain terms)"),
|
|
4554
|
+
content: z.string().min(1).describe("Markdown content to write"),
|
|
4555
|
+
mode: z.enum(["append", "prepend", "overwrite"]).optional().default("append").describe("Write mode: append (default), prepend, or overwrite")
|
|
4556
|
+
},
|
|
4557
|
+
async (params) => {
|
|
4558
|
+
try {
|
|
4559
|
+
const result = handleWriteMemory(params);
|
|
4560
|
+
return { content: [{ type: "text", text: result }] };
|
|
4561
|
+
} catch (err) {
|
|
4562
|
+
return { content: [{ type: "text", text: `Error in write_memory: ${err}` }], isError: true };
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
);
|
|
4566
|
+
server.tool(
|
|
4567
|
+
"git_diff",
|
|
4568
|
+
"Shows structured git diff output (staged or unstaged). Supports file filter, ref ranges, and context line control.",
|
|
4569
|
+
{
|
|
4570
|
+
filePath: z.string().optional().describe("Filter diff to a specific file"),
|
|
4571
|
+
staged: z.boolean().optional().default(false).describe("Show staged changes (--cached)"),
|
|
4572
|
+
contextLines: z.number().min(1).max(20).optional().default(3).describe("Number of context lines per chunk"),
|
|
4573
|
+
baseRef: z.string().optional().describe("Base git ref for comparing (e.g. main, HEAD~1)"),
|
|
4574
|
+
targetRef: z.string().optional().describe("Target git ref (defaults to HEAD if baseRef is set)")
|
|
4575
|
+
},
|
|
4576
|
+
async (params) => {
|
|
4577
|
+
try {
|
|
4578
|
+
const result = await handleGitDiff(params);
|
|
4579
|
+
return { content: [{ type: "text", text: result }] };
|
|
4580
|
+
} catch (err) {
|
|
4581
|
+
return { content: [{ type: "text", text: `Error in git_diff: ${err}` }], isError: true };
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
);
|
|
4585
|
+
server.tool(
|
|
4586
|
+
"project_structure",
|
|
4587
|
+
"Displays a tree view of the project's directory layout. Helps AI understand where files live before reading or editing.",
|
|
4588
|
+
{
|
|
4589
|
+
depth: z.number().min(1).max(6).optional().default(3).describe("Max directory depth to show (1-6)"),
|
|
4590
|
+
folderOnly: z.boolean().optional().default(false).describe("Show folders only (no files)"),
|
|
4591
|
+
includePattern: z.string().optional().describe("Only show items containing this string"),
|
|
4592
|
+
excludePattern: z.string().optional().describe("Exclude items containing this string")
|
|
4593
|
+
},
|
|
4594
|
+
async (params) => {
|
|
4595
|
+
try {
|
|
4596
|
+
const result = await handleProjectStructure(params);
|
|
4597
|
+
return { content: [{ type: "text", text: result }] };
|
|
4598
|
+
} catch (err) {
|
|
4599
|
+
return { content: [{ type: "text", text: `Error in project_structure: ${err}` }], isError: true };
|
|
4600
|
+
}
|
|
4601
|
+
}
|
|
4602
|
+
);
|
|
4603
|
+
server.tool(
|
|
4604
|
+
"search_session_memory",
|
|
4605
|
+
"Search through session memory by keyword. Covers tool call history, memory files, errors, modified files, and search results.",
|
|
4606
|
+
{
|
|
4607
|
+
query: z.string().min(1).describe("Keyword to search for in session memory"),
|
|
4608
|
+
limit: z.number().min(1).max(100).optional().default(20).describe("Maximum results to return")
|
|
4609
|
+
},
|
|
4610
|
+
async (params) => {
|
|
4611
|
+
try {
|
|
4612
|
+
const result = searchSessionMemory(params);
|
|
4613
|
+
return { content: [{ type: "text", text: result }] };
|
|
4614
|
+
} catch (err) {
|
|
4615
|
+
return { content: [{ type: "text", text: `Error in search_session_memory: ${err}` }], isError: true };
|
|
4616
|
+
}
|
|
4617
|
+
}
|
|
4618
|
+
);
|
|
4619
|
+
server.tool(
|
|
4620
|
+
"static_analysis",
|
|
4621
|
+
"Runs available linters/checkers (ESLint, TypeScript, Prettier, Ruff) and parses output into structured results. Auto-detects tools from project config.",
|
|
4622
|
+
{
|
|
4623
|
+
tool: z.enum(["eslint", "tsc", "prettier", "ruff", "all"]).optional().default("all").describe("Which tool to run (default: auto-detect all available)"),
|
|
4624
|
+
files: z.array(z.string()).optional().describe("Specific files to check (default: whole project)"),
|
|
4625
|
+
autoFix: z.boolean().optional().default(false).describe("Auto-fix fixable issues (eslint --fix, prettier --write)"),
|
|
4626
|
+
timeout: z.number().min(5).max(180).optional().default(60).describe("Timeout in seconds per tool")
|
|
4627
|
+
},
|
|
4628
|
+
async (params) => {
|
|
4629
|
+
try {
|
|
4630
|
+
const result = await handleStaticAnalysis(params);
|
|
4631
|
+
return { content: [{ type: "text", text: result }] };
|
|
4632
|
+
} catch (err) {
|
|
4633
|
+
return { content: [{ type: "text", text: `Error in static_analysis: ${err}` }], isError: true };
|
|
4634
|
+
}
|
|
4635
|
+
}
|
|
4636
|
+
);
|
|
4637
|
+
console.error("[Manifest] Registered 16 tools.");
|
|
4638
|
+
}
|
|
4639
|
+
|
|
4640
|
+
// src/index.ts
|
|
4641
|
+
var SERVER_NAME = "kuma";
|
|
4642
|
+
var SERVER_VERSION = JSON.parse(
|
|
4643
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf-8")
|
|
4644
|
+
).version;
|
|
4645
|
+
async function main() {
|
|
4646
|
+
sessionMemory.init({
|
|
4647
|
+
projectRoot: process.cwd(),
|
|
4648
|
+
startTime: Date.now()
|
|
4649
|
+
});
|
|
4650
|
+
const server = new McpServer(
|
|
4651
|
+
{
|
|
4652
|
+
name: SERVER_NAME,
|
|
4653
|
+
version: SERVER_VERSION
|
|
4654
|
+
},
|
|
4655
|
+
{
|
|
4656
|
+
// Capabilities declaration
|
|
4657
|
+
capabilities: {
|
|
4658
|
+
tools: {},
|
|
4659
|
+
resources: {}
|
|
4660
|
+
}
|
|
4661
|
+
}
|
|
4662
|
+
);
|
|
4663
|
+
registerAllTools(server);
|
|
4664
|
+
const transport = new StdioServerTransport();
|
|
4665
|
+
console.error(`[${SERVER_NAME} v${SERVER_VERSION}] Starting MCP server...`);
|
|
4666
|
+
console.error(`[${SERVER_NAME}] Project root: ${process.cwd()}`);
|
|
4667
|
+
console.error(
|
|
4668
|
+
`[${SERVER_NAME}] Session started: ${(/* @__PURE__ */ new Date()).toISOString()}`
|
|
4669
|
+
);
|
|
4670
|
+
await server.connect(transport);
|
|
4671
|
+
console.error(
|
|
4672
|
+
`[${SERVER_NAME}] Server connected via stdio. Waiting for requests...`
|
|
4673
|
+
);
|
|
4674
|
+
}
|
|
4675
|
+
main().catch((err) => {
|
|
4676
|
+
console.error(`[${SERVER_NAME}] Fatal error:`, err);
|
|
4677
|
+
process.exit(1);
|
|
4678
|
+
});
|