token-pilot 0.8.3 → 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.
@@ -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
@@ -27,9 +27,12 @@ import { handleReadForEdit } from './handlers/read-for-edit.js';
27
27
  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
+ import { handleModuleInfo } from './handlers/module-info.js';
31
+ import { handleSmartDiff } from './handlers/smart-diff.js';
32
+ import { handleExploreArea } from './handlers/explore-area.js';
30
33
  import { detectContextMode } from './integration/context-mode-detector.js';
31
34
  import { estimateTokens } from './core/token-estimator.js';
32
- import { resolveSafePath, validateSmartReadArgs, validateReadSymbolArgs, validateReadRangeArgs, validateReadDiffArgs, validateFindUsagesArgs, validateSmartReadManyArgs, validateReadForEditArgs, validateRelatedFilesArgs, validateOutlineArgs, validateFindUnusedArgs, validateCodeAuditArgs, } 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';
33
36
  export async function createServer(projectRoot, options) {
34
37
  const config = await loadConfig(projectRoot);
35
38
  const astIndex = new AstIndexClient(projectRoot, config.astIndex.timeout, {
@@ -182,6 +185,8 @@ export async function createServer(projectRoot, options) {
182
185
  '• Reading file again → smart_read (returns compact reminder, not full content)',
183
186
  '• Multiple files → smart_read_many (batch, max 20)',
184
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)',
185
190
  '',
186
191
  'WHEN TO USE DEFAULT TOOLS (Token Pilot adds no value):',
187
192
  '• Small files (≤200 lines) → smart_read returns full content anyway, same as Read',
@@ -198,8 +203,9 @@ export async function createServer(projectRoot, options) {
198
203
  '• Text pattern search/counting → Grep (regex, count mode)',
199
204
  '• Security audit → Grep for: password, token, secret, credential, hardcoded, api_key, TODO.*security',
200
205
  '• Deep dive into specific code → read_symbol (after finding issues)',
206
+ '• Module architecture → module_info (deps, dependents, public API, unused deps)',
201
207
  '',
202
- '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',
203
209
  ].join('\n'),
204
210
  });
205
211
  server.setRequestHandler(ListToolsRequestSchema, () => ({
@@ -291,21 +297,31 @@ export async function createServer(projectRoot, options) {
291
297
  // --- Search & navigation ---
292
298
  {
293
299
  name: 'find_usages',
294
- description: 'Use INSTEAD OF Grep/ripgrep for finding symbol references. Semantic search across the project — groups results by: definitions, imports, usages.',
300
+ description: 'Use INSTEAD OF Grep/ripgrep for finding symbol references. Semantic search across the project — groups results by: definitions, imports, usages. (v1.1: added scope, kind, limit, lang filters)',
295
301
  inputSchema: {
296
302
  type: 'object',
297
303
  properties: {
298
304
  symbol: { type: 'string', description: 'Symbol name to find usages of' },
305
+ scope: { type: 'string', description: 'Filter results by path prefix (e.g., "src/Domain/")' },
306
+ kind: { type: 'string', enum: ['definitions', 'imports', 'usages', 'all'], description: 'Show only specific section (default: "all")' },
307
+ limit: { type: 'number', description: 'Max results per category (default: 50, max: 500)' },
308
+ lang: { type: 'string', description: 'Filter by language/extension (e.g., "php", "typescript")' },
299
309
  },
300
310
  required: ['symbol'],
301
311
  },
302
312
  },
303
313
  {
304
314
  name: 'project_overview',
305
- description: 'START HERE for unfamiliar codebases. Shows project type, architecture, framework detection, directory structure with symbol counts. Use before exploring code.',
315
+ description: 'START HERE for unfamiliar codebases. Shows project type (dual-detection: ast-index + config files), architecture, framework detection, quality tools, CI, directory map. (v1.1: added include filter)',
306
316
  inputSchema: {
307
317
  type: 'object',
308
- properties: {},
318
+ properties: {
319
+ include: {
320
+ type: 'array',
321
+ items: { type: 'string', enum: ['stack', 'ci', 'quality', 'architecture'] },
322
+ description: 'Sections to include (default: all). Use ["stack"] for quick type check, ["quality","ci"] for tooling overview.',
323
+ },
324
+ },
309
325
  },
310
326
  },
311
327
  {
@@ -321,11 +337,13 @@ export async function createServer(projectRoot, options) {
321
337
  },
322
338
  {
323
339
  name: 'outline',
324
- description: 'Use INSTEAD OF listing dir + reading each file. One call returns all symbols (classes, functions, methods, routes) for every code file in a directory.',
340
+ description: 'Use INSTEAD OF listing dir + reading each file. One call returns all symbols (classes, functions, methods, routes) for every code file in a directory. (v1.1: added recursive, max_depth)',
325
341
  inputSchema: {
326
342
  type: 'object',
327
343
  properties: {
328
344
  path: { type: 'string', description: 'Directory path' },
345
+ recursive: { type: 'boolean', description: 'Recursively outline subdirectories (default: false)' },
346
+ max_depth: { type: 'number', description: 'Max recursion depth when recursive=true (default: 2, max: 5)' },
329
347
  },
330
348
  required: ['path'],
331
349
  },
@@ -371,6 +389,51 @@ export async function createServer(projectRoot, options) {
371
389
  required: ['check'],
372
390
  },
373
391
  },
392
+ {
393
+ name: 'module_info',
394
+ description: 'Analyze module dependencies, dependents, public API, and unused deps. Use for architecture understanding and dependency cleanup.',
395
+ inputSchema: {
396
+ type: 'object',
397
+ properties: {
398
+ module: { type: 'string', description: 'Module name or path pattern (e.g., "auth", "src/Domain/")' },
399
+ check: {
400
+ type: 'string',
401
+ enum: ['deps', 'dependents', 'api', 'unused-deps', 'all'],
402
+ description: 'What to check: "deps" (dependencies), "dependents" (who depends on this), "api" (public symbols), "unused-deps" (dead dependencies), "all" (everything). Default: "all"',
403
+ },
404
+ },
405
+ required: ['module'],
406
+ },
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
+ },
374
437
  ],
375
438
  }));
376
439
  // Helper: get real full-file token count for honest analytics
@@ -476,7 +539,8 @@ export async function createServer(projectRoot, options) {
476
539
  return usagesResult;
477
540
  }
478
541
  case 'project_overview': {
479
- const overviewResult = await handleProjectOverview(projectRoot, astIndex);
542
+ const overviewArgs = validateProjectOverviewArgs(args);
543
+ const overviewResult = await handleProjectOverview(overviewArgs, projectRoot, astIndex);
480
544
  const overviewText = overviewResult.content[0]?.text ?? '';
481
545
  overviewResult.content[0] = { type: 'text', text: `TOKEN PILOT v${pkgVersion}\n\n${overviewText}` };
482
546
  const ovTokens = estimateTokens(overviewResult.content[0].text);
@@ -513,6 +577,33 @@ export async function createServer(projectRoot, options) {
513
577
  analytics.record({ tool: 'code_audit', path: auditArgs.check, tokensReturned: estimateTokens(auditText), tokensWouldBe: estimateTokens(auditText), timestamp: Date.now() });
514
578
  return auditResult;
515
579
  }
580
+ case 'module_info': {
581
+ const moduleArgs = validateModuleInfoArgs(args);
582
+ const moduleResult = await handleModuleInfo(moduleArgs, projectRoot, astIndex);
583
+ const moduleText = moduleResult.content[0]?.text ?? '';
584
+ // Estimate: manual analysis would require reading all module files + grepping deps
585
+ const moduleWouldBe = estimateTokens(moduleText) * 5;
586
+ analytics.record({ tool: 'module_info', path: moduleArgs.module, tokensReturned: estimateTokens(moduleText), tokensWouldBe: moduleWouldBe, timestamp: Date.now() });
587
+ return moduleResult;
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
+ }
516
607
  default:
517
608
  return {
518
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.8.3",
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",