token-pilot 0.7.6 → 0.8.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ 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.8.1] - 2026-03-08
9
+
10
+ ### Added
11
+ - **ast-grep auto-install** — `@ast-grep/cli` added as optional dependency. `code_audit(check="pattern")` now works out-of-the-box without manual `brew install ast-grep`.
12
+ - **MCP instructions: security audit guidance** — instructions now recommend Grep for security patterns (password, token, secret, credential) and `find_unused` for dead code detection.
13
+
14
+ ### Changed
15
+ - **ast-index stats → JSON parsing** — `--format json` for reliable file count extraction instead of regex on text output.
16
+
17
+ ## [0.8.0] - 2026-03-07
18
+
19
+ ### Added
20
+ - **`code_audit` tool** — find code quality issues in one call: TODO/FIXME comments (`check="todo"`), deprecated symbols (`check="deprecated"`), structural code patterns via ast-grep (`check="pattern"`), decorator search (`check="annotations"`), or combined audit (`check="all"`).
21
+ - **Incremental index update on file changes** — file watcher now triggers `ast-index update` (debounced 2s) after edits. Keeps index fresh for find_usages, find_unused, code_audit.
22
+ - **ast-index client methods** — `agrep()`, `todo()`, `deprecated()`, `annotations()`, `incrementalUpdate()`.
23
+
24
+ ### Fixed
25
+ - **smart_read on directories** — now returns helpful message instead of EISDIR crash.
26
+ - **MCP instructions** — added "COMBINE BOTH" section for audit tasks (Token Pilot + Grep).
27
+
8
28
  ## [0.7.6] - 2026-03-07
9
29
 
10
30
  ### Added
package/README.md CHANGED
@@ -25,6 +25,8 @@ One command creates `.mcp.json` with token-pilot + context-mode:
25
25
  npx -y token-pilot init
26
26
  ```
27
27
 
28
+ Safe to run in any project — if `.mcp.json` already exists, only adds missing servers without overwriting existing config.
29
+
28
30
  This generates:
29
31
 
30
32
  ```json
@@ -53,7 +55,7 @@ Add to your `.mcp.json` (project-level or `~/.mcp.json` for global):
53
55
  }
54
56
  ```
55
57
 
56
- Works with: **Cursor**, **Cline**, **Continue**, **Codex**, **Antigravity**, and any MCP-compatible client.
58
+ Works with: **Claude Code**, **Cursor**, **Codex**, **Antigravity**, **Cline**, and any MCP-compatible client.
57
59
 
58
60
  #### Cursor
59
61
 
@@ -105,6 +107,10 @@ brew tap defendend/ast-index && brew install ast-index
105
107
  npx token-pilot install-ast-index
106
108
  ```
107
109
 
