@hasna/terminal 0.7.4 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +35 -0
- package/dist/loop-detector.js +75 -0
- package/dist/mcp/server.js +48 -0
- package/package.json +1 -1
- package/src/cli.tsx +30 -0
- package/src/loop-detector.ts +96 -0
- package/src/mcp/server.ts +63 -0
package/dist/cli.js
CHANGED
|
@@ -83,6 +83,12 @@ if (args[0] === "exec") {
|
|
|
83
83
|
const { parseOutput, estimateTokens } = await import("./parsers/index.js");
|
|
84
84
|
const { recordSaving, recordUsage } = await import("./economy.js");
|
|
85
85
|
const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
|
|
86
|
+
const { detectLoop } = await import("./loop-detector.js");
|
|
87
|
+
// Loop detection — suggest narrowing if running full test suite repeatedly
|
|
88
|
+
const loop = detectLoop(command);
|
|
89
|
+
if (loop.detected) {
|
|
90
|
+
console.error(`[open-terminal] loop detected: test run #${loop.iteration}${loop.suggestedNarrow ? ` — try: ${loop.suggestedNarrow}` : " — consider narrowing to specific test file"}`);
|
|
91
|
+
}
|
|
86
92
|
// Rewrite command if possible
|
|
87
93
|
const rw = rewriteCommand(command);
|
|
88
94
|
const actualCmd = rw.changed ? rw.rewritten : command;
|
|
@@ -452,6 +458,35 @@ else if (args[0] === "sessions") {
|
|
|
452
458
|
}
|
|
453
459
|
}
|
|
454
460
|
}
|
|
461
|
+
// ── Overview command ─────────────────────────────────────────────────────────
|
|
462
|
+
else if (args[0] === "overview") {
|
|
463
|
+
const { existsSync, readFileSync } = await import("fs");
|
|
464
|
+
const { execSync } = await import("child_process");
|
|
465
|
+
const run = (cmd) => { try {
|
|
466
|
+
return execSync(cmd, { encoding: "utf8", cwd: process.cwd() }).trim();
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return "";
|
|
470
|
+
} };
|
|
471
|
+
let pkg = null;
|
|
472
|
+
try {
|
|
473
|
+
pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
474
|
+
}
|
|
475
|
+
catch { }
|
|
476
|
+
if (pkg) {
|
|
477
|
+
console.log(`${pkg.name}@${pkg.version}`);
|
|
478
|
+
if (pkg.scripts) {
|
|
479
|
+
console.log("\nScripts:");
|
|
480
|
+
for (const [k, v] of Object.entries(pkg.scripts).slice(0, 10))
|
|
481
|
+
console.log(` ${k}: ${v}`);
|
|
482
|
+
}
|
|
483
|
+
if (pkg.dependencies)
|
|
484
|
+
console.log(`\nDeps: ${Object.keys(pkg.dependencies).join(", ")}`);
|
|
485
|
+
}
|
|
486
|
+
const src = run("ls -1 src/ 2>/dev/null || ls -1 lib/ 2>/dev/null");
|
|
487
|
+
if (src)
|
|
488
|
+
console.log(`\nSource:\n${src.split("\n").map(f => " " + f).join("\n")}`);
|
|
489
|
+
}
|
|
455
490
|
// ── Repo command ─────────────────────────────────────────────────────────────
|
|
456
491
|
else if (args[0] === "repo") {
|
|
457
492
|
const { execSync } = await import("child_process");
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Edit-test loop detector — detects repetitive test→edit→test patterns
|
|
2
|
+
// and suggests narrowing to specific test files
|
|
3
|
+
const history = [];
|
|
4
|
+
const MAX_HISTORY = 20;
|
|
5
|
+
// Detect test commands
|
|
6
|
+
const TEST_PATTERNS = [
|
|
7
|
+
/\bbun\s+test\b/, /\bnpm\s+test\b/, /\bnpx\s+jest\b/, /\bnpx\s+vitest\b/,
|
|
8
|
+
/\bpnpm\s+test\b/, /\byarn\s+test\b/, /\bpytest\b/, /\bgo\s+test\b/,
|
|
9
|
+
/\bcargo\s+test\b/, /\brspec\b/, /\bphpunit\b/, /\bmocha\b/,
|
|
10
|
+
];
|
|
11
|
+
function isTestCommand(cmd) {
|
|
12
|
+
return TEST_PATTERNS.some(p => p.test(cmd));
|
|
13
|
+
}
|
|
14
|
+
function isFullSuiteCommand(cmd) {
|
|
15
|
+
// Full suite = test command without specific file/pattern
|
|
16
|
+
if (!isTestCommand(cmd))
|
|
17
|
+
return false;
|
|
18
|
+
// If it has a specific file or --grep, it's already narrowed
|
|
19
|
+
if (/\.(test|spec)\.(ts|tsx|js|jsx|py|rs|go)/.test(cmd))
|
|
20
|
+
return false;
|
|
21
|
+
if (/--grep|--filter|-t\s/.test(cmd))
|
|
22
|
+
return false;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
/** Record a command execution and detect loops */
|
|
26
|
+
export function detectLoop(command) {
|
|
27
|
+
history.push({ command, timestamp: Date.now() });
|
|
28
|
+
if (history.length > MAX_HISTORY)
|
|
29
|
+
history.shift();
|
|
30
|
+
if (!isTestCommand(command)) {
|
|
31
|
+
return { detected: false, iteration: 0, testCommand: command };
|
|
32
|
+
}
|
|
33
|
+
// Count consecutive test runs (allowing non-test commands between them)
|
|
34
|
+
let testCount = 0;
|
|
35
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
36
|
+
if (isTestCommand(history[i].command))
|
|
37
|
+
testCount++;
|
|
38
|
+
// If we hit a non-test, non-edit command, stop counting
|
|
39
|
+
// (edits are invisible to us since we only see exec'd commands)
|
|
40
|
+
}
|
|
41
|
+
if (testCount < 3 || !isFullSuiteCommand(command)) {
|
|
42
|
+
return { detected: false, iteration: testCount, testCommand: command };
|
|
43
|
+
}
|
|
44
|
+
// Detected loop — suggest narrowing
|
|
45
|
+
// Try to find a recently-mentioned test file in recent commands
|
|
46
|
+
let suggestedNarrow;
|
|
47
|
+
// Look for file paths in recent history that could be test targets
|
|
48
|
+
for (let i = history.length - 2; i >= Math.max(0, history.length - 10); i--) {
|
|
49
|
+
const cmd = history[i].command;
|
|
50
|
+
// Look for edited/touched files
|
|
51
|
+
const fileMatch = cmd.match(/(\S+\.(ts|tsx|js|jsx|py|rs|go))\b/);
|
|
52
|
+
if (fileMatch && !isTestCommand(cmd)) {
|
|
53
|
+
const file = fileMatch[1];
|
|
54
|
+
// Suggest corresponding test file
|
|
55
|
+
const testFile = file.replace(/\.(ts|tsx|js|jsx)$/, ".test.$1");
|
|
56
|
+
suggestedNarrow = command.replace(/\b(test)\b/, `test ${testFile}`);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Fallback: suggest adding --grep or specific file
|
|
61
|
+
if (!suggestedNarrow) {
|
|
62
|
+
suggestedNarrow = undefined; // Can't determine which file
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
detected: true,
|
|
66
|
+
iteration: testCount,
|
|
67
|
+
testCommand: command,
|
|
68
|
+
suggestedNarrow,
|
|
69
|
+
reason: `Full test suite run ${testCount} times. Consider narrowing to specific test file.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Reset loop detection (e.g., on session start) */
|
|
73
|
+
export function resetLoopDetector() {
|
|
74
|
+
history.length = 0;
|
|
75
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -416,6 +416,54 @@ export function createServer() {
|
|
|
416
416
|
const sessions = listSessions(limit ?? 20);
|
|
417
417
|
return { content: [{ type: "text", text: JSON.stringify(sessions) }] };
|
|
418
418
|
});
|
|
419
|
+
// ── project_overview: orient agent in one call ─────────────────────────────
|
|
420
|
+
server.tool("project_overview", "Get project overview in one call — package.json info, source structure, config files. Replaces: cat package.json + ls src/ + cat tsconfig.json.", {
|
|
421
|
+
path: z.string().optional().describe("Project root (default: cwd)"),
|
|
422
|
+
}, async ({ path }) => {
|
|
423
|
+
const cwd = path ?? process.cwd();
|
|
424
|
+
const [pkgResult, srcResult, configResult] = await Promise.all([
|
|
425
|
+
exec("cat package.json 2>/dev/null", cwd),
|
|
426
|
+
exec("ls -1 src/ 2>/dev/null || ls -1 lib/ 2>/dev/null || ls -1 app/ 2>/dev/null", cwd),
|
|
427
|
+
exec("ls -1 *.json *.config.* .env* tsconfig* 2>/dev/null", cwd),
|
|
428
|
+
]);
|
|
429
|
+
let pkg = null;
|
|
430
|
+
try {
|
|
431
|
+
pkg = JSON.parse(pkgResult.stdout);
|
|
432
|
+
}
|
|
433
|
+
catch { }
|
|
434
|
+
return {
|
|
435
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
436
|
+
name: pkg?.name,
|
|
437
|
+
version: pkg?.version,
|
|
438
|
+
scripts: pkg?.scripts,
|
|
439
|
+
dependencies: pkg?.dependencies ? Object.keys(pkg.dependencies) : [],
|
|
440
|
+
devDependencies: pkg?.devDependencies ? Object.keys(pkg.devDependencies) : [],
|
|
441
|
+
sourceFiles: srcResult.stdout.split("\n").filter(l => l.trim()),
|
|
442
|
+
configFiles: configResult.stdout.split("\n").filter(l => l.trim()),
|
|
443
|
+
}) }],
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
// ── last_commit: what just happened ───────────────────────────────────────
|
|
447
|
+
server.tool("last_commit", "Get details of the last commit — hash, message, files changed, diff stats. Replaces: git log -1 + git show --stat + git diff HEAD~1.", {
|
|
448
|
+
path: z.string().optional().describe("Repo path (default: cwd)"),
|
|
449
|
+
}, async ({ path }) => {
|
|
450
|
+
const cwd = path ?? process.cwd();
|
|
451
|
+
const [logResult, statResult] = await Promise.all([
|
|
452
|
+
exec("git log -1 --format='%H%n%s%n%an%n%ai'", cwd),
|
|
453
|
+
exec("git show --stat --format='' HEAD", cwd),
|
|
454
|
+
]);
|
|
455
|
+
const [hash, message, author, date] = logResult.stdout.split("\n");
|
|
456
|
+
const filesChanged = statResult.stdout.split("\n").filter(l => l.trim() && !l.includes("changed"));
|
|
457
|
+
return {
|
|
458
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
459
|
+
hash: hash?.trim(),
|
|
460
|
+
message: message?.trim(),
|
|
461
|
+
author: author?.trim(),
|
|
462
|
+
date: date?.trim(),
|
|
463
|
+
filesChanged,
|
|
464
|
+
}) }],
|
|
465
|
+
};
|
|
466
|
+
});
|
|
419
467
|
// ── read_file: cached file reading ─────────────────────────────────────────
|
|
420
468
|
server.tool("read_file", "Read a file with session caching. Second read of unchanged file returns instantly from cache. Supports offset/limit for pagination without re-reading.", {
|
|
421
469
|
path: z.string().describe("File path"),
|
package/package.json
CHANGED
package/src/cli.tsx
CHANGED
|
@@ -87,6 +87,13 @@ if (args[0] === "exec") {
|
|
|
87
87
|
const { parseOutput, estimateTokens } = await import("./parsers/index.js");
|
|
88
88
|
const { recordSaving, recordUsage } = await import("./economy.js");
|
|
89
89
|
const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
|
|
90
|
+
const { detectLoop } = await import("./loop-detector.js");
|
|
91
|
+
|
|
92
|
+
// Loop detection — suggest narrowing if running full test suite repeatedly
|
|
93
|
+
const loop = detectLoop(command);
|
|
94
|
+
if (loop.detected) {
|
|
95
|
+
console.error(`[open-terminal] loop detected: test run #${loop.iteration}${loop.suggestedNarrow ? ` — try: ${loop.suggestedNarrow}` : " — consider narrowing to specific test file"}`);
|
|
96
|
+
}
|
|
90
97
|
|
|
91
98
|
// Rewrite command if possible
|
|
92
99
|
const rw = rewriteCommand(command);
|
|
@@ -436,6 +443,29 @@ else if (args[0] === "sessions") {
|
|
|
436
443
|
}
|
|
437
444
|
}
|
|
438
445
|
|
|
446
|
+
// ── Overview command ─────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
else if (args[0] === "overview") {
|
|
449
|
+
const { existsSync, readFileSync } = await import("fs");
|
|
450
|
+
const { execSync } = await import("child_process");
|
|
451
|
+
const run = (cmd: string) => { try { return execSync(cmd, { encoding: "utf8", cwd: process.cwd() }).trim(); } catch { return ""; } };
|
|
452
|
+
|
|
453
|
+
let pkg: any = null;
|
|
454
|
+
try { pkg = JSON.parse(readFileSync("package.json", "utf8")); } catch {}
|
|
455
|
+
|
|
456
|
+
if (pkg) {
|
|
457
|
+
console.log(`${pkg.name}@${pkg.version}`);
|
|
458
|
+
if (pkg.scripts) {
|
|
459
|
+
console.log("\nScripts:");
|
|
460
|
+
for (const [k, v] of Object.entries(pkg.scripts).slice(0, 10)) console.log(` ${k}: ${v}`);
|
|
461
|
+
}
|
|
462
|
+
if (pkg.dependencies) console.log(`\nDeps: ${Object.keys(pkg.dependencies).join(", ")}`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const src = run("ls -1 src/ 2>/dev/null || ls -1 lib/ 2>/dev/null");
|
|
466
|
+
if (src) console.log(`\nSource:\n${src.split("\n").map(f => " " + f).join("\n")}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
439
469
|
// ── Repo command ─────────────────────────────────────────────────────────────
|
|
440
470
|
|
|
441
471
|
else if (args[0] === "repo") {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Edit-test loop detector — detects repetitive test→edit→test patterns
|
|
2
|
+
// and suggests narrowing to specific test files
|
|
3
|
+
|
|
4
|
+
interface CommandRecord {
|
|
5
|
+
command: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const history: CommandRecord[] = [];
|
|
10
|
+
const MAX_HISTORY = 20;
|
|
11
|
+
|
|
12
|
+
// Detect test commands
|
|
13
|
+
const TEST_PATTERNS = [
|
|
14
|
+
/\bbun\s+test\b/, /\bnpm\s+test\b/, /\bnpx\s+jest\b/, /\bnpx\s+vitest\b/,
|
|
15
|
+
/\bpnpm\s+test\b/, /\byarn\s+test\b/, /\bpytest\b/, /\bgo\s+test\b/,
|
|
16
|
+
/\bcargo\s+test\b/, /\brspec\b/, /\bphpunit\b/, /\bmocha\b/,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function isTestCommand(cmd: string): boolean {
|
|
20
|
+
return TEST_PATTERNS.some(p => p.test(cmd));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isFullSuiteCommand(cmd: string): boolean {
|
|
24
|
+
// Full suite = test command without specific file/pattern
|
|
25
|
+
if (!isTestCommand(cmd)) return false;
|
|
26
|
+
// If it has a specific file or --grep, it's already narrowed
|
|
27
|
+
if (/\.(test|spec)\.(ts|tsx|js|jsx|py|rs|go)/.test(cmd)) return false;
|
|
28
|
+
if (/--grep|--filter|-t\s/.test(cmd)) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LoopContext {
|
|
33
|
+
detected: boolean;
|
|
34
|
+
iteration: number;
|
|
35
|
+
testCommand: string;
|
|
36
|
+
suggestedNarrow?: string;
|
|
37
|
+
reason?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Record a command execution and detect loops */
|
|
41
|
+
export function detectLoop(command: string): LoopContext {
|
|
42
|
+
history.push({ command, timestamp: Date.now() });
|
|
43
|
+
if (history.length > MAX_HISTORY) history.shift();
|
|
44
|
+
|
|
45
|
+
if (!isTestCommand(command)) {
|
|
46
|
+
return { detected: false, iteration: 0, testCommand: command };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Count consecutive test runs (allowing non-test commands between them)
|
|
50
|
+
let testCount = 0;
|
|
51
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
52
|
+
if (isTestCommand(history[i].command)) testCount++;
|
|
53
|
+
// If we hit a non-test, non-edit command, stop counting
|
|
54
|
+
// (edits are invisible to us since we only see exec'd commands)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (testCount < 3 || !isFullSuiteCommand(command)) {
|
|
58
|
+
return { detected: false, iteration: testCount, testCommand: command };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Detected loop — suggest narrowing
|
|
62
|
+
// Try to find a recently-mentioned test file in recent commands
|
|
63
|
+
let suggestedNarrow: string | undefined;
|
|
64
|
+
|
|
65
|
+
// Look for file paths in recent history that could be test targets
|
|
66
|
+
for (let i = history.length - 2; i >= Math.max(0, history.length - 10); i--) {
|
|
67
|
+
const cmd = history[i].command;
|
|
68
|
+
// Look for edited/touched files
|
|
69
|
+
const fileMatch = cmd.match(/(\S+\.(ts|tsx|js|jsx|py|rs|go))\b/);
|
|
70
|
+
if (fileMatch && !isTestCommand(cmd)) {
|
|
71
|
+
const file = fileMatch[1];
|
|
72
|
+
// Suggest corresponding test file
|
|
73
|
+
const testFile = file.replace(/\.(ts|tsx|js|jsx)$/, ".test.$1");
|
|
74
|
+
suggestedNarrow = command.replace(/\b(test)\b/, `test ${testFile}`);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fallback: suggest adding --grep or specific file
|
|
80
|
+
if (!suggestedNarrow) {
|
|
81
|
+
suggestedNarrow = undefined; // Can't determine which file
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
detected: true,
|
|
86
|
+
iteration: testCount,
|
|
87
|
+
testCommand: command,
|
|
88
|
+
suggestedNarrow,
|
|
89
|
+
reason: `Full test suite run ${testCount} times. Consider narrowing to specific test file.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Reset loop detection (e.g., on session start) */
|
|
94
|
+
export function resetLoopDetector(): void {
|
|
95
|
+
history.length = 0;
|
|
96
|
+
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -587,6 +587,69 @@ export function createServer(): McpServer {
|
|
|
587
587
|
}
|
|
588
588
|
);
|
|
589
589
|
|
|
590
|
+
// ── project_overview: orient agent in one call ─────────────────────────────
|
|
591
|
+
|
|
592
|
+
server.tool(
|
|
593
|
+
"project_overview",
|
|
594
|
+
"Get project overview in one call — package.json info, source structure, config files. Replaces: cat package.json + ls src/ + cat tsconfig.json.",
|
|
595
|
+
{
|
|
596
|
+
path: z.string().optional().describe("Project root (default: cwd)"),
|
|
597
|
+
},
|
|
598
|
+
async ({ path }) => {
|
|
599
|
+
const cwd = path ?? process.cwd();
|
|
600
|
+
const [pkgResult, srcResult, configResult] = await Promise.all([
|
|
601
|
+
exec("cat package.json 2>/dev/null", cwd),
|
|
602
|
+
exec("ls -1 src/ 2>/dev/null || ls -1 lib/ 2>/dev/null || ls -1 app/ 2>/dev/null", cwd),
|
|
603
|
+
exec("ls -1 *.json *.config.* .env* tsconfig* 2>/dev/null", cwd),
|
|
604
|
+
]);
|
|
605
|
+
|
|
606
|
+
let pkg: any = null;
|
|
607
|
+
try { pkg = JSON.parse(pkgResult.stdout); } catch {}
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
611
|
+
name: pkg?.name,
|
|
612
|
+
version: pkg?.version,
|
|
613
|
+
scripts: pkg?.scripts,
|
|
614
|
+
dependencies: pkg?.dependencies ? Object.keys(pkg.dependencies) : [],
|
|
615
|
+
devDependencies: pkg?.devDependencies ? Object.keys(pkg.devDependencies) : [],
|
|
616
|
+
sourceFiles: srcResult.stdout.split("\n").filter(l => l.trim()),
|
|
617
|
+
configFiles: configResult.stdout.split("\n").filter(l => l.trim()),
|
|
618
|
+
}) }],
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
// ── last_commit: what just happened ───────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
server.tool(
|
|
626
|
+
"last_commit",
|
|
627
|
+
"Get details of the last commit — hash, message, files changed, diff stats. Replaces: git log -1 + git show --stat + git diff HEAD~1.",
|
|
628
|
+
{
|
|
629
|
+
path: z.string().optional().describe("Repo path (default: cwd)"),
|
|
630
|
+
},
|
|
631
|
+
async ({ path }) => {
|
|
632
|
+
const cwd = path ?? process.cwd();
|
|
633
|
+
const [logResult, statResult] = await Promise.all([
|
|
634
|
+
exec("git log -1 --format='%H%n%s%n%an%n%ai'", cwd),
|
|
635
|
+
exec("git show --stat --format='' HEAD", cwd),
|
|
636
|
+
]);
|
|
637
|
+
|
|
638
|
+
const [hash, message, author, date] = logResult.stdout.split("\n");
|
|
639
|
+
const filesChanged = statResult.stdout.split("\n").filter(l => l.trim() && !l.includes("changed"));
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
643
|
+
hash: hash?.trim(),
|
|
644
|
+
message: message?.trim(),
|
|
645
|
+
author: author?.trim(),
|
|
646
|
+
date: date?.trim(),
|
|
647
|
+
filesChanged,
|
|
648
|
+
}) }],
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
);
|
|
652
|
+
|
|
590
653
|
// ── read_file: cached file reading ─────────────────────────────────────────
|
|
591
654
|
|
|
592
655
|
server.tool(
|