token-pilot 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +4 -0
- package/dist/core/validation.d.ts +17 -0
- package/dist/core/validation.js +44 -0
- package/dist/handlers/explore-area.d.ts +9 -0
- package/dist/handlers/explore-area.js +280 -0
- package/dist/handlers/outline.d.ts +6 -0
- package/dist/handlers/outline.js +2 -2
- package/dist/handlers/smart-diff.d.ts +35 -0
- package/dist/handlers/smart-diff.js +257 -0
- package/dist/server.js +53 -2
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "token-pilot",
|
|
3
3
|
"displayName": "Token Pilot",
|
|
4
|
-
"description": "Reduces token consumption by 80-95% via AST-aware lazy file reading.
|
|
5
|
-
"version": "0.
|
|
4
|
+
"description": "Reduces token consumption by 80-95% via AST-aware lazy file reading. 16 MCP tools for structural code reading, symbol navigation, and cross-file search.",
|
|
5
|
+
"version": "0.10.0",
|
|
6
6
|
"author": "Digital-Threads",
|
|
7
7
|
"repository": "https://github.com/Digital-Threads/token-pilot",
|
|
8
8
|
"license": "MIT",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to Token Pilot will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.10.0] - 2026-03-14
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`smart_diff` tool** — structural git diff with AST symbol mapping. Shows which functions/classes were modified/added/removed instead of raw patch output. Supports scopes: `unstaged`, `staged`, `commit` (ref required), `branch` (ref required). Small diffs (<=30 lines) include actual hunks, large diffs show summary. Returns `rawTokens` for precise savings analytics.
|
|
12
|
+
- **`explore_area` tool** — one-call directory exploration combining outline + imports + tests + git changes. Replaces 3-5 separate tool calls when starting work on an area. Sections: `outline` (recursive depth 2), `imports` (external deps + who imports this area), `tests` (matching test/spec files), `changes` (recent git log). All sections run in parallel via `Promise.allSettled`.
|
|
13
|
+
- **26 new tests** — smart_diff parser (10), symbol mapping (5), validation (11). Total: 170 tests (was 144).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **16 tools** (was 14) — added `smart_diff`, `explore_area`
|
|
17
|
+
- **MCP instructions** — updated workflow: `project_overview → explore_area → smart_read → read_symbol → read_for_edit → edit → smart_diff`
|
|
18
|
+
- **`outlineDir` and `CODE_EXTENSIONS` exported** from outline.ts for reuse by explore_area
|
|
19
|
+
|
|
8
20
|
## [0.9.0] - 2026-03-08
|
|
9
21
|
|
|
10
22
|
### Added
|
package/README.md
CHANGED
|
@@ -173,6 +173,8 @@ For more control, you can add rules to your project:
|
|
|
173
173
|
| `find_unused` | manual | Detect dead code — unused functions, classes, variables. |
|
|
174
174
|
| `code_audit` | multiple `Grep` | Code quality issues in one call: TODO/FIXME comments, deprecated symbols, structural code patterns (via ast-grep), decorator search. |
|
|
175
175
|
| `module_info` | manual analysis | Module dependency analysis: dependencies, dependents, public API, unused deps. Use for architecture understanding and dependency cleanup. |
|
|
176
|
+
| `smart_diff` | raw `git diff` | Structural diff with AST symbol mapping — shows which functions/classes changed instead of raw patch. Scopes: unstaged, staged, commit, branch. |
|
|
177
|
+
| `explore_area` | outline + related_files + git log | One-call directory exploration: structure, imports, tests, recent changes. Replaces 3-5 separate calls. |
|
|
176
178
|
|
|
177
179
|
### Analytics
|
|
178
180
|
|
|
@@ -351,6 +353,8 @@ src/
|
|
|
351
353
|
code-audit.ts — code_audit handler (TODOs, deprecated, patterns)
|
|
352
354
|
project-overview.ts — project_overview (dual-detection + confidence)
|
|
353
355
|
module-info.ts — module_info handler (deps, dependents, API, unused)
|
|
356
|
+
smart-diff.ts — smart_diff handler (structural git diff + symbol mapping)
|
|
357
|
+
explore-area.ts — explore_area handler (outline + imports + tests + changes)
|
|
354
358
|
non-code.ts — JSON/YAML/MD/TOML structural summaries
|
|
355
359
|
export-ast-index.ts — AST export for context-mode BM25
|
|
356
360
|
git/
|
|
@@ -110,6 +110,23 @@ export interface ModuleInfoArgs {
|
|
|
110
110
|
check?: 'deps' | 'dependents' | 'api' | 'unused-deps' | 'all';
|
|
111
111
|
}
|
|
112
112
|
export declare function validateModuleInfoArgs(args: unknown): ModuleInfoArgs;
|
|
113
|
+
/**
|
|
114
|
+
* Validate smart_diff arguments.
|
|
115
|
+
*/
|
|
116
|
+
export interface SmartDiffArgs {
|
|
117
|
+
scope?: 'unstaged' | 'staged' | 'commit' | 'branch';
|
|
118
|
+
path?: string;
|
|
119
|
+
ref?: string;
|
|
120
|
+
}
|
|
121
|
+
export declare function validateSmartDiffArgs(args: unknown): SmartDiffArgs;
|
|
122
|
+
/**
|
|
123
|
+
* Validate explore_area arguments.
|
|
124
|
+
*/
|
|
125
|
+
export interface ExploreAreaArgs {
|
|
126
|
+
path: string;
|
|
127
|
+
include?: Array<'outline' | 'imports' | 'tests' | 'changes'>;
|
|
128
|
+
}
|
|
129
|
+
export declare function validateExploreAreaArgs(args: unknown): ExploreAreaArgs;
|
|
113
130
|
/** Detect roots that would cause ast-index to scan the entire filesystem */
|
|
114
131
|
export declare function isDangerousRoot(root: string): boolean;
|
|
115
132
|
//# sourceMappingURL=validation.d.ts.map
|
package/dist/core/validation.js
CHANGED
|
@@ -293,6 +293,50 @@ export function validateModuleInfoArgs(args) {
|
|
|
293
293
|
check: check ?? 'all',
|
|
294
294
|
};
|
|
295
295
|
}
|
|
296
|
+
export function validateSmartDiffArgs(args) {
|
|
297
|
+
if (!args || typeof args !== 'object')
|
|
298
|
+
return { scope: 'unstaged' };
|
|
299
|
+
const a = args;
|
|
300
|
+
let scope;
|
|
301
|
+
if (a.scope !== undefined && a.scope !== null) {
|
|
302
|
+
const validScopes = ['unstaged', 'staged', 'commit', 'branch'];
|
|
303
|
+
if (typeof a.scope !== 'string' || !validScopes.includes(a.scope)) {
|
|
304
|
+
throw new Error(`"scope" must be one of: ${validScopes.join(', ')}`);
|
|
305
|
+
}
|
|
306
|
+
scope = a.scope;
|
|
307
|
+
}
|
|
308
|
+
const ref = optionalString(a.ref, 'ref');
|
|
309
|
+
if ((scope === 'commit' || scope === 'branch') && !ref) {
|
|
310
|
+
throw new Error(`"ref" is required when scope="${scope}".`);
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
scope: scope ?? 'unstaged',
|
|
314
|
+
path: optionalString(a.path, 'path'),
|
|
315
|
+
ref,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const VALID_EXPLORE_SECTIONS = ['outline', 'imports', 'tests', 'changes'];
|
|
319
|
+
export function validateExploreAreaArgs(args) {
|
|
320
|
+
if (!args || typeof args !== 'object') {
|
|
321
|
+
throw new Error('Arguments must be an object with a "path" parameter.');
|
|
322
|
+
}
|
|
323
|
+
const a = args;
|
|
324
|
+
if (typeof a.path !== 'string' || a.path.length === 0) {
|
|
325
|
+
throw new Error('Required parameter "path" must be a non-empty string.');
|
|
326
|
+
}
|
|
327
|
+
if (a.include !== undefined && a.include !== null) {
|
|
328
|
+
if (!Array.isArray(a.include)) {
|
|
329
|
+
throw new Error('"include" must be an array of section names.');
|
|
330
|
+
}
|
|
331
|
+
for (const item of a.include) {
|
|
332
|
+
if (typeof item !== 'string' || !VALID_EXPLORE_SECTIONS.includes(item)) {
|
|
333
|
+
throw new Error(`Each element of "include" must be one of: ${VALID_EXPLORE_SECTIONS.join(', ')}. Got: "${item}"`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { path: a.path, include: a.include };
|
|
337
|
+
}
|
|
338
|
+
return { path: a.path };
|
|
339
|
+
}
|
|
296
340
|
/** Detect roots that would cause ast-index to scan the entire filesystem */
|
|
297
341
|
export function isDangerousRoot(root) {
|
|
298
342
|
const normalized = root.replace(/\/+$/, '') || '/';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AstIndexClient } from '../ast-index/client.js';
|
|
2
|
+
import type { ExploreAreaArgs } from '../core/validation.js';
|
|
3
|
+
export declare function handleExploreArea(args: ExploreAreaArgs, projectRoot: string, astIndex: AstIndexClient): Promise<{
|
|
4
|
+
content: Array<{
|
|
5
|
+
type: 'text';
|
|
6
|
+
text: string;
|
|
7
|
+
}>;
|
|
8
|
+
}>;
|
|
9
|
+
//# sourceMappingURL=explore-area.d.ts.map
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
4
|
+
import { resolve, relative, basename, dirname } from 'node:path';
|
|
5
|
+
import { resolveSafePath } from '../core/validation.js';
|
|
6
|
+
import { outlineDir, CODE_EXTENSIONS } from './outline.js';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
// ──────────────────────────────────────────────
|
|
9
|
+
// Constants
|
|
10
|
+
// ──────────────────────────────────────────────
|
|
11
|
+
const MAX_IMPORT_FILES = 20;
|
|
12
|
+
const MAX_OUTPUT_LINES = 500;
|
|
13
|
+
// ──────────────────────────────────────────────
|
|
14
|
+
// Handler
|
|
15
|
+
// ──────────────────────────────────────────────
|
|
16
|
+
export async function handleExploreArea(args, projectRoot, astIndex) {
|
|
17
|
+
// Resolve path — if it points to a file, use its parent directory
|
|
18
|
+
let absPath = resolveSafePath(projectRoot, args.path);
|
|
19
|
+
const pathStat = await stat(absPath).catch(() => null);
|
|
20
|
+
if (!pathStat) {
|
|
21
|
+
return {
|
|
22
|
+
content: [{ type: 'text', text: `Path "${args.path}" not found.` }],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (!pathStat.isDirectory()) {
|
|
26
|
+
absPath = dirname(absPath);
|
|
27
|
+
}
|
|
28
|
+
const relDir = relative(projectRoot, absPath) || '.';
|
|
29
|
+
const include = args.include ?? ['outline', 'imports', 'tests', 'changes'];
|
|
30
|
+
// Collect code files for import/test analysis
|
|
31
|
+
const codeFiles = await listCodeFiles(absPath);
|
|
32
|
+
// Run all sections in parallel
|
|
33
|
+
const [outlineSection, importsSection, testsSection, changesSection] = await Promise.allSettled([
|
|
34
|
+
include.includes('outline') ? buildOutlineSection(absPath, projectRoot, astIndex) : Promise.resolve(null),
|
|
35
|
+
include.includes('imports') ? buildImportsSection(codeFiles, absPath, projectRoot, astIndex) : Promise.resolve(null),
|
|
36
|
+
include.includes('tests') ? buildTestsSection(codeFiles, absPath, projectRoot) : Promise.resolve(null),
|
|
37
|
+
include.includes('changes') ? buildChangesSection(relDir, projectRoot) : Promise.resolve(null),
|
|
38
|
+
]);
|
|
39
|
+
// Assemble output
|
|
40
|
+
const lines = [];
|
|
41
|
+
const subdirCount = await countSubdirs(absPath);
|
|
42
|
+
lines.push(`AREA: ${relDir}/ (${codeFiles.length} code files${subdirCount > 0 ? `, ${subdirCount} subdirs` : ''})`);
|
|
43
|
+
lines.push('');
|
|
44
|
+
// Outline
|
|
45
|
+
const outlineLines = extractResult(outlineSection);
|
|
46
|
+
if (outlineLines) {
|
|
47
|
+
lines.push('STRUCTURE:');
|
|
48
|
+
lines.push(...outlineLines);
|
|
49
|
+
lines.push('');
|
|
50
|
+
}
|
|
51
|
+
// Imports
|
|
52
|
+
const importLines = extractResult(importsSection);
|
|
53
|
+
if (importLines) {
|
|
54
|
+
lines.push(...importLines);
|
|
55
|
+
}
|
|
56
|
+
// Tests
|
|
57
|
+
const testLines = extractResult(testsSection);
|
|
58
|
+
if (testLines) {
|
|
59
|
+
lines.push(...testLines);
|
|
60
|
+
}
|
|
61
|
+
// Changes
|
|
62
|
+
const changeLines = extractResult(changesSection);
|
|
63
|
+
if (changeLines) {
|
|
64
|
+
lines.push(...changeLines);
|
|
65
|
+
}
|
|
66
|
+
// Truncate if needed
|
|
67
|
+
if (lines.length > MAX_OUTPUT_LINES) {
|
|
68
|
+
lines.length = MAX_OUTPUT_LINES;
|
|
69
|
+
lines.push('... truncated. Use outline() on specific subdirectories for details.');
|
|
70
|
+
}
|
|
71
|
+
lines.push('HINT: Use smart_read(file) for details, read_symbol(path, symbol) for source code, find_usages(symbol) for references.');
|
|
72
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
73
|
+
}
|
|
74
|
+
// ──────────────────────────────────────────────
|
|
75
|
+
// Outline section — reuses outlineDir from outline.ts
|
|
76
|
+
// ──────────────────────────────────────────────
|
|
77
|
+
async function buildOutlineSection(absPath, projectRoot, astIndex) {
|
|
78
|
+
const sections = [];
|
|
79
|
+
await outlineDir(absPath, sections, 0, 2, projectRoot, astIndex);
|
|
80
|
+
return sections;
|
|
81
|
+
}
|
|
82
|
+
// ──────────────────────────────────────────────
|
|
83
|
+
// Imports section — aggregate external deps + who imports this area
|
|
84
|
+
// ──────────────────────────────────────────────
|
|
85
|
+
async function buildImportsSection(codeFiles, absPath, projectRoot, astIndex) {
|
|
86
|
+
if (!astIndex.isAvailable() || astIndex.isDisabled() || astIndex.isOversized()) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
const filesToAnalyze = codeFiles.slice(0, MAX_IMPORT_FILES);
|
|
90
|
+
const externalDeps = new Set();
|
|
91
|
+
const internalDeps = new Set();
|
|
92
|
+
const relDir = relative(projectRoot, absPath) || '.';
|
|
93
|
+
// Get imports for each file
|
|
94
|
+
const importResults = await Promise.allSettled(filesToAnalyze.map(f => astIndex.fileImports(f)));
|
|
95
|
+
for (const result of importResults) {
|
|
96
|
+
if (result.status !== 'fulfilled' || !result.value)
|
|
97
|
+
continue;
|
|
98
|
+
for (const imp of result.value) {
|
|
99
|
+
const source = imp.source;
|
|
100
|
+
if (!source)
|
|
101
|
+
continue;
|
|
102
|
+
if (source.startsWith('.') || source.startsWith('/')) {
|
|
103
|
+
// Internal import — track if it's outside this area
|
|
104
|
+
const resolved = resolve(absPath, source);
|
|
105
|
+
if (!resolved.startsWith(absPath)) {
|
|
106
|
+
const relImport = relative(projectRoot, resolved).replace(/\.[^.]+$/, '');
|
|
107
|
+
internalDeps.add(relImport);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// External package
|
|
112
|
+
const pkg = source.startsWith('@') ? source.split('/').slice(0, 2).join('/') : source.split('/')[0];
|
|
113
|
+
externalDeps.add(pkg);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Find who imports files from this area (reverse dependencies)
|
|
118
|
+
const importedBy = new Set();
|
|
119
|
+
const fileBasenames = filesToAnalyze.map(f => basename(f).replace(/\.[^.]+$/, ''));
|
|
120
|
+
const refResults = await Promise.allSettled(fileBasenames.slice(0, 10).map(name => astIndex.refs(name, 10)));
|
|
121
|
+
for (const result of refResults) {
|
|
122
|
+
if (result.status !== 'fulfilled' || !result.value)
|
|
123
|
+
continue;
|
|
124
|
+
const refs = result.value;
|
|
125
|
+
if (refs.imports) {
|
|
126
|
+
for (const imp of refs.imports) {
|
|
127
|
+
const impFile = imp.path;
|
|
128
|
+
if (!impFile)
|
|
129
|
+
continue;
|
|
130
|
+
const relFile = relative(projectRoot, impFile);
|
|
131
|
+
// Only include files outside this area
|
|
132
|
+
if (!relFile.startsWith(relDir)) {
|
|
133
|
+
importedBy.add(relFile.replace(/\.[^.]+$/, ''));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const lines = [];
|
|
139
|
+
if (externalDeps.size > 0) {
|
|
140
|
+
const deps = Array.from(externalDeps).sort().slice(0, 20);
|
|
141
|
+
lines.push(`IMPORTS: ${deps.join(', ')}${externalDeps.size > 20 ? ` ... (${externalDeps.size} total)` : ''}`);
|
|
142
|
+
}
|
|
143
|
+
if (internalDeps.size > 0) {
|
|
144
|
+
const deps = Array.from(internalDeps).sort().slice(0, 10);
|
|
145
|
+
lines.push(`INTERNAL DEPS: ${deps.join(', ')}${internalDeps.size > 10 ? ` ... (${internalDeps.size} total)` : ''}`);
|
|
146
|
+
}
|
|
147
|
+
if (importedBy.size > 0) {
|
|
148
|
+
const importers = Array.from(importedBy).sort().slice(0, 10);
|
|
149
|
+
lines.push(`IMPORTED BY: ${importers.join(', ')}${importedBy.size > 10 ? ` ... (${importedBy.size} total)` : ''}`);
|
|
150
|
+
}
|
|
151
|
+
if (lines.length > 0)
|
|
152
|
+
lines.push('');
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
// ──────────────────────────────────────────────
|
|
156
|
+
// Tests section — find test/spec files matching area files
|
|
157
|
+
// ──────────────────────────────────────────────
|
|
158
|
+
async function buildTestsSection(codeFiles, absPath, projectRoot) {
|
|
159
|
+
const testFiles = [];
|
|
160
|
+
const areaFileNames = new Set(codeFiles.map(f => basename(f).replace(/\.[^.]+$/, '')));
|
|
161
|
+
// Scan for test files: check area dir + common test dirs
|
|
162
|
+
const dirsToScan = [absPath];
|
|
163
|
+
// Check for sibling __tests__ or tests directory
|
|
164
|
+
const parent = dirname(absPath);
|
|
165
|
+
const areaName = basename(absPath);
|
|
166
|
+
const testDirCandidates = [
|
|
167
|
+
resolve(absPath, '__tests__'),
|
|
168
|
+
resolve(absPath, 'tests'),
|
|
169
|
+
resolve(absPath, 'test'),
|
|
170
|
+
resolve(parent, '__tests__', areaName),
|
|
171
|
+
resolve(parent, 'tests', areaName),
|
|
172
|
+
];
|
|
173
|
+
for (const testDir of testDirCandidates) {
|
|
174
|
+
const testDirStat = await stat(testDir).catch(() => null);
|
|
175
|
+
if (testDirStat?.isDirectory()) {
|
|
176
|
+
dirsToScan.push(testDir);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Also check project-level test directories
|
|
180
|
+
const projectTestDirs = [
|
|
181
|
+
resolve(projectRoot, 'tests'),
|
|
182
|
+
resolve(projectRoot, 'test'),
|
|
183
|
+
resolve(projectRoot, '__tests__'),
|
|
184
|
+
];
|
|
185
|
+
for (const testDir of projectTestDirs) {
|
|
186
|
+
if (dirsToScan.includes(testDir))
|
|
187
|
+
continue;
|
|
188
|
+
const testDirStat = await stat(testDir).catch(() => null);
|
|
189
|
+
if (testDirStat?.isDirectory()) {
|
|
190
|
+
dirsToScan.push(testDir);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const dir of dirsToScan) {
|
|
194
|
+
try {
|
|
195
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
if (!entry.isFile())
|
|
198
|
+
continue;
|
|
199
|
+
const name = entry.name;
|
|
200
|
+
if (name.includes('.test.') || name.includes('.spec.') || name.includes('_test.') || name.includes('_spec.')) {
|
|
201
|
+
// Check if this test corresponds to an area file
|
|
202
|
+
const testBase = name
|
|
203
|
+
.replace(/\.(test|spec)\./, '.')
|
|
204
|
+
.replace(/_(test|spec)\./, '.')
|
|
205
|
+
.replace(/\.[^.]+$/, '');
|
|
206
|
+
if (areaFileNames.has(testBase) || dir !== absPath) {
|
|
207
|
+
const relPath = relative(projectRoot, resolve(dir, name));
|
|
208
|
+
if (!testFiles.includes(relPath)) {
|
|
209
|
+
testFiles.push(relPath);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch { /* skip unreadable dirs */ }
|
|
216
|
+
}
|
|
217
|
+
if (testFiles.length === 0)
|
|
218
|
+
return [];
|
|
219
|
+
const lines = [];
|
|
220
|
+
lines.push(`TESTS: ${testFiles.join(', ')}`);
|
|
221
|
+
lines.push('');
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
|
224
|
+
// ──────────────────────────────────────────────
|
|
225
|
+
// Changes section — recent git log for this area
|
|
226
|
+
// ──────────────────────────────────────────────
|
|
227
|
+
async function buildChangesSection(relDir, projectRoot) {
|
|
228
|
+
try {
|
|
229
|
+
const { stdout } = await execFileAsync('git', ['log', '--oneline', '-5', '--', relDir], { cwd: projectRoot, timeout: 5000 });
|
|
230
|
+
if (!stdout.trim())
|
|
231
|
+
return [];
|
|
232
|
+
const lines = [];
|
|
233
|
+
lines.push('RECENT CHANGES:');
|
|
234
|
+
for (const line of stdout.trim().split('\n')) {
|
|
235
|
+
lines.push(` ${line}`);
|
|
236
|
+
}
|
|
237
|
+
lines.push('');
|
|
238
|
+
return lines;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// ──────────────────────────────────────────────
|
|
245
|
+
// Helpers
|
|
246
|
+
// ──────────────────────────────────────────────
|
|
247
|
+
function extractResult(settled) {
|
|
248
|
+
if (settled.status === 'fulfilled' && settled.value && settled.value.length > 0) {
|
|
249
|
+
return settled.value;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
async function listCodeFiles(dirPath) {
|
|
254
|
+
try {
|
|
255
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
256
|
+
const files = [];
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
if (entry.isFile()) {
|
|
259
|
+
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
|
260
|
+
if (CODE_EXTENSIONS.has(ext)) {
|
|
261
|
+
files.push(resolve(dirPath, entry.name));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return files.sort();
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async function countSubdirs(dirPath) {
|
|
272
|
+
try {
|
|
273
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
274
|
+
return entries.filter(e => e.isDirectory()).length;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
//# sourceMappingURL=explore-area.js.map
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { AstIndexClient } from '../ast-index/client.js';
|
|
2
2
|
import type { OutlineArgs } from '../core/validation.js';
|
|
3
|
+
export declare const CODE_EXTENSIONS: Set<string>;
|
|
3
4
|
export declare function handleOutline(args: OutlineArgs, projectRoot: string, astIndex: AstIndexClient): Promise<{
|
|
4
5
|
content: Array<{
|
|
5
6
|
type: 'text';
|
|
6
7
|
text: string;
|
|
7
8
|
}>;
|
|
8
9
|
}>;
|
|
10
|
+
/**
|
|
11
|
+
* Outline a single directory. When depth < maxDepth and recursive,
|
|
12
|
+
* recurse into subdirectories. Otherwise show file counts only.
|
|
13
|
+
*/
|
|
14
|
+
export declare function outlineDir(absPath: string, sections: string[], depth: number, maxDepth: number, projectRoot: string, astIndex: AstIndexClient): Promise<void>;
|
|
9
15
|
//# sourceMappingURL=outline.d.ts.map
|
package/dist/handlers/outline.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { resolve, basename, relative } from 'node:path';
|
|
3
3
|
import { resolveSafePath } from '../core/validation.js';
|
|
4
|
-
const CODE_EXTENSIONS = new Set([
|
|
4
|
+
export const CODE_EXTENSIONS = new Set([
|
|
5
5
|
'ts', 'tsx', 'js', 'jsx', 'mjs', 'py', 'go', 'rs', 'java', 'kt', 'kts',
|
|
6
6
|
'swift', 'cs', 'cpp', 'cc', 'cxx', 'hpp', 'c', 'h', 'php', 'rb', 'scala',
|
|
7
7
|
'dart', 'lua', 'sh', 'bash', 'sql', 'r', 'vue', 'svelte', 'pl', 'pm',
|
|
@@ -35,7 +35,7 @@ const MAX_OUTLINE_LINES = 500;
|
|
|
35
35
|
* Outline a single directory. When depth < maxDepth and recursive,
|
|
36
36
|
* recurse into subdirectories. Otherwise show file counts only.
|
|
37
37
|
*/
|
|
38
|
-
async function outlineDir(absPath, sections, depth, maxDepth, projectRoot, astIndex) {
|
|
38
|
+
export async function outlineDir(absPath, sections, depth, maxDepth, projectRoot, astIndex) {
|
|
39
39
|
// Guard: stop if output is already too large
|
|
40
40
|
if (sections.length >= MAX_OUTLINE_LINES) {
|
|
41
41
|
if (!sections[sections.length - 1]?.startsWith('⚠')) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AstIndexClient } from '../ast-index/client.js';
|
|
2
|
+
import type { SmartDiffArgs } from '../core/validation.js';
|
|
3
|
+
import type { FileStructure } from '../types.js';
|
|
4
|
+
interface FileDiff {
|
|
5
|
+
path: string;
|
|
6
|
+
oldPath?: string;
|
|
7
|
+
addedLines: number;
|
|
8
|
+
removedLines: number;
|
|
9
|
+
hunks: DiffHunk[];
|
|
10
|
+
isBinary: boolean;
|
|
11
|
+
isNew: boolean;
|
|
12
|
+
isDeleted: boolean;
|
|
13
|
+
}
|
|
14
|
+
interface DiffHunk {
|
|
15
|
+
newStart: number;
|
|
16
|
+
newCount: number;
|
|
17
|
+
lines: string[];
|
|
18
|
+
}
|
|
19
|
+
interface SymbolChange {
|
|
20
|
+
name: string;
|
|
21
|
+
kind: string;
|
|
22
|
+
changeType: 'MODIFIED' | 'ADDED' | 'REMOVED';
|
|
23
|
+
lineRange: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function handleSmartDiff(args: SmartDiffArgs, projectRoot: string, astIndex: AstIndexClient): Promise<{
|
|
26
|
+
content: Array<{
|
|
27
|
+
type: 'text';
|
|
28
|
+
text: string;
|
|
29
|
+
}>;
|
|
30
|
+
rawTokens: number;
|
|
31
|
+
}>;
|
|
32
|
+
export declare function parseUnifiedDiff(raw: string): FileDiff[];
|
|
33
|
+
export declare function mapHunksToSymbols(hunks: DiffHunk[], structure: FileStructure): SymbolChange[];
|
|
34
|
+
export {};
|
|
35
|
+
//# sourceMappingURL=smart-diff.d.ts.map
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { estimateTokens } from '../core/token-estimator.js';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
// ──────────────────────────────────────────────
|
|
7
|
+
// Handler
|
|
8
|
+
// ──────────────────────────────────────────────
|
|
9
|
+
const SMALL_DIFF_THRESHOLD = 30;
|
|
10
|
+
const MAX_FILES = 50;
|
|
11
|
+
const MAX_OUTPUT_LINES = 500;
|
|
12
|
+
export async function handleSmartDiff(args, projectRoot, astIndex) {
|
|
13
|
+
// 1. Build git command
|
|
14
|
+
const gitArgs = buildGitArgs(args, projectRoot);
|
|
15
|
+
// 2. Execute git diff
|
|
16
|
+
let rawDiff;
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execFileAsync('git', gitArgs, {
|
|
19
|
+
cwd: projectRoot,
|
|
20
|
+
timeout: 10000,
|
|
21
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
22
|
+
});
|
|
23
|
+
rawDiff = stdout;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
27
|
+
if (msg.includes('not a git repository') || msg.includes('fatal:')) {
|
|
28
|
+
return { content: [{ type: 'text', text: 'Not a git repository. smart_diff requires git.' }], rawTokens: 0 };
|
|
29
|
+
}
|
|
30
|
+
return { content: [{ type: 'text', text: `git diff failed: ${msg}` }], rawTokens: 0 };
|
|
31
|
+
}
|
|
32
|
+
const rawTokens = estimateTokens(rawDiff);
|
|
33
|
+
if (!rawDiff.trim()) {
|
|
34
|
+
const scopeLabel = args.scope ?? 'unstaged';
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: 'text', text: `NO CHANGES (${scopeLabel}): working tree is clean.` }],
|
|
37
|
+
rawTokens: 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// 3. Parse unified diff
|
|
41
|
+
const fileDiffs = parseUnifiedDiff(rawDiff);
|
|
42
|
+
if (fileDiffs.length === 0) {
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: 'text', text: 'NO CHANGES: diff parsed but no file changes found.' }],
|
|
45
|
+
rawTokens,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// 4. Map hunks to symbols (parallel, capped)
|
|
49
|
+
const filesToProcess = fileDiffs.slice(0, MAX_FILES);
|
|
50
|
+
const symbolChanges = new Map();
|
|
51
|
+
const outlineResults = await Promise.allSettled(filesToProcess
|
|
52
|
+
.filter(f => !f.isBinary && !f.isDeleted)
|
|
53
|
+
.map(async (f) => {
|
|
54
|
+
const absPath = resolve(projectRoot, f.path);
|
|
55
|
+
const structure = await astIndex.outline(absPath);
|
|
56
|
+
return { path: f.path, structure };
|
|
57
|
+
}));
|
|
58
|
+
for (const result of outlineResults) {
|
|
59
|
+
if (result.status === 'fulfilled' && result.value.structure) {
|
|
60
|
+
const { path, structure } = result.value;
|
|
61
|
+
const fd = filesToProcess.find(f => f.path === path);
|
|
62
|
+
if (fd) {
|
|
63
|
+
symbolChanges.set(path, mapHunksToSymbols(fd.hunks, structure));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// 5. Format output
|
|
68
|
+
const output = formatSmartDiff(fileDiffs, filesToProcess, symbolChanges, args, rawTokens);
|
|
69
|
+
return { content: [{ type: 'text', text: output }], rawTokens };
|
|
70
|
+
}
|
|
71
|
+
// ──────────────────────────────────────────────
|
|
72
|
+
// Git command builder
|
|
73
|
+
// ──────────────────────────────────────────────
|
|
74
|
+
function buildGitArgs(args, projectRoot) {
|
|
75
|
+
const base = [];
|
|
76
|
+
switch (args.scope) {
|
|
77
|
+
case 'staged':
|
|
78
|
+
base.push('diff', '--cached');
|
|
79
|
+
break;
|
|
80
|
+
case 'commit':
|
|
81
|
+
base.push('show', '--format=', args.ref);
|
|
82
|
+
break;
|
|
83
|
+
case 'branch':
|
|
84
|
+
base.push('diff', `${args.ref}...HEAD`);
|
|
85
|
+
break;
|
|
86
|
+
case 'unstaged':
|
|
87
|
+
default:
|
|
88
|
+
base.push('diff');
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if (args.path) {
|
|
92
|
+
base.push('--', args.path);
|
|
93
|
+
}
|
|
94
|
+
return base;
|
|
95
|
+
}
|
|
96
|
+
// ──────────────────────────────────────────────
|
|
97
|
+
// Unified diff parser
|
|
98
|
+
// ──────────────────────────────────────────────
|
|
99
|
+
export function parseUnifiedDiff(raw) {
|
|
100
|
+
const files = [];
|
|
101
|
+
let current = null;
|
|
102
|
+
let currentHunk = null;
|
|
103
|
+
for (const line of raw.split('\n')) {
|
|
104
|
+
// New file
|
|
105
|
+
if (line.startsWith('diff --git ')) {
|
|
106
|
+
if (current)
|
|
107
|
+
files.push(current);
|
|
108
|
+
const match = line.match(/diff --git a\/(.+?) b\/(.+)/);
|
|
109
|
+
current = {
|
|
110
|
+
path: match?.[2] ?? '',
|
|
111
|
+
oldPath: match?.[1] !== match?.[2] ? match?.[1] : undefined,
|
|
112
|
+
addedLines: 0,
|
|
113
|
+
removedLines: 0,
|
|
114
|
+
hunks: [],
|
|
115
|
+
isBinary: false,
|
|
116
|
+
isNew: false,
|
|
117
|
+
isDeleted: false,
|
|
118
|
+
};
|
|
119
|
+
currentHunk = null;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!current)
|
|
123
|
+
continue;
|
|
124
|
+
if (line.startsWith('new file mode')) {
|
|
125
|
+
current.isNew = true;
|
|
126
|
+
}
|
|
127
|
+
else if (line.startsWith('deleted file mode')) {
|
|
128
|
+
current.isDeleted = true;
|
|
129
|
+
}
|
|
130
|
+
else if (line.startsWith('Binary files')) {
|
|
131
|
+
current.isBinary = true;
|
|
132
|
+
}
|
|
133
|
+
else if (line.startsWith('rename from ')) {
|
|
134
|
+
current.oldPath = line.slice(12);
|
|
135
|
+
}
|
|
136
|
+
else if (line.startsWith('@@ ')) {
|
|
137
|
+
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
|
138
|
+
currentHunk = {
|
|
139
|
+
newStart: match ? parseInt(match[1], 10) : 0,
|
|
140
|
+
newCount: match?.[2] ? parseInt(match[2], 10) : 1,
|
|
141
|
+
lines: [],
|
|
142
|
+
};
|
|
143
|
+
current.hunks.push(currentHunk);
|
|
144
|
+
}
|
|
145
|
+
else if (currentHunk) {
|
|
146
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
147
|
+
current.addedLines++;
|
|
148
|
+
currentHunk.lines.push(line);
|
|
149
|
+
}
|
|
150
|
+
else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
151
|
+
current.removedLines++;
|
|
152
|
+
currentHunk.lines.push(line);
|
|
153
|
+
}
|
|
154
|
+
else if (line.startsWith(' ')) {
|
|
155
|
+
currentHunk.lines.push(line);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (current)
|
|
160
|
+
files.push(current);
|
|
161
|
+
return files;
|
|
162
|
+
}
|
|
163
|
+
// ──────────────────────────────────────────────
|
|
164
|
+
// Symbol mapping
|
|
165
|
+
// ──────────────────────────────────────────────
|
|
166
|
+
function flattenSymbols(symbols, prefix = '') {
|
|
167
|
+
const result = [];
|
|
168
|
+
for (const sym of symbols) {
|
|
169
|
+
const name = prefix ? `${prefix}.${sym.name}` : sym.name;
|
|
170
|
+
result.push({
|
|
171
|
+
name,
|
|
172
|
+
kind: sym.kind,
|
|
173
|
+
start: sym.location.startLine,
|
|
174
|
+
end: sym.location.endLine,
|
|
175
|
+
});
|
|
176
|
+
if (sym.children.length > 0) {
|
|
177
|
+
result.push(...flattenSymbols(sym.children, sym.kind === 'class' || sym.kind === 'interface' ? sym.name : ''));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
export function mapHunksToSymbols(hunks, structure) {
|
|
183
|
+
const allSymbols = flattenSymbols(structure.symbols);
|
|
184
|
+
const changedSymbols = new Map();
|
|
185
|
+
for (const hunk of hunks) {
|
|
186
|
+
const hunkStart = hunk.newStart;
|
|
187
|
+
const hunkEnd = hunk.newStart + hunk.newCount - 1;
|
|
188
|
+
for (const sym of allSymbols) {
|
|
189
|
+
if (hunkStart <= sym.end && hunkEnd >= sym.start) {
|
|
190
|
+
if (!changedSymbols.has(sym.name)) {
|
|
191
|
+
changedSymbols.set(sym.name, {
|
|
192
|
+
name: sym.name,
|
|
193
|
+
kind: sym.kind,
|
|
194
|
+
changeType: 'MODIFIED',
|
|
195
|
+
lineRange: `[L${sym.start}-${sym.end}]`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return Array.from(changedSymbols.values());
|
|
202
|
+
}
|
|
203
|
+
// ──────────────────────────────────────────────
|
|
204
|
+
// Output formatter
|
|
205
|
+
// ──────────────────────────────────────────────
|
|
206
|
+
function formatSmartDiff(allFiles, processedFiles, symbolChanges, args, rawTokens) {
|
|
207
|
+
const totalAdded = allFiles.reduce((s, f) => s + f.addedLines, 0);
|
|
208
|
+
const totalRemoved = allFiles.reduce((s, f) => s + f.removedLines, 0);
|
|
209
|
+
const scopeLabel = args.scope ?? 'unstaged';
|
|
210
|
+
const lines = [];
|
|
211
|
+
lines.push(`CHANGES: ${allFiles.length} file${allFiles.length !== 1 ? 's' : ''}, +${totalAdded} -${totalRemoved} (${scopeLabel})`);
|
|
212
|
+
lines.push('');
|
|
213
|
+
for (const fd of processedFiles) {
|
|
214
|
+
if (lines.length >= MAX_OUTPUT_LINES) {
|
|
215
|
+
lines.push(`... truncated (${allFiles.length - processedFiles.indexOf(fd)} more files)`);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
// File header
|
|
219
|
+
const changeLabel = fd.isNew ? ' [NEW]' : fd.isDeleted ? ' [DELETED]' : '';
|
|
220
|
+
const renameLabel = fd.oldPath ? ` (renamed from ${fd.oldPath})` : '';
|
|
221
|
+
const binaryLabel = fd.isBinary ? ' [BINARY]' : '';
|
|
222
|
+
lines.push(`${fd.path} (+${fd.addedLines} -${fd.removedLines})${changeLabel}${renameLabel}${binaryLabel}`);
|
|
223
|
+
if (fd.isBinary) {
|
|
224
|
+
lines.push('');
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
// Symbol changes
|
|
228
|
+
const symbols = symbolChanges.get(fd.path);
|
|
229
|
+
if (symbols && symbols.length > 0) {
|
|
230
|
+
for (const sc of symbols) {
|
|
231
|
+
lines.push(` ${sc.changeType}: ${sc.name}() ${sc.lineRange}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Small diff: include actual hunks
|
|
235
|
+
const totalHunkLines = fd.hunks.reduce((s, h) => s + h.lines.length, 0);
|
|
236
|
+
if (totalHunkLines <= SMALL_DIFF_THRESHOLD && totalHunkLines > 0) {
|
|
237
|
+
for (const hunk of fd.hunks) {
|
|
238
|
+
lines.push(` @@ L${hunk.newStart}`);
|
|
239
|
+
for (const hl of hunk.lines) {
|
|
240
|
+
lines.push(` ${hl}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (totalHunkLines > SMALL_DIFF_THRESHOLD) {
|
|
245
|
+
lines.push(` (${totalHunkLines} lines changed — use read_symbol for details)`);
|
|
246
|
+
}
|
|
247
|
+
lines.push('');
|
|
248
|
+
}
|
|
249
|
+
if (allFiles.length > MAX_FILES) {
|
|
250
|
+
lines.push(`Showing ${MAX_FILES} of ${allFiles.length} changed files. Use path filter to narrow.`);
|
|
251
|
+
lines.push('');
|
|
252
|
+
}
|
|
253
|
+
lines.push(`HINT: Use read_symbol(path, symbol) to see full changed code, read_diff(path) for line-level diff.`);
|
|
254
|
+
lines.push(`RAW DIFF: ~${rawTokens} tokens → smart_diff: ~${estimateTokens(lines.join('\n'))} tokens`);
|
|
255
|
+
return lines.join('\n');
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=smart-diff.js.map
|
package/dist/server.js
CHANGED
|
@@ -28,9 +28,11 @@ import { handleRelatedFiles } from './handlers/related-files.js';
|
|
|
28
28
|
import { handleOutline } from './handlers/outline.js';
|
|
29
29
|
import { handleCodeAudit } from './handlers/code-audit.js';
|
|
30
30
|
import { handleModuleInfo } from './handlers/module-info.js';
|
|
31
|
+
import { handleSmartDiff } from './handlers/smart-diff.js';
|
|
32
|
+
import { handleExploreArea } from './handlers/explore-area.js';
|
|
31
33
|
import { detectContextMode } from './integration/context-mode-detector.js';
|
|
32
34
|
import { estimateTokens } from './core/token-estimator.js';
|
|
33
|
-
import { resolveSafePath, validateSmartReadArgs, validateReadSymbolArgs, validateReadRangeArgs, validateReadDiffArgs, validateFindUsagesArgs, validateSmartReadManyArgs, validateReadForEditArgs, validateRelatedFilesArgs, validateOutlineArgs, validateFindUnusedArgs, validateCodeAuditArgs, validateProjectOverviewArgs, validateModuleInfoArgs, } from './core/validation.js';
|
|
35
|
+
import { resolveSafePath, validateSmartReadArgs, validateReadSymbolArgs, validateReadRangeArgs, validateReadDiffArgs, validateFindUsagesArgs, validateSmartReadManyArgs, validateReadForEditArgs, validateRelatedFilesArgs, validateOutlineArgs, validateFindUnusedArgs, validateCodeAuditArgs, validateProjectOverviewArgs, validateModuleInfoArgs, validateSmartDiffArgs, validateExploreAreaArgs, } from './core/validation.js';
|
|
34
36
|
export async function createServer(projectRoot, options) {
|
|
35
37
|
const config = await loadConfig(projectRoot);
|
|
36
38
|
const astIndex = new AstIndexClient(projectRoot, config.astIndex.timeout, {
|
|
@@ -183,6 +185,8 @@ export async function createServer(projectRoot, options) {
|
|
|
183
185
|
'• Reading file again → smart_read (returns compact reminder, not full content)',
|
|
184
186
|
'• Multiple files → smart_read_many (batch, max 20)',
|
|
185
187
|
'• Code quality audit → code_audit (TODOs, deprecated, structural code patterns)',
|
|
188
|
+
'• Reviewing git changes → smart_diff (structural diff with symbol mapping, not raw patch)',
|
|
189
|
+
'• Starting work on an area → explore_area (outline + imports + tests + git log in one call)',
|
|
186
190
|
'',
|
|
187
191
|
'WHEN TO USE DEFAULT TOOLS (Token Pilot adds no value):',
|
|
188
192
|
'• Small files (≤200 lines) → smart_read returns full content anyway, same as Read',
|
|
@@ -201,7 +205,7 @@ export async function createServer(projectRoot, options) {
|
|
|
201
205
|
'• Deep dive into specific code → read_symbol (after finding issues)',
|
|
202
206
|
'• Module architecture → module_info (deps, dependents, public API, unused deps)',
|
|
203
207
|
'',
|
|
204
|
-
'WORKFLOW: project_overview → smart_read → read_symbol → read_for_edit → edit →
|
|
208
|
+
'WORKFLOW: project_overview → explore_area → smart_read → read_symbol → read_for_edit → edit → smart_diff',
|
|
205
209
|
].join('\n'),
|
|
206
210
|
});
|
|
207
211
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
@@ -401,6 +405,35 @@ export async function createServer(projectRoot, options) {
|
|
|
401
405
|
required: ['module'],
|
|
402
406
|
},
|
|
403
407
|
},
|
|
408
|
+
// --- Diff & exploration ---
|
|
409
|
+
{
|
|
410
|
+
name: 'smart_diff',
|
|
411
|
+
description: 'Use INSTEAD OF raw git diff. Shows changed files with AST symbol mapping — which functions/classes were modified/added/removed. Small diffs include hunks, large diffs show summary.',
|
|
412
|
+
inputSchema: {
|
|
413
|
+
type: 'object',
|
|
414
|
+
properties: {
|
|
415
|
+
scope: { type: 'string', enum: ['unstaged', 'staged', 'commit', 'branch'], description: 'Diff scope (default: "unstaged")' },
|
|
416
|
+
path: { type: 'string', description: 'Filter to specific file or directory' },
|
|
417
|
+
ref: { type: 'string', description: 'Git ref — required for scope="commit" (commit hash) or scope="branch" (branch name)' },
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: 'explore_area',
|
|
423
|
+
description: 'One-call exploration of a directory: outline (all symbols), imports (external deps + who imports this area), tests (matching test files), recent git changes. Use INSTEAD OF separate outline + related_files + git log calls.',
|
|
424
|
+
inputSchema: {
|
|
425
|
+
type: 'object',
|
|
426
|
+
properties: {
|
|
427
|
+
path: { type: 'string', description: 'Directory path (or file path — will use its parent directory)' },
|
|
428
|
+
include: {
|
|
429
|
+
type: 'array',
|
|
430
|
+
items: { type: 'string', enum: ['outline', 'imports', 'tests', 'changes'] },
|
|
431
|
+
description: 'Sections to include (default: all)',
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
required: ['path'],
|
|
435
|
+
},
|
|
436
|
+
},
|
|
404
437
|
],
|
|
405
438
|
}));
|
|
406
439
|
// Helper: get real full-file token count for honest analytics
|
|
@@ -553,6 +586,24 @@ export async function createServer(projectRoot, options) {
|
|
|
553
586
|
analytics.record({ tool: 'module_info', path: moduleArgs.module, tokensReturned: estimateTokens(moduleText), tokensWouldBe: moduleWouldBe, timestamp: Date.now() });
|
|
554
587
|
return moduleResult;
|
|
555
588
|
}
|
|
589
|
+
case 'smart_diff': {
|
|
590
|
+
const sdArgs = validateSmartDiffArgs(args);
|
|
591
|
+
const sdResult = await handleSmartDiff(sdArgs, projectRoot, astIndex);
|
|
592
|
+
const sdText = sdResult.content[0]?.text ?? '';
|
|
593
|
+
const sdTokens = estimateTokens(sdText);
|
|
594
|
+
analytics.record({ tool: 'smart_diff', path: sdArgs.path ?? sdArgs.scope ?? 'unstaged', tokensReturned: sdTokens, tokensWouldBe: sdResult.rawTokens || sdTokens, timestamp: Date.now() });
|
|
595
|
+
return { content: sdResult.content };
|
|
596
|
+
}
|
|
597
|
+
case 'explore_area': {
|
|
598
|
+
const eaArgs = validateExploreAreaArgs(args);
|
|
599
|
+
const eaResult = await handleExploreArea(eaArgs, projectRoot, astIndex);
|
|
600
|
+
const eaText = eaResult.content[0]?.text ?? '';
|
|
601
|
+
const eaTokens = estimateTokens(eaText);
|
|
602
|
+
// Without explore_area, agent would call: outline + related_files + git log = ~3-5x tokens
|
|
603
|
+
const eaWouldBe = eaTokens * 4;
|
|
604
|
+
analytics.record({ tool: 'explore_area', path: eaArgs.path, tokensReturned: eaTokens, tokensWouldBe: eaWouldBe, timestamp: Date.now() });
|
|
605
|
+
return eaResult;
|
|
606
|
+
}
|
|
556
607
|
default:
|
|
557
608
|
return {
|
|
558
609
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "token-pilot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Save 60-80% tokens when AI reads code — MCP server for token-efficient code navigation, AST-aware structural reading instead of dumping full files into context window",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|