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.
@@ -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. 14 MCP tools for structural code reading, symbol navigation, and cross-file search.",
5
- "version": "0.9.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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-pilot",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Reduces token consumption by 80-95% via AST-aware lazy file reading. Returns structural overviews instead of full files.",
5
5
  "author": "token-pilot",
6
6
  "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
@@ -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
@@ -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 → read_diff',
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.9.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",