110
+ ### ast-grep (bundled)
111
+
112
+ [ast-grep](https://ast-grep.github.io/) (`sg`) is included as optional dependency for structural code pattern search via `code_audit(check="pattern")`. Installs automatically with `npm i -g token-pilot`.
113
+
108
114
  ### PreToolUse Hook (Claude Code only)
109
115
 
110
116
  Optional hook that intercepts `Read` calls for large code files (>500 lines) and suggests `smart_read`. Claude Code only.
@@ -143,7 +149,7 @@ For more control, you can add rules to your project:
143
149
  - **Cursor** → `.cursorrules` in project root
144
150
  - **Codex** → `AGENTS.md` in project root
145
151
 
146
- ## MCP Tools (12)
152
+ ## MCP Tools (13)
147
153
 
148
154
  ### Core Reading
149
155
 
@@ -165,6 +171,7 @@ For more control, you can add rules to your project:
165
171
  | `related_files` | manual explore | Import graph: what a file imports, what imports it, test files. |
166
172
  | `outline` | multiple `smart_read` | Compact overview of all code files in a directory. One call instead of 5-6. |
167
173
  | `find_unused` | manual | Detect dead code — unused functions, classes, variables. |
174
+ | `code_audit` | multiple `Grep` | Code quality issues in one call: TODO/FIXME comments, deprecated symbols, structural code patterns (via ast-grep), decorator search. |
168
175
 
169
176
  ### Analytics
170
177
 
@@ -177,6 +184,7 @@ For more control, you can add rules to your project:
177
184
  ```bash
178
185
  token-pilot # Start MCP server (uses cwd as project root)
179
186
  token-pilot /path/to/project # Start with specific project root
187
+ token-pilot init [dir] # Create/update .mcp.json (token-pilot + context-mode)
180
188
  token-pilot install-ast-index # Download ast-index binary (auto on first run)
181
189
  token-pilot install-hook [root] # Install PreToolUse hook
182
190
  token-pilot uninstall-hook # Remove hook
@@ -193,7 +201,7 @@ Create `.token-pilot.json` in your project root to customize behavior:
193
201
  ```json
194
202
  {
195
203
  "smartRead": {
196
- "smallFileThreshold": 80,
204
+ "smallFileThreshold": 200,
197
205
  "advisoryReminders": true
198
206
  },
199
207
  "cache": {
@@ -251,7 +259,6 @@ When both are configured, Token Pilot automatically:
251
259
  - Detects context-mode via `.mcp.json`
252
260
  - Suggests context-mode for large non-code files
253
261
  - Shows combined architecture info in `session_analytics`
254
- - Provides `export_ast_index` to feed AST data into context-mode's BM25 index
255
262
 
256
263
  **Combined savings: 60-80%** in a typical coding session.
257
264
 
@@ -339,6 +346,7 @@ src/
339
346
  related-files.ts — related_files handler (import graph)
340
347
  outline.ts — outline handler (directory overview)
341
348
  find-unused.ts — find_unused handler
349
+ code-audit.ts — code_audit handler (TODOs, deprecated, patterns)
342
350
  project-overview.ts — project_overview (via ast-index map + conventions)
343
351
  non-code.ts — JSON/YAML/MD/TOML structural summaries
344
352
  export-ast-index.ts — AST export for context-mode BM25
@@ -1,5 +1,5 @@
1
1
  import type { FileStructure } from '../types.js';
2
- import type { AstIndexSymbolDetail, AstIndexSearchResult, AstIndexUsageResult, AstIndexImplementation, AstIndexHierarchyNode, AstIndexRefsResponse, AstIndexMapResponse, AstIndexConventionsResponse, AstIndexCallerEntry, AstIndexCallTreeNode, AstIndexChangedEntry, AstIndexUnusedSymbol, AstIndexImportEntry } from './types.js';
2
+ import type { AstIndexSymbolDetail, AstIndexSearchResult, AstIndexUsageResult, AstIndexImplementation, AstIndexHierarchyNode, AstIndexRefsResponse, AstIndexMapResponse, AstIndexConventionsResponse, AstIndexCallerEntry, AstIndexCallTreeNode, AstIndexChangedEntry, AstIndexUnusedSymbol, AstIndexImportEntry, AstIndexAgrepMatch, AstIndexTodoEntry, AstIndexDeprecatedEntry, AstIndexAnnotationEntry } from './types.js';
3
3
  export declare class AstIndexClient {
4
4
  private static readonly MAX_INDEX_FILES;
5
5
  private binaryPath;
@@ -11,6 +11,7 @@ export declare class AstIndexClient {
11
11
  private timeout;
12
12
  private configBinaryPath;
13
13
  private autoInstall;
14
+ private astGrepAvailable;
14
15
  constructor(projectRoot: string, timeout?: number, options?: {
15
16
  binaryPath?: string | null;
16
17
  autoInstall?: boolean;
@@ -20,7 +21,7 @@ export declare class AstIndexClient {
20
21
  private buildIndex;
21
22
  /** Mark index as oversized — disables index-dependent tools, outline still works */
22
23
  private handleOversizedIndex;
23
- /** Extract file count from stats output */
24
+ /** Extract file count from stats output (JSON or text) */
24
25
  private parseFileCount;
25
26
  outline(filePath: string): Promise<FileStructure | null>;
26
27
  /**
@@ -92,6 +93,25 @@ export declare class AstIndexClient {
92
93
  */
93
94
  fileImports(filePath: string): Promise<AstIndexImportEntry[]>;
94
95
  private parseImportsText;
96
+ /** Check if ast-grep (sg) is available for structural pattern search */
97
+ private checkAstGrep;
98
+ /** Structural pattern search via ast-grep. Requires ast-grep (sg) installed. */
99
+ agrep(pattern: string, options?: {
100
+ lang?: string;
101
+ limit?: number;
102
+ }): Promise<AstIndexAgrepMatch[]>;
103
+ private parseAgrepText;
104
+ /** Find TODO/FIXME/HACK comments in the project */
105
+ todo(): Promise<AstIndexTodoEntry[]>;
106
+ private parseTodoText;
107
+ /** Find @Deprecated symbols in the project */
108
+ deprecated(): Promise<AstIndexDeprecatedEntry[]>;
109
+ private parseDeprecatedText;
110
+ /** Find symbols with a specific annotation/decorator */
111
+ annotations(name: string): Promise<AstIndexAnnotationEntry[]>;
112
+ private parseAnnotationsText;
113
+ /** Trigger incremental index update (called by file watcher after edits) */
114
+ incrementalUpdate(): Promise<void>;
95
115
  isAvailable(): boolean;
96
116
  /** Returns true if the index was built but found >50k files (node_modules leak) */
97
117
  isOversized(): boolean;
@@ -16,6 +16,7 @@ export class AstIndexClient {
16
16
  timeout;
17
17
  configBinaryPath;
18
18
  autoInstall;
19
+ astGrepAvailable = null;
19
20
  constructor(projectRoot, timeout = 5000, options) {
20
21
  this.projectRoot = projectRoot;
21
22
  this.timeout = timeout;
@@ -74,9 +75,8 @@ export class AstIndexClient {
74
75
  // Check if index already exists and has files
75
76
  let existingFileCount = 0;
76
77
  try {
77
- const stats = await this.exec(['stats']);
78
- const filesMatch = stats.match(/Files:\s*(\d+)/);
79
- existingFileCount = filesMatch ? parseInt(filesMatch[1], 10) : 0;
78
+ const stats = await this.exec(['--format', 'json', 'stats']);
79
+ existingFileCount = this.parseFileCount(stats);
80
80
  }
81
81
  catch { /* no index yet */ }
82
82
  // Guard: existing index is oversized (node_modules leak from previous build)
@@ -96,9 +96,7 @@ export class AstIndexClient {
96
96
  await this.exec(['update'], 30000);
97
97
  // Re-check count after update
98
98
  try {
99
- const statsText = await this.exec(['stats']);
100
- const filesMatch = statsText.match(/Files:\s*(\d+)/);
101
- existingFileCount = filesMatch ? parseInt(filesMatch[1], 10) : existingFileCount;
99
+ existingFileCount = this.parseFileCount(await this.exec(['--format', 'json', 'stats']));
102
100
  }
103
101
  catch { /* keep previous count */ }
104
102
  // Guard: update may have grown index beyond limit
@@ -117,7 +115,7 @@ export class AstIndexClient {
117
115
  console.error('[token-pilot] ast-index: building index (this may take a moment)...');
118
116
  try {
119
117
  await this.exec(['rebuild'], 120000);
120
- const fileCount = this.parseFileCount(await this.exec(['stats']).catch(() => ''));
118
+ const fileCount = this.parseFileCount(await this.exec(['--format', 'json', 'stats']).catch(() => ''));
121
119
  // Guard: rebuild produced oversized index
122
120
  if (fileCount > AstIndexClient.MAX_INDEX_FILES) {
123
121
  return this.handleOversizedIndex(fileCount);
@@ -129,7 +127,7 @@ export class AstIndexClient {
129
127
  // If rebuild failed due to lock, check if index is usable anyway
130
128
  const errMsg = buildErr instanceof Error ? buildErr.message : String(buildErr);
131
129
  if (errMsg.includes('lock') || errMsg.includes('already running')) {
132
- const count = this.parseFileCount(await this.exec(['stats']).catch(() => ''));
130
+ const count = this.parseFileCount(await this.exec(['--format', 'json', 'stats']).catch(() => ''));
133
131
  if (count > 0 && count <= AstIndexClient.MAX_INDEX_FILES) {
134
132
  this.indexed = true;
135
133
  console.error(`[token-pilot] ast-index: using existing index (${count} files, rebuild skipped due to lock)`);
@@ -157,8 +155,16 @@ export class AstIndexClient {
157
155
  ` → Tools disabled: find_unused, find_usages, related_files, project_overview\n` +
158
156
  ` → Tools still working: outline, smart_read, smart_read_many, read_symbol`);
159
157
  }
160
- /** Extract file count from stats output */
158
+ /** Extract file count from stats output (JSON or text) */
161
159
  parseFileCount(statsText) {
160
+ // Try JSON first (--format json)
161
+ try {
162
+ const json = JSON.parse(statsText);
163
+ if (json?.stats?.file_count !== undefined)
164
+ return json.stats.file_count;
165
+ }
166
+ catch { /* not JSON, fall through */ }
167
+ // Fallback: text format
162
168
  const match = statsText.match(/Files:\s*(\d+)/);
163
169
  return match ? parseInt(match[1], 10) : 0;
164
170
  }
@@ -636,6 +642,172 @@ export class AstIndexClient {
636
642
  }
637
643
  return entries;
638
644
  }
645
+ // --- Code audit commands ---
646
+ /** Check if ast-grep (sg) is available for structural pattern search */
647
+ async checkAstGrep() {
648
+ if (this.astGrepAvailable !== null)
649
+ return this.astGrepAvailable;
650
+ try {
651
+ await execFileAsync('sg', ['--version'], { timeout: 3000 });
652
+ this.astGrepAvailable = true;
653
+ }
654
+ catch {
655
+ this.astGrepAvailable = false;
656
+ }
657
+ return this.astGrepAvailable;
658
+ }
659
+ /** Structural pattern search via ast-grep. Requires ast-grep (sg) installed. */
660
+ async agrep(pattern, options) {
661
+ if (this.indexDisabled || this.indexOversized)
662
+ return [];
663
+ await this.ensureIndex();
664
+ const available = await this.checkAstGrep();
665
+ if (!available) {
666
+ throw new Error('ast-grep (sg) not installed — required for structural pattern search.\n' +
667
+ 'Install: brew install ast-grep OR npm i -g @ast-grep/cli\n' +
668
+ 'Alternative: use Grep/ripgrep for text-based pattern search.');
669
+ }
670
+ const limit = options?.limit ?? 50;
671
+ const args = ['agrep', pattern];
672
+ if (options?.lang)
673
+ args.push('--lang', options.lang);
674
+ args.push('--limit', String(limit));
675
+ try {
676
+ const result = await this.exec(args, 15000);
677
+ return this.parseAgrepText(result).slice(0, limit);
678
+ }
679
+ catch (err) {
680
+ console.error(`[token-pilot] ast-index agrep failed: ${err instanceof Error ? err.message : err}`);
681
+ return [];
682
+ }
683
+ }
684
+ parseAgrepText(text) {
685
+ const results = [];
686
+ for (const line of text.split('\n')) {
687
+ if (!line.trim())
688
+ continue;
689
+ // Format: file:line:matched_text OR file:line: matched_text
690
+ const match = line.match(/^(.+?):(\d+):(.*)$/);
691
+ if (match) {
692
+ results.push({
693
+ file: match[1],
694
+ line: parseInt(match[2], 10),
695
+ text: match[3].trim(),
696
+ });
697
+ }
698
+ }
699
+ return results;
700
+ }
701
+ /** Find TODO/FIXME/HACK comments in the project */
702
+ async todo() {
703
+ if (this.indexDisabled || this.indexOversized)
704
+ return [];
705
+ await this.ensureIndex();
706
+ try {
707
+ const result = await this.exec(['todo'], 15000);
708
+ return this.parseTodoText(result);
709
+ }
710
+ catch (err) {
711
+ console.error(`[token-pilot] ast-index todo failed: ${err instanceof Error ? err.message : err}`);
712
+ return [];
713
+ }
714
+ }
715
+ parseTodoText(text) {
716
+ const results = [];
717
+ for (const line of text.split('\n')) {
718
+ if (!line.trim())
719
+ continue;
720
+ // Try format: file:line: KIND: message OR file:line: KIND message
721
+ const match = line.match(/^(.+?):(\d+):\s*(TODO|FIXME|HACK|XXX|NOTE|WARN(?:ING)?)[:\s]+(.*)$/i);
722
+ if (match) {
723
+ results.push({
724
+ file: match[1],
725
+ line: parseInt(match[2], 10),
726
+ kind: match[3].toUpperCase(),
727
+ text: match[4].trim(),
728
+ });
729
+ }
730
+ }
731
+ return results;
732
+ }
733
+ /** Find @Deprecated symbols in the project */
734
+ async deprecated() {
735
+ if (this.indexDisabled || this.indexOversized)
736
+ return [];
737
+ await this.ensureIndex();
738
+ try {
739
+ const result = await this.exec(['deprecated'], 15000);
740
+ return this.parseDeprecatedText(result);
741
+ }
742
+ catch (err) {
743
+ console.error(`[token-pilot] ast-index deprecated failed: ${err instanceof Error ? err.message : err}`);
744
+ return [];
745
+ }
746
+ }
747
+ parseDeprecatedText(text) {
748
+ const results = [];
749
+ for (const line of text.split('\n')) {
750
+ if (!line.trim())
751
+ continue;
752
+ // Try format: kind name (file:line) - message OR kind name (file:line)
753
+ const match = line.match(/^(\w+)\s+(\S+)\s+\((.+?):(\d+)\)(?:\s*-\s*(.+))?$/);
754
+ if (match) {
755
+ results.push({
756
+ kind: match[1],
757
+ name: match[2],
758
+ file: match[3],
759
+ line: parseInt(match[4], 10),
760
+ message: match[5]?.trim(),
761
+ });
762
+ }
763
+ }
764
+ return results;
765
+ }
766
+ /** Find symbols with a specific annotation/decorator */
767
+ async annotations(name) {
768
+ if (this.indexDisabled || this.indexOversized)
769
+ return [];
770
+ await this.ensureIndex();
771
+ try {
772
+ const result = await this.exec(['annotations', name], 15000);
773
+ return this.parseAnnotationsText(result, name);
774
+ }
775
+ catch (err) {
776
+ console.error(`[token-pilot] ast-index annotations failed: ${err instanceof Error ? err.message : err}`);
777
+ return [];
778
+ }
779
+ }
780
+ parseAnnotationsText(text, annotationName) {
781
+ const results = [];
782
+ for (const line of text.split('\n')) {
783
+ if (!line.trim())
784
+ continue;
785
+ // Try format: kind name (file:line) OR @Annotation kind name (file:line)
786
+ const match = line.match(/^(?:@\S+\s+)?(\w+)\s+(\S+)\s+\((.+?):(\d+)\)$/);
787
+ if (match) {
788
+ results.push({
789
+ kind: match[1],
790
+ name: match[2],
791
+ file: match[3],
792
+ line: parseInt(match[4], 10),
793
+ annotation: annotationName,
794
+ });
795
+ }
796
+ }
797
+ return results;
798
+ }
799
+ /** Trigger incremental index update (called by file watcher after edits) */
800
+ async incrementalUpdate() {
801
+ if (!this.indexed || this.indexDisabled || this.indexOversized)
802
+ return;
803
+ try {
804
+ await this.exec(['update'], 15000);
805
+ }
806
+ catch (err) {
807
+ console.error(`[token-pilot] ast-index incremental update failed: ${err instanceof Error ? err.message : err}`);
808
+ }
809
+ }
810
+ // --- Utility methods ---
639
811
  isAvailable() {
640
812
  return this.binaryPath !== null;
641
813
  }
@@ -153,4 +153,33 @@ export interface AstIndexImportEntry {
153
153
  isDefault?: boolean;
154
154
  isNamespace?: boolean;
155
155
  }
156
+ /** ast-index agrep — structural pattern search via ast-grep */
157
+ export interface AstIndexAgrepMatch {
158
+ file: string;
159
+ line: number;
160
+ text: string;
161
+ }
162
+ /** ast-index todo — TODO/FIXME/HACK comments */
163
+ export interface AstIndexTodoEntry {
164
+ file: string;
165
+ line: number;
166
+ kind: string;
167
+ text: string;
168
+ }
169
+ /** ast-index deprecated — @Deprecated symbols */
170
+ export interface AstIndexDeprecatedEntry {
171
+ name: string;
172
+ kind: string;
173
+ file: string;
174
+ line: number;
175
+ message?: string;
176
+ }
177
+ /** ast-index annotations — symbols with specific decorator/annotation */
178
+ export interface AstIndexAnnotationEntry {
179
+ name: string;
180
+ kind: string;
181
+ file: string;
182
+ line: number;
183
+ annotation: string;
184
+ }
156
185
  //# sourceMappingURL=types.d.ts.map
@@ -76,6 +76,14 @@ export declare function validateFindUnusedArgs(args: unknown): {
76
76
  export_only?: boolean;
77
77
  limit?: number;
78
78
  };
79
+ export interface CodeAuditArgs {
80
+ check: 'pattern' | 'todo' | 'deprecated' | 'annotations' | 'all';
81
+ pattern?: string;
82
+ name?: string;
83
+ lang?: string;
84
+ limit?: number;
85
+ }
86
+ export declare function validateCodeAuditArgs(args: unknown): CodeAuditArgs;
79
87
  /** Detect roots that would cause ast-index to scan the entire filesystem */
80
88
  export declare function isDangerousRoot(root: string): boolean;
81
89
  //# sourceMappingURL=validation.d.ts.map
@@ -207,6 +207,33 @@ export function validateFindUnusedArgs(args) {
207
207
  limit: optionalNumber(a.limit, 'limit'),
208
208
  };
209
209
  }
210
+ export function validateCodeAuditArgs(args) {
211
+ if (!args || typeof args !== 'object') {
212
+ throw new Error('Arguments must be an object with a "check" parameter.');
213
+ }
214
+ const a = args;
215
+ const validChecks = ['pattern', 'todo', 'deprecated', 'annotations', 'all'];
216
+ if (typeof a.check !== 'string' || !validChecks.includes(a.check)) {
217
+ throw new Error(`Required parameter "check" must be one of: ${validChecks.join(', ')}`);
218
+ }
219
+ if (a.check === 'pattern') {
220
+ if (typeof a.pattern !== 'string' || a.pattern.length === 0) {
221
+ throw new Error('Parameter "pattern" is required when check="pattern". Example: "except:" or "print($$$ARGS)"');
222
+ }
223
+ }
224
+ if (a.check === 'annotations') {
225
+ if (typeof a.name !== 'string' || a.name.length === 0) {
226
+ throw new Error('Parameter "name" is required when check="annotations". Example: "Deprecated" or "Controller"');
227
+ }
228
+ }
229
+ return {
230
+ check: a.check,
231
+ pattern: optionalString(a.pattern, 'pattern'),
232
+ name: optionalString(a.name, 'name'),
233
+ lang: optionalString(a.lang, 'lang'),
234
+ limit: optionalNumber(a.limit, 'limit'),
235
+ };
236
+ }
210
237
  /** Detect roots that would cause ast-index to scan the entire filesystem */
211
238
  export function isDangerousRoot(root) {
212
239
  const normalized = root.replace(/\/+$/, '') || '/';
@@ -1,18 +1,27 @@
1
1
  import type { FileCache } from '../core/file-cache.js';
2
2
  import type { ContextRegistry } from '../core/context-registry.js';
3
+ import type { AstIndexClient } from '../ast-index/client.js';
3
4
  /**
4
5
  * Watches individual files for changes and auto-invalidates cache.
5
6
  * Only watches files that have been explicitly added (via watchFile()),
6
7
  * NOT the entire project root — avoids scanning thousands of files
7
8
  * and permission errors on Docker volumes, restricted dirs, etc.
9
+ *
10
+ * Also triggers debounced ast-index incremental update on file changes
11
+ * to keep the index fresh for find_usages, find_unused, code_audit.
8
12
  */
9
13
  export declare class FileWatcher {
14
+ private static readonly UPDATE_DEBOUNCE_MS;
10
15
  private fileCache;
11
16
  private contextRegistry;
17
+ private astIndex;
12
18
  private watcher;
13
19
  private watchedFiles;
14
- constructor(_projectRoot: string, fileCache: FileCache, contextRegistry: ContextRegistry, _ignore: string[]);
20
+ private updateTimer;
21
+ constructor(_projectRoot: string, fileCache: FileCache, contextRegistry: ContextRegistry, _ignore: string[], astIndex?: AstIndexClient);
15
22
  start(): void;
23
+ /** Debounced ast-index incremental update after file changes */
24
+ private scheduleIndexUpdate;
16
25
  /** Add a specific file to watch. Called after smart_read/read_symbol loads a file. */
17
26
  watchFile(filePath: string): void;
18
27
  stop(): void;
@@ -5,15 +5,22 @@ import { resolve } from 'node:path';
5
5
  * Only watches files that have been explicitly added (via watchFile()),
6
6
  * NOT the entire project root — avoids scanning thousands of files
7
7
  * and permission errors on Docker volumes, restricted dirs, etc.
8
+ *
9
+ * Also triggers debounced ast-index incremental update on file changes
10
+ * to keep the index fresh for find_usages, find_unused, code_audit.
8
11
  */
9
12
  export class FileWatcher {
13
+ static UPDATE_DEBOUNCE_MS = 2000;
10
14
  fileCache;
11
15
  contextRegistry;
16
+ astIndex;
12
17
  watcher = null;
13
18
  watchedFiles = new Set();
14
- constructor(_projectRoot, fileCache, contextRegistry, _ignore) {
19
+ updateTimer = null;
20
+ constructor(_projectRoot, fileCache, contextRegistry, _ignore, astIndex) {
15
21
  this.fileCache = fileCache;
16
22
  this.contextRegistry = contextRegistry;
23
+ this.astIndex = astIndex ?? null;
17
24
  }
18
25
  start() {
19
26
  // Start with an empty watcher — files are added on demand via watchFile()
@@ -30,14 +37,26 @@ export class FileWatcher {
30
37
  if (this.fileCache.get(absPath)) {
31
38
  this.fileCache.invalidate(absPath);
32
39
  }
40
+ this.scheduleIndexUpdate();
33
41
  });
34
42
  this.watcher.on('unlink', (filePath) => {
35
43
  const absPath = resolve(filePath);
36
44
  this.fileCache.invalidate(absPath);
37
45
  this.contextRegistry.forget(absPath);
38
46
  this.watchedFiles.delete(absPath);
47
+ this.scheduleIndexUpdate();
39
48
  });
40
49
  }
50
+ /** Debounced ast-index incremental update after file changes */
51
+ scheduleIndexUpdate() {
52
+ if (!this.astIndex)
53
+ return;
54
+ if (this.updateTimer)
55
+ clearTimeout(this.updateTimer);
56
+ this.updateTimer = setTimeout(() => {
57
+ this.astIndex?.incrementalUpdate().catch(() => { });
58
+ }, FileWatcher.UPDATE_DEBOUNCE_MS);
59
+ }
41
60
  /** Add a specific file to watch. Called after smart_read/read_symbol loads a file. */
42
61
  watchFile(filePath) {
43
62
  const absPath = resolve(filePath);
@@ -49,6 +68,9 @@ export class FileWatcher {
49
68
  this.watchedFiles.add(absPath);
50
69
  }
51
70
  stop() {
71
+ if (this.updateTimer)
72
+ clearTimeout(this.updateTimer);
73
+ this.updateTimer = null;
52
74
  this.watcher?.close();
53
75
  this.watcher = null;
54
76
  this.watchedFiles.clear();
@@ -0,0 +1,9 @@
1
+ import type { AstIndexClient } from '../ast-index/client.js';
2
+ import type { CodeAuditArgs } from '../core/validation.js';
3
+ export declare function handleCodeAudit(args: CodeAuditArgs, projectRoot: string, astIndex: AstIndexClient): Promise<{
4
+ content: Array<{
5
+ type: 'text';
6
+ text: string;
7
+ }>;
8
+ }>;
9
+ //# sourceMappingURL=code-audit.d.ts.map
@@ -0,0 +1,200 @@
1
+ import { relative } from 'node:path';
2
+ export async function handleCodeAudit(args, projectRoot, astIndex) {
3
+ if (astIndex.isDisabled() || astIndex.isOversized()) {
4
+ return {
5
+ content: [{
6
+ type: 'text',
7
+ text: 'ast-index is not available (project root too broad or index oversized). Use Grep/ripgrep for pattern search.',
8
+ }],
9
+ };
10
+ }
11
+ const limit = args.limit ?? 50;
12
+ switch (args.check) {
13
+ case 'pattern':
14
+ return handlePattern(args.pattern, args.lang, limit, projectRoot, astIndex);
15
+ case 'todo':
16
+ return handleTodo(limit, projectRoot, astIndex);
17
+ case 'deprecated':
18
+ return handleDeprecated(limit, projectRoot, astIndex);
19
+ case 'annotations':
20
+ return handleAnnotations(args.name, limit, projectRoot, astIndex);
21
+ case 'all':
22
+ return handleAll(limit, projectRoot, astIndex);
23
+ default:
24
+ return {
25
+ content: [{
26
+ type: 'text',
27
+ text: `Unknown check type: "${args.check}". Use: pattern, todo, deprecated, annotations, all`,
28
+ }],
29
+ };
30
+ }
31
+ }
32
+ function rel(projectRoot, absPath) {
33
+ return relative(projectRoot, absPath) || absPath;
34
+ }
35
+ async function handlePattern(pattern, lang, limit, projectRoot, astIndex) {
36
+ try {
37
+ const matches = await astIndex.agrep(pattern, { lang, limit });
38
+ if (matches.length === 0) {
39
+ return {
40
+ content: [{
41
+ type: 'text',
42
+ text: `PATTERN SEARCH: "${pattern}"${lang ? ` (${lang})` : ''}\n\nNo matches found.\n\nHINT: Try Grep/ripgrep for text-based search if the pattern is not structural.`,
43
+ }],
44
+ };
45
+ }
46
+ // Group by file
47
+ const byFile = new Map();
48
+ for (const m of matches) {
49
+ const key = rel(projectRoot, m.file);
50
+ if (!byFile.has(key))
51
+ byFile.set(key, []);
52
+ byFile.get(key).push({ line: m.line, text: m.text });
53
+ }
54
+ const lines = [
55
+ `PATTERN SEARCH: "${pattern}"${lang ? ` (${lang})` : ''} — ${matches.length} matches in ${byFile.size} files`,
56
+ '',
57
+ ];
58
+ for (const [file, items] of byFile) {
59
+ lines.push(`${file}:`);
60
+ for (const item of items) {
61
+ lines.push(` L${item.line}: ${item.text}`);
62
+ }
63
+ lines.push('');
64
+ }
65
+ lines.push('HINT: Use read_symbol() to inspect specific matches, or Grep for text-based counting.');
66
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
67
+ }
68
+ catch (err) {
69
+ // ast-grep not installed — return the error message
70
+ return {
71
+ content: [{
72
+ type: 'text',
73
+ text: `PATTERN SEARCH ERROR:\n${err instanceof Error ? err.message : String(err)}\n\nFallback: Use Grep/ripgrep for text-based pattern search.`,
74
+ }],
75
+ };
76
+ }
77
+ }
78
+ async function handleTodo(limit, projectRoot, astIndex) {
79
+ const entries = await astIndex.todo();
80
+ const limited = entries.slice(0, limit);
81
+ if (limited.length === 0) {
82
+ return {
83
+ content: [{
84
+ type: 'text',
85
+ text: 'TODO/FIXME COMMENTS: none found.\n\nHINT: ast-index may not detect all comment formats. Try Grep: grep -rn "TODO\\|FIXME\\|HACK" --include="*.ts"',
86
+ }],
87
+ };
88
+ }
89
+ // Group by kind
90
+ const byKind = new Map();
91
+ for (const e of limited) {
92
+ const kind = e.kind;
93
+ if (!byKind.has(kind))
94
+ byKind.set(kind, []);
95
+ byKind.get(kind).push({ file: rel(projectRoot, e.file), line: e.line, text: e.text });
96
+ }
97
+ const lines = [
98
+ `TODO/FIXME COMMENTS: ${limited.length} found${entries.length > limit ? ` (showing ${limit} of ${entries.length})` : ''}`,
99
+ '',
100
+ ];
101
+ for (const [kind, items] of byKind) {
102
+ lines.push(`${kind} (${items.length}):`);
103
+ for (const item of items) {
104
+ lines.push(` ${item.file}:${item.line} — ${item.text}`);
105
+ }
106
+ lines.push('');
107
+ }
108
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
109
+ }
110
+ async function handleDeprecated(limit, projectRoot, astIndex) {
111
+ const entries = await astIndex.deprecated();
112
+ const limited = entries.slice(0, limit);
113
+ if (limited.length === 0) {
114
+ return {
115
+ content: [{
116
+ type: 'text',
117
+ text: 'DEPRECATED SYMBOLS: none found.\n\nHINT: ast-index detects @Deprecated annotations. Try Grep for other deprecation patterns.',
118
+ }],
119
+ };
120
+ }
121
+ const lines = [
122
+ `DEPRECATED SYMBOLS: ${limited.length} found${entries.length > limit ? ` (showing ${limit} of ${entries.length})` : ''}`,
123
+ '',
124
+ ];
125
+ for (const e of limited) {
126
+ const loc = `${rel(projectRoot, e.file)}:${e.line}`;
127
+ lines.push(` ${e.kind} ${e.name} ${loc}${e.message ? ` — ${e.message}` : ''}`);
128
+ }
129
+ lines.push('');
130
+ lines.push('HINT: Use read_symbol() to inspect deprecated symbols before removing them.');
131
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
132
+ }
133
+ async function handleAnnotations(name, limit, projectRoot, astIndex) {
134
+ const entries = await astIndex.annotations(name);
135
+ const limited = entries.slice(0, limit);
136
+ if (limited.length === 0) {
137
+ return {
138
+ content: [{
139
+ type: 'text',
140
+ text: `ANNOTATIONS @${name}: none found.\n\nHINT: Try Grep for text-based search: grep -rn "@${name}" --include="*.ts"`,
141
+ }],
142
+ };
143
+ }
144
+ // Group by file
145
+ const byFile = new Map();
146
+ for (const e of limited) {
147
+ const key = rel(projectRoot, e.file);
148
+ if (!byFile.has(key))
149
+ byFile.set(key, []);
150
+ byFile.get(key).push({ name: e.name, kind: e.kind, line: e.line });
151
+ }
152
+ const lines = [
153
+ `ANNOTATIONS @${name}: ${limited.length} found in ${byFile.size} files${entries.length > limit ? ` (showing ${limit} of ${entries.length})` : ''}`,
154
+ '',
155
+ ];
156
+ for (const [file, items] of byFile) {
157
+ lines.push(`${file}:`);
158
+ for (const item of items) {
159
+ lines.push(` L${item.line}: ${item.kind} ${item.name}`);
160
+ }
161
+ lines.push('');
162
+ }
163
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
164
+ }
165
+ async function handleAll(limit, projectRoot, astIndex) {
166
+ // Run todo + deprecated in parallel
167
+ const [todos, deprecated] = await Promise.all([
168
+ astIndex.todo(),
169
+ astIndex.deprecated(),
170
+ ]);
171
+ const sections = ['CODE AUDIT SUMMARY', ''];
172
+ // TODOs
173
+ const todoLimited = todos.slice(0, limit);
174
+ if (todoLimited.length === 0) {
175
+ sections.push('TODO/FIXME: none found');
176
+ }
177
+ else {
178
+ sections.push(`TODO/FIXME: ${todoLimited.length} comments${todos.length > limit ? ` (${todos.length} total)` : ''}`);
179
+ for (const e of todoLimited) {
180
+ sections.push(` ${rel(projectRoot, e.file)}:${e.line} [${e.kind}] ${e.text}`);
181
+ }
182
+ }
183
+ sections.push('');
184
+ // Deprecated
185
+ const depLimited = deprecated.slice(0, limit);
186
+ if (depLimited.length === 0) {
187
+ sections.push('DEPRECATED: none found');
188
+ }
189
+ else {
190
+ sections.push(`DEPRECATED: ${depLimited.length} symbols${deprecated.length > limit ? ` (${deprecated.length} total)` : ''}`);
191
+ for (const e of depLimited) {
192
+ sections.push(` ${rel(projectRoot, e.file)}:${e.line} ${e.kind} ${e.name}${e.message ? ` — ${e.message}` : ''}`);
193
+ }
194
+ }
195
+ sections.push('');
196
+ sections.push('HINT: Use code_audit(check="pattern", pattern="...") for structural pattern search (requires ast-grep).');
197
+ sections.push(' Use Grep for text-based counting and regex search.');
198
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
199
+ }
200
+ //# sourceMappingURL=code-audit.js.map
@@ -6,6 +6,16 @@ import { resolveSafePath } from '../core/validation.js';
6
6
  import { isNonCodeStructured, handleNonCodeRead } from './non-code.js';
7
7
  export async function handleSmartRead(args, projectRoot, astIndex, fileCache, contextRegistry, config) {
8
8
  const absPath = resolveSafePath(projectRoot, args.path);
9
+ // 0. Guard: directory passed instead of file
10
+ const fileStat0 = await stat(absPath).catch(() => null);
11
+ if (fileStat0?.isDirectory()) {
12
+ return {
13
+ content: [{
14
+ type: 'text',
15
+ text: `"${args.path}" is a directory. Use outline("${args.path}") for directory overview, or smart_read a specific file inside it.`,
16
+ }],
17
+ };
18
+ }
9
19
  // 1. Read file content
10
20
  const content = await readFile(absPath, 'utf-8');
11
21
  const lines = content.split('\n');
package/dist/server.js CHANGED
@@ -26,9 +26,10 @@ import { handleFindUnused } from './handlers/find-unused.js';
26
26
  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
+ import { handleCodeAudit } from './handlers/code-audit.js';
29
30
  import { detectContextMode } from './integration/context-mode-detector.js';
30
31
  import { estimateTokens } from './core/token-estimator.js';
31
- import { resolveSafePath, validateSmartReadArgs, validateReadSymbolArgs, validateReadRangeArgs, validateReadDiffArgs, validateFindUsagesArgs, validateSmartReadManyArgs, validateReadForEditArgs, validateRelatedFilesArgs, validateOutlineArgs, validateFindUnusedArgs, } from './core/validation.js';
32
+ import { resolveSafePath, validateSmartReadArgs, validateReadSymbolArgs, validateReadRangeArgs, validateReadDiffArgs, validateFindUsagesArgs, validateSmartReadManyArgs, validateReadForEditArgs, validateRelatedFilesArgs, validateOutlineArgs, validateFindUnusedArgs, validateCodeAuditArgs, } from './core/validation.js';
32
33
  export async function createServer(projectRoot, options) {
33
34
  const config = await loadConfig(projectRoot);
34
35
  const astIndex = new AstIndexClient(projectRoot, config.astIndex.timeout, {
@@ -152,7 +153,7 @@ export async function createServer(projectRoot, options) {
152
153
  // Watches only files that have been loaded — NOT the entire project root
153
154
  let fileWatcher = null;
154
155
  if (config.cache.watchFiles) {
155
- fileWatcher = new FileWatcher(projectRoot, fileCache, contextRegistry, config.ignore);
156
+ fileWatcher = new FileWatcher(projectRoot, fileCache, contextRegistry, config.ignore, astIndex);
156
157
  fileWatcher.start();
157
158
  fileCache.onSet((filePath) => fileWatcher?.watchFile(filePath));
158
159
  }
@@ -180,13 +181,24 @@ export async function createServer(projectRoot, options) {
180
181
  '• New codebase → project_overview first',
181
182
  '• Reading file again → smart_read (returns compact reminder, not full content)',
182
183
  '• Multiple files → smart_read_many (batch, max 20)',
184
+ '• Code quality audit → code_audit (TODOs, deprecated, structural code patterns)',
183
185
  '',
184
186
  'WHEN TO USE DEFAULT TOOLS (Token Pilot adds no value):',
185
187
  '• Small files (≤200 lines) → smart_read returns full content anyway, same as Read',
186
- '• Regex/pattern search (e.g. TODO.*fix) → use Grep/ripgrep, NOT find_usages',
188
+ '• Regex text search (e.g. TODO.*fix) → use Grep/ripgrep',
189
+ '• Counting occurrences (e.g. how many `any` types?) → use Grep count mode',
190
+ '• Finding code duplication → use Grep to search for repeated patterns',
187
191
  '• Non-code files (JSON, YAML, Markdown, configs) → smart_read handles these but default Read works too',
188
192
  '• You need exact raw content for copy-paste → use Read',
189
193
  '',
194
+ 'COMBINE BOTH for audits and code review:',
195
+ '• Structure/navigation → Token Pilot (project_overview, outline, smart_read)',
196
+ '• Dead code detection → find_unused (finds unreferenced symbols)',
197
+ '• Code issues → code_audit (TODOs, deprecated, structural patterns like bare except:)',
198
+ '• Text pattern search/counting → Grep (regex, count mode)',
199
+ '• Security audit → Grep for: password, token, secret, credential, hardcoded, api_key, TODO.*security',
200
+ '• Deep dive into specific code → read_symbol (after finding issues)',
201
+ '',
190
202
  'WORKFLOW: project_overview → smart_read → read_symbol → read_for_edit → edit → read_diff',
191
203
  ].join('\n'),
192
204
  });
@@ -340,6 +352,25 @@ export async function createServer(projectRoot, options) {
340
352
  },
341
353
  },
342
354
  },
355
+ {
356
+ name: 'code_audit',
357
+ description: 'Find code quality issues: TODO/FIXME comments, deprecated symbols, structural code patterns (bare except:, print() calls). Use for project-wide audits.',
358
+ inputSchema: {
359
+ type: 'object',
360
+ properties: {
361
+ check: {
362
+ type: 'string',
363
+ enum: ['pattern', 'todo', 'deprecated', 'annotations', 'all'],
364
+ description: 'What to check: "pattern" (structural search via ast-grep, e.g. "except:", "print($$$ARGS)"), "todo" (TODO/FIXME comments), "deprecated" (deprecated symbols), "annotations" (find by decorator name), "all" (todo + deprecated summary)',
365
+ },
366
+ pattern: { type: 'string', description: 'Code pattern for check="pattern". ast-grep syntax: "except:" finds bare excepts, "print($$$ARGS)" finds print calls.' },
367
+ name: { type: 'string', description: 'Decorator/annotation name for check="annotations". Example: "Deprecated", "Controller"' },
368
+ lang: { type: 'string', description: 'Language filter for check="pattern" (e.g., "python", "typescript")' },
369
+ limit: { type: 'number', description: 'Max results (default: 50)' },
370
+ },
371
+ required: ['check'],
372
+ },
373
+ },
343
374
  ],
344
375
  }));
345
376
  // Helper: get real full-file token count for honest analytics
@@ -475,6 +506,13 @@ export async function createServer(projectRoot, options) {
475
506
  analytics.record({ tool: 'find_unused', path: unusedArgs.module ?? 'all', tokensReturned: estimateTokens(unusedText), tokensWouldBe: estimateTokens(unusedText), timestamp: Date.now() });
476
507
  return unusedResult;
477
508
  }
509
+ case 'code_audit': {
510
+ const auditArgs = validateCodeAuditArgs(args);
511
+ const auditResult = await handleCodeAudit(auditArgs, projectRoot, astIndex);
512
+ const auditText = auditResult.content[0]?.text ?? '';
513
+ analytics.record({ tool: 'code_audit', path: auditArgs.check, tokensReturned: estimateTokens(auditText), tokensWouldBe: estimateTokens(auditText), timestamp: Date.now() });
514
+ return auditResult;
515
+ }
478
516
  default:
479
517
  return {
480
518
  content: [{ type: 'text', text: `Unknown tool: ${name}` }],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "token-pilot",
3
- "version": "0.7.6",
4
- "description": "MCP server that reduces token consumption in AI coding assistants via AST-aware lazy file reading",
3
+ "version": "0.8.1",
4
+ "description": "Save 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",
7
7
  "bin": {
@@ -28,18 +28,24 @@
28
28
  "prepublishOnly": "npm run build && chmod +x dist/index.js"
29
29
  },
30
30
  "keywords": [
31
- "mcp",
32
- "token",
33
- "ast",
34
- "claude",
35
- "cursor",
36
- "codex",
37
- "cline",
38
- "ai",
39
- "coding-assistant",
31
+ "token-savings",
32
+ "token-reduction",
33
+ "context-window",
34
+ "save-tokens",
35
+ "reduce-tokens",
36
+ "token-efficient",
37
+ "token-economy",
40
38
  "context-optimization",
39
+ "fewer-tokens",
40
+ "mcp",
41
+ "mcp-server",
41
42
  "model-context-protocol",
42
- "token-savings"
43
+ "ast",
44
+ "code-reading",
45
+ "code-navigation",
46
+ "smart-read",
47
+ "ai-coding",
48
+ "llm-tools"
43
49
  ],
44
50
  "repository": {
45
51
  "type": "git",
@@ -69,5 +75,8 @@
69
75
  "ast-index": {
70
76
  "optional": true
71
77
  }
78
+ },
79
+ "optionalDependencies": {
80
+ "@ast-grep/cli": "^0.41.0"
72
81
  }
73
82
  }