token-pilot 0.13.0 → 0.14.2

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.
Files changed (69) hide show
  1. package/.claude-plugin/hooks/hooks.json +9 -0
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +29 -0
  5. package/README.md +36 -15
  6. package/dist/config/defaults.js +12 -0
  7. package/dist/core/architecture-fingerprint.d.ts +34 -0
  8. package/dist/core/architecture-fingerprint.js +127 -0
  9. package/dist/core/budget-planner.d.ts +21 -0
  10. package/dist/core/budget-planner.js +68 -0
  11. package/dist/core/confidence.d.ts +31 -0
  12. package/dist/core/confidence.js +99 -0
  13. package/dist/core/context-registry.d.ts +14 -0
  14. package/dist/core/context-registry.js +55 -0
  15. package/dist/core/decision-trace.d.ts +31 -0
  16. package/dist/core/decision-trace.js +45 -0
  17. package/dist/core/intent-classifier.d.ts +13 -0
  18. package/dist/core/intent-classifier.js +44 -0
  19. package/dist/core/policy-engine.d.ts +41 -0
  20. package/dist/core/policy-engine.js +76 -0
  21. package/dist/core/session-analytics.d.ts +8 -0
  22. package/dist/core/session-analytics.js +86 -7
  23. package/dist/core/session-cache.d.ts +74 -0
  24. package/dist/core/session-cache.js +162 -0
  25. package/dist/core/validation.d.ts +3 -0
  26. package/dist/core/validation.js +3 -0
  27. package/dist/git/file-watcher.d.ts +6 -0
  28. package/dist/git/file-watcher.js +18 -2
  29. package/dist/git/watcher.d.ts +3 -0
  30. package/dist/git/watcher.js +6 -0
  31. package/dist/handlers/code-audit.d.ts +7 -2
  32. package/dist/handlers/code-audit.js +19 -5
  33. package/dist/handlers/explore-area.d.ts +10 -0
  34. package/dist/handlers/explore-area.js +39 -13
  35. package/dist/handlers/find-unused.d.ts +3 -0
  36. package/dist/handlers/find-unused.js +3 -2
  37. package/dist/handlers/find-usages.d.ts +7 -0
  38. package/dist/handlers/find-usages.js +36 -5
  39. package/dist/handlers/module-info.d.ts +3 -0
  40. package/dist/handlers/module-info.js +22 -2
  41. package/dist/handlers/project-overview.d.ts +1 -1
  42. package/dist/handlers/project-overview.js +18 -2
  43. package/dist/handlers/read-for-edit.d.ts +3 -0
  44. package/dist/handlers/read-for-edit.js +185 -3
  45. package/dist/handlers/read-range.d.ts +1 -1
  46. package/dist/handlers/read-range.js +16 -1
  47. package/dist/handlers/read-symbol.d.ts +1 -1
  48. package/dist/handlers/read-symbol.js +26 -2
  49. package/dist/handlers/related-files.d.ts +11 -0
  50. package/dist/handlers/related-files.js +178 -42
  51. package/dist/handlers/smart-read-many.js +70 -16
  52. package/dist/handlers/smart-read.js +10 -1
  53. package/dist/handlers/test-summary.js +26 -3
  54. package/dist/hooks/installer.d.ts +12 -8
  55. package/dist/hooks/installer.js +24 -8
  56. package/dist/index.d.ts +16 -1
  57. package/dist/index.js +62 -56
  58. package/dist/server.js +395 -30
  59. package/dist/types.d.ts +12 -0
  60. package/package.json +18 -14
  61. package/start.sh +28 -27
  62. package/dist/handlers/class-hierarchy.d.ts +0 -11
  63. package/dist/handlers/class-hierarchy.js +0 -28
  64. package/dist/handlers/export-ast-index.d.ts +0 -22
  65. package/dist/handlers/export-ast-index.js +0 -175
  66. package/dist/handlers/find-implementations.d.ts +0 -11
  67. package/dist/handlers/find-implementations.js +0 -27
  68. package/dist/handlers/search-code.d.ts +0 -14
  69. package/dist/handlers/search-code.js +0 -32
package/dist/server.js CHANGED
@@ -5,9 +5,12 @@ import { FileCache } from './core/file-cache.js';
5
5
  import { ContextRegistry } from './core/context-registry.js';
6
6
  import { SymbolResolver } from './core/symbol-resolver.js';
7
7
  import { SessionAnalytics } from './core/session-analytics.js';
8
+ import { classifyIntent } from './core/intent-classifier.js';
9
+ import { buildDecisionTrace } from './core/decision-trace.js';
10
+ import { SessionCache } from './core/session-cache.js';
8
11
  import { loadConfig } from './config/loader.js';
9
12
  import { readFileSync } from 'node:fs';
10
- import { dirname } from 'node:path';
13
+ import { dirname, resolve } from 'node:path';
11
14
  import { execFile } from 'node:child_process';
12
15
  import { isDangerousRoot } from './core/validation.js';
13
16
  import { promisify } from 'node:util';
@@ -25,7 +28,7 @@ import { handleNonCodeRead, isNonCodeStructured } from './handlers/non-code.js';
25
28
  import { handleFindUnused } from './handlers/find-unused.js';
26
29
  import { handleReadForEdit } from './handlers/read-for-edit.js';
27
30
  import { handleRelatedFiles } from './handlers/related-files.js';
28
- import { handleOutline } from './handlers/outline.js';
31
+ import { handleOutline, CODE_EXTENSIONS } from './handlers/outline.js';
29
32
  import { handleCodeAudit } from './handlers/code-audit.js';
30
33
  import { handleModuleInfo } from './handlers/module-info.js';
31
34
  import { handleSmartDiff } from './handlers/smart-diff.js';
@@ -34,6 +37,7 @@ import { handleSmartLog } from './handlers/smart-log.js';
34
37
  import { handleTestSummary } from './handlers/test-summary.js';
35
38
  import { detectContextMode } from './integration/context-mode-detector.js';
36
39
  import { estimateTokens } from './core/token-estimator.js';
40
+ import { checkPolicy, isFullReadTool } from './core/policy-engine.js';
37
41
  import { resolveSafePath, validateSmartReadArgs, validateReadSymbolArgs, validateReadRangeArgs, validateReadDiffArgs, validateFindUsagesArgs, validateSmartReadManyArgs, validateReadForEditArgs, validateRelatedFilesArgs, validateOutlineArgs, validateFindUnusedArgs, validateCodeAuditArgs, validateProjectOverviewArgs, validateModuleInfoArgs, validateSmartDiffArgs, validateExploreAreaArgs, validateSmartLogArgs, validateTestSummaryArgs, } from './core/validation.js';
38
42
  export async function createServer(projectRoot, options) {
39
43
  const config = await loadConfig(projectRoot);
@@ -139,6 +143,13 @@ export async function createServer(projectRoot, options) {
139
143
  }
140
144
  // Session analytics
141
145
  const analytics = new SessionAnalytics();
146
+ // Session cache (tool-result-level caching, invalidated by file/AST/git changes)
147
+ const sessionCache = config.sessionCache.enabled
148
+ ? new SessionCache(config.sessionCache.maxEntries)
149
+ : null;
150
+ // Policy engine state
151
+ let fullFileReadsCount = 0;
152
+ const readForEditCalled = new Set();
142
153
  // Detect context-mode companion
143
154
  const cmEnabled = config.contextMode.enabled;
144
155
  const contextModeStatus = await detectContextMode(projectRoot, cmEnabled === 'auto' ? undefined : cmEnabled);
@@ -161,6 +172,17 @@ export async function createServer(projectRoot, options) {
161
172
  fileWatcher = new FileWatcher(projectRoot, fileCache, contextRegistry, config.ignore, astIndex);
162
173
  fileWatcher.start();
163
174
  fileCache.onSet((filePath) => fileWatcher?.watchFile(filePath));
175
+ if (sessionCache) {
176
+ fileWatcher.onFileChange((absPath) => sessionCache.invalidateByFiles([absPath]));
177
+ fileWatcher.onAstUpdate(() => sessionCache.invalidateByAst());
178
+ }
179
+ }
180
+ // Wire session cache to git watcher
181
+ if (sessionCache) {
182
+ gitWatcher.onBranchSwitchEvent((changedFiles) => {
183
+ sessionCache.invalidateByFiles(changedFiles);
184
+ sessionCache.invalidateByGit();
185
+ });
164
186
  }
165
187
  // Read version from package.json
166
188
  let pkgVersion = '0.1.1';
@@ -271,7 +293,7 @@ export async function createServer(projectRoot, options) {
271
293
  },
272
294
  {
273
295
  name: 'read_for_edit',
274
- description: 'Use INSTEAD OF Read when preparing an edit. Returns exact raw code around a symbol or line — copy directly as old_string for Edit tool.',
296
+ description: 'Use INSTEAD OF Read when preparing an edit. Returns exact raw code around a symbol or line — copy directly as old_string for Edit tool. Optional: include_callers, include_tests, include_changes for enriched context.',
275
297
  inputSchema: {
276
298
  type: 'object',
277
299
  properties: {
@@ -279,6 +301,9 @@ export async function createServer(projectRoot, options) {
279
301
  symbol: { type: 'string', description: 'Symbol name to edit (e.g. "UserService.updateUser")' },
280
302
  line: { type: 'number', description: 'Line number to edit (alternative to symbol)' },
281
303
  context: { type: 'number', description: 'Lines of context around target (default: 5)' },
304
+ include_callers: { type: 'boolean', description: 'Show top callers of this symbol (saves a separate find_usages call)' },
305
+ include_tests: { type: 'boolean', description: 'Show related test file and test names' },
306
+ include_changes: { type: 'boolean', description: 'Show recent git changes in the target region' },
282
307
  },
283
308
  required: ['path'],
284
309
  },
@@ -330,7 +355,7 @@ export async function createServer(projectRoot, options) {
330
355
  },
331
356
  {
332
357
  name: 'related_files',
333
- description: 'Show import graph for a file: what it imports, what imports it, and test files. Understand dependencies before refactoring.',
358
+ description: 'Show ranked import graph for a file: imports, importers, and tests scored by relevance (test adjacency, import closeness, recent changes, path proximity). Files ranked into HIGH VALUE / MEDIUM / LOW to prioritize reading.',
334
359
  inputSchema: {
335
360
  type: 'object',
336
361
  properties: {
@@ -480,6 +505,205 @@ export async function createServer(projectRoot, options) {
480
505
  return 0;
481
506
  }
482
507
  }
508
+ async function estimateProjectOverviewWorkflowTokens(includeSections) {
509
+ const sectionFiles = {
510
+ stack: ['package.json', 'composer.json', 'Cargo.toml', 'pyproject.toml', 'go.mod'],
511
+ ci: ['.gitlab-ci.yml', 'Jenkinsfile', '.circleci/config.yml', 'bitbucket-pipelines.yml', '.travis.yml'],
512
+ quality: [
513
+ 'tsconfig.json',
514
+ 'vitest.config.ts',
515
+ 'vitest.config.js',
516
+ 'vitest.config.mts',
517
+ 'jest.config.js',
518
+ 'jest.config.ts',
519
+ 'jest.config.mjs',
520
+ 'eslint.config.js',
521
+ 'eslint.config.mjs',
522
+ '.eslintrc',
523
+ '.eslintrc.js',
524
+ '.eslintrc.json',
525
+ '.eslintrc.yml',
526
+ 'biome.json',
527
+ 'biome.jsonc',
528
+ '.prettierrc',
529
+ '.prettierrc.js',
530
+ '.prettierrc.json',
531
+ 'prettier.config.js',
532
+ 'phpunit.xml',
533
+ 'phpunit.xml.dist',
534
+ 'phpstan.neon',
535
+ 'phpstan.neon.dist',
536
+ ],
537
+ architecture: ['README.md'],
538
+ };
539
+ let total = 0;
540
+ const seen = new Set();
541
+ for (const section of includeSections) {
542
+ for (const file of sectionFiles[section]) {
543
+ if (seen.has(file))
544
+ continue;
545
+ seen.add(file);
546
+ total += await fullFileTokens(file);
547
+ }
548
+ }
549
+ if (includeSections.includes('ci')) {
550
+ try {
551
+ const { readdir: readDirAsync } = await import('node:fs/promises');
552
+ const workflowDir = resolveSafePath(projectRoot, '.github/workflows');
553
+ const workflowFiles = await readDirAsync(workflowDir, { withFileTypes: true });
554
+ for (const file of workflowFiles) {
555
+ if (!file.isFile())
556
+ continue;
557
+ if (!file.name.endsWith('.yml') && !file.name.endsWith('.yaml'))
558
+ continue;
559
+ total += await fullFileTokens(`.github/workflows/${file.name}`);
560
+ }
561
+ }
562
+ catch {
563
+ // ignore missing workflows dir
564
+ }
565
+ }
566
+ if (includeSections.includes('architecture')) {
567
+ total += 200;
568
+ }
569
+ return total;
570
+ }
571
+ async function estimateOutlineWorkflowTokens(relativePath, recursive, maxDepth) {
572
+ const SAMPLE_LIMIT = 30;
573
+ try {
574
+ const { readdir: readDirAsync } = await import('node:fs/promises');
575
+ const { resolve: resolvePath } = await import('node:path');
576
+ const absDir = resolveSafePath(projectRoot, relativePath);
577
+ const sampledFiles = [];
578
+ let totalFiles = 0;
579
+ async function walk(dirPath, depth) {
580
+ const entries = await readDirAsync(dirPath, { withFileTypes: true });
581
+ for (const entry of entries) {
582
+ if (entry.isFile()) {
583
+ const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
584
+ if (!CODE_EXTENSIONS.has(ext))
585
+ continue;
586
+ totalFiles++;
587
+ if (sampledFiles.length < SAMPLE_LIMIT) {
588
+ sampledFiles.push(resolvePath(dirPath, entry.name));
589
+ }
590
+ continue;
591
+ }
592
+ if (entry.isDirectory() && recursive && depth < maxDepth) {
593
+ await walk(resolvePath(dirPath, entry.name), depth + 1);
594
+ }
595
+ }
596
+ }
597
+ await walk(absDir, 0);
598
+ if (totalFiles === 0)
599
+ return 0;
600
+ let sampledTokens = 0;
601
+ for (const filePath of sampledFiles) {
602
+ const relPath = filePath.startsWith(projectRoot)
603
+ ? filePath.slice(projectRoot.length + 1)
604
+ : filePath;
605
+ sampledTokens += await fullFileTokens(relPath);
606
+ }
607
+ if (sampledFiles.length === 0 || sampledTokens === 0)
608
+ return 0;
609
+ if (sampledFiles.length === totalFiles)
610
+ return sampledTokens;
611
+ const averageTokens = sampledTokens / sampledFiles.length;
612
+ return Math.round(averageTokens * totalFiles);
613
+ }
614
+ catch {
615
+ return 0;
616
+ }
617
+ }
618
+ async function estimateRelatedFilesWorkflowTokens(targetPath, meta) {
619
+ const related = new Set([targetPath]);
620
+ for (const path of meta?.imports ?? [])
621
+ related.add(path);
622
+ for (const path of meta?.importedBy ?? [])
623
+ related.add(path);
624
+ for (const path of meta?.tests ?? [])
625
+ related.add(path);
626
+ let total = 0;
627
+ let counted = 0;
628
+ for (const path of related) {
629
+ total += await fullFileTokens(path);
630
+ counted++;
631
+ if (counted >= 12)
632
+ break;
633
+ }
634
+ return total;
635
+ }
636
+ async function estimateFindUsagesWorkflowTokens(files) {
637
+ let total = 0;
638
+ let counted = 0;
639
+ for (const file of files) {
640
+ total += await fullFileTokens(file);
641
+ counted++;
642
+ if (counted >= 20)
643
+ break;
644
+ }
645
+ return total;
646
+ }
647
+ /** Detect savings category from response text prefix. */
648
+ function detectSavingsCategory(text) {
649
+ if (text.startsWith('REMINDER:') || text.startsWith('DEDUP:'))
650
+ return 'dedup';
651
+ return 'compression';
652
+ }
653
+ /** Record analytics with intent classification and decision trace. Returns policy advisory if any. */
654
+ function recordWithTrace(call) {
655
+ const { absPath, args, recentlyEdited, ...rest } = call;
656
+ analytics.record({
657
+ ...rest,
658
+ intent: classifyIntent(rest.tool),
659
+ decisionTrace: buildDecisionTrace({
660
+ absPath,
661
+ tool: rest.tool,
662
+ args: (args ?? {}),
663
+ contextRegistry,
664
+ fileCache,
665
+ tokensReturned: rest.tokensReturned,
666
+ tokensWouldBe: rest.tokensWouldBe,
667
+ recentlyEdited,
668
+ }),
669
+ });
670
+ // Policy tracking
671
+ if (isFullReadTool(rest.tool)) {
672
+ fullFileReadsCount++;
673
+ }
674
+ if (rest.tool === 'read_for_edit' && call.path) {
675
+ readForEditCalled.add(call.path);
676
+ }
677
+ // Policy check
678
+ const advisory = checkPolicy(config.policies, rest.tool, {
679
+ fullFileReadsCount,
680
+ tokensReturned: rest.tokensReturned,
681
+ readForEditCalled,
682
+ });
683
+ return advisory ? `\n${advisory.message}` : null;
684
+ }
685
+ async function estimateExploreAreaWorkflowTokens(meta) {
686
+ const localFiles = new Set();
687
+ for (const file of meta.codeFiles ?? [])
688
+ localFiles.add(file);
689
+ for (const file of meta.testFiles ?? [])
690
+ localFiles.add(file);
691
+ for (const file of meta.internalDeps ?? [])
692
+ localFiles.add(file);
693
+ for (const file of meta.importedBy ?? [])
694
+ localFiles.add(file);
695
+ let total = 0;
696
+ let counted = 0;
697
+ for (const file of localFiles) {
698
+ total += await fullFileTokens(file);
699
+ counted++;
700
+ if (counted >= 24)
701
+ break;
702
+ }
703
+ total += (meta.externalDeps?.length ?? 0) * 30;
704
+ total += (meta.changeCount ?? 0) * 40;
705
+ return total;
706
+ }
483
707
  // Handle tool calls with validated arguments
484
708
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
485
709
  const { name, arguments: args } = request.params;
@@ -502,32 +726,44 @@ export async function createServer(projectRoot, options) {
502
726
  });
503
727
  if (nonCodeResult) {
504
728
  const text = nonCodeResult.content[0]?.text ?? '';
505
- analytics.record({ tool: 'smart_read', path: validArgs.path, tokensReturned: estimateTokens(text), tokensWouldBe: estimateTokens(text), timestamp: Date.now() });
729
+ recordWithTrace({
730
+ tool: 'smart_read',
731
+ path: validArgs.path,
732
+ tokensReturned: estimateTokens(text),
733
+ tokensWouldBe: await fullFileTokens(validArgs.path) || estimateTokens(text),
734
+ timestamp: Date.now(),
735
+ delegatedToContextMode: text.includes('ADVISORY:') && text.includes('context-mode'),
736
+ savingsCategory: 'compression',
737
+ absPath: resolve(projectRoot, validArgs.path),
738
+ args: validArgs,
739
+ });
506
740
  return nonCodeResult;
507
741
  }
508
742
  }
509
743
  const result = await handleSmartRead(validArgs, projectRoot, astIndex, fileCache, contextRegistry, config);
510
744
  const text = result.content[0]?.text ?? '';
511
745
  const fullTokensSR = await fullFileTokens(validArgs.path);
512
- analytics.record({ tool: 'smart_read', path: validArgs.path, tokensReturned: estimateTokens(text), tokensWouldBe: fullTokensSR || estimateTokens(text), timestamp: Date.now() });
746
+ const policyAdv = recordWithTrace({ tool: 'smart_read', path: validArgs.path, tokensReturned: estimateTokens(text), tokensWouldBe: fullTokensSR || estimateTokens(text), timestamp: Date.now(), savingsCategory: detectSavingsCategory(text), absPath: resolve(projectRoot, validArgs.path), args: validArgs });
747
+ if (policyAdv)
748
+ result.content[0] = { type: 'text', text: text + policyAdv };
513
749
  return result;
514
750
  }
515
751
  case 'read_symbol': {
516
752
  const symArgs = validateReadSymbolArgs(args);
517
- const symResult = await handleReadSymbol(symArgs, projectRoot, symbolResolver, fileCache, contextRegistry, astIndex);
753
+ const symResult = await handleReadSymbol(symArgs, projectRoot, symbolResolver, fileCache, contextRegistry, astIndex, config.smartRead.advisoryReminders);
518
754
  const symText = symResult.content[0]?.text ?? '';
519
755
  const symTokens = estimateTokens(symText);
520
756
  const fullTokensSym = await fullFileTokens(symArgs.path);
521
- analytics.record({ tool: 'read_symbol', path: symArgs.path, tokensReturned: symTokens, tokensWouldBe: fullTokensSym || symTokens, timestamp: Date.now() });
757
+ recordWithTrace({ tool: 'read_symbol', path: symArgs.path, tokensReturned: symTokens, tokensWouldBe: fullTokensSym || symTokens, timestamp: Date.now(), savingsCategory: detectSavingsCategory(symText), absPath: resolve(projectRoot, symArgs.path), args: symArgs });
522
758
  return symResult;
523
759
  }
524
760
  case 'read_range': {
525
761
  const rangeArgs = validateReadRangeArgs(args);
526
- const rangeResult = await handleReadRange(rangeArgs, projectRoot, fileCache, contextRegistry);
762
+ const rangeResult = await handleReadRange(rangeArgs, projectRoot, fileCache, contextRegistry, config.smartRead.advisoryReminders);
527
763
  const rangeText = rangeResult.content[0]?.text ?? '';
528
764
  const rangeTokens = estimateTokens(rangeText);
529
765
  const fullTokensRange = await fullFileTokens(rangeArgs.path);
530
- analytics.record({ tool: 'read_range', path: rangeArgs.path, tokensReturned: rangeTokens, tokensWouldBe: fullTokensRange || rangeTokens, timestamp: Date.now() });
766
+ recordWithTrace({ tool: 'read_range', path: rangeArgs.path, tokensReturned: rangeTokens, tokensWouldBe: fullTokensRange || rangeTokens, timestamp: Date.now(), savingsCategory: detectSavingsCategory(rangeText), absPath: resolve(projectRoot, rangeArgs.path), args: rangeArgs });
531
767
  return rangeResult;
532
768
  }
533
769
  case 'read_diff': {
@@ -536,7 +772,7 @@ export async function createServer(projectRoot, options) {
536
772
  const diffText = diffResult.content[0]?.text ?? '';
537
773
  const diffTokens = estimateTokens(diffText);
538
774
  const fullTokensDiff = await fullFileTokens(diffArgs.path);
539
- analytics.record({ tool: 'read_diff', path: diffArgs.path, tokensReturned: diffTokens, tokensWouldBe: fullTokensDiff || diffTokens, timestamp: Date.now() });
775
+ recordWithTrace({ tool: 'read_diff', path: diffArgs.path, tokensReturned: diffTokens, tokensWouldBe: fullTokensDiff || diffTokens, timestamp: Date.now(), savingsCategory: 'compression', absPath: resolve(projectRoot, diffArgs.path), args: diffArgs });
540
776
  return diffResult;
541
777
  }
542
778
  case 'read_for_edit': {
@@ -545,7 +781,7 @@ export async function createServer(projectRoot, options) {
545
781
  const editText = editResult.content[0]?.text ?? '';
546
782
  const editTokens = estimateTokens(editText);
547
783
  const fullTokensEdit = await fullFileTokens(editArgs.path);
548
- analytics.record({ tool: 'read_for_edit', path: editArgs.path, tokensReturned: editTokens, tokensWouldBe: fullTokensEdit || editTokens, timestamp: Date.now() });
784
+ recordWithTrace({ tool: 'read_for_edit', path: editArgs.path, tokensReturned: editTokens, tokensWouldBe: fullTokensEdit || editTokens, timestamp: Date.now(), savingsCategory: 'compression', absPath: resolve(projectRoot, editArgs.path), args: editArgs });
549
785
  return editResult;
550
786
  }
551
787
  case 'smart_read_many': {
@@ -553,66 +789,178 @@ export async function createServer(projectRoot, options) {
553
789
  const manyResult = await handleSmartReadMany(manyArgs, projectRoot, astIndex, fileCache, contextRegistry, config);
554
790
  const manyText = manyResult.content[0]?.text ?? '';
555
791
  const manyTokens = estimateTokens(manyText);
792
+ const uniqueManyPaths = Array.from(new Set(manyArgs.paths));
556
793
  let fullTokensMany = 0;
557
- for (const p of manyArgs.paths) {
794
+ for (const p of uniqueManyPaths) {
558
795
  fullTokensMany += await fullFileTokens(p);
559
796
  }
560
- analytics.record({ tool: 'smart_read_many', path: manyArgs.paths.join(', '), tokensReturned: manyTokens, tokensWouldBe: fullTokensMany || manyTokens, timestamp: Date.now() });
797
+ recordWithTrace({
798
+ tool: 'smart_read_many',
799
+ path: uniqueManyPaths.join(', '),
800
+ tokensReturned: manyTokens,
801
+ tokensWouldBe: fullTokensMany || manyTokens,
802
+ timestamp: Date.now(),
803
+ savingsCategory: 'compression',
804
+ args: manyArgs,
805
+ });
561
806
  return manyResult;
562
807
  }
563
808
  case 'find_usages': {
564
809
  const usagesArgs = validateFindUsagesArgs(args);
810
+ const cachedUsages = sessionCache?.get('find_usages', usagesArgs);
811
+ if (cachedUsages) {
812
+ recordWithTrace({ tool: 'find_usages', path: usagesArgs.symbol, tokensReturned: cachedUsages.tokenEstimate, tokensWouldBe: cachedUsages.tokensWouldBe ?? cachedUsages.tokenEstimate, timestamp: Date.now(), sessionCacheHit: true, savingsCategory: 'cache', args: usagesArgs });
813
+ return cachedUsages.result;
814
+ }
565
815
  const usagesResult = await handleFindUsages(usagesArgs, astIndex);
566
816
  const usagesText = usagesResult.content[0]?.text ?? '';
567
- analytics.record({ tool: 'find_usages', path: usagesArgs.symbol, tokensReturned: estimateTokens(usagesText), tokensWouldBe: estimateTokens(usagesText), timestamp: Date.now() });
817
+ const usagesTokens = estimateTokens(usagesText);
818
+ const usagesWouldBe = await estimateFindUsagesWorkflowTokens(usagesResult.meta.files);
819
+ sessionCache?.set('find_usages', usagesArgs, usagesResult, {
820
+ files: usagesResult.meta.files.map(f => resolve(projectRoot, f)),
821
+ dependsOnAst: true,
822
+ }, usagesTokens, usagesWouldBe || usagesTokens);
823
+ recordWithTrace({
824
+ tool: 'find_usages',
825
+ path: usagesArgs.symbol,
826
+ tokensReturned: usagesTokens,
827
+ tokensWouldBe: usagesWouldBe || usagesTokens,
828
+ timestamp: Date.now(),
829
+ savingsCategory: 'compression',
830
+ args: usagesArgs,
831
+ });
568
832
  return usagesResult;
569
833
  }
570
834
  case 'project_overview': {
571
835
  const overviewArgs = validateProjectOverviewArgs(args);
572
- const overviewResult = await handleProjectOverview(overviewArgs, projectRoot, astIndex);
836
+ const cachedOverview = sessionCache?.get('project_overview', overviewArgs);
837
+ if (cachedOverview) {
838
+ recordWithTrace({ tool: 'project_overview', path: projectRoot, tokensReturned: cachedOverview.tokenEstimate, tokensWouldBe: cachedOverview.tokensWouldBe ?? cachedOverview.tokenEstimate, timestamp: Date.now(), sessionCacheHit: true, savingsCategory: 'cache', args: overviewArgs });
839
+ return cachedOverview.result;
840
+ }
841
+ const overviewResult = await handleProjectOverview(overviewArgs, projectRoot, astIndex, pkgVersion);
573
842
  const overviewText = overviewResult.content[0]?.text ?? '';
574
843
  overviewResult.content[0] = { type: 'text', text: `TOKEN PILOT v${pkgVersion}\n\n${overviewText}` };
575
844
  const ovTokens = estimateTokens(overviewResult.content[0].text);
576
- analytics.record({ tool: 'project_overview', path: projectRoot, tokensReturned: ovTokens, tokensWouldBe: ovTokens, timestamp: Date.now() });
845
+ const overviewWouldBe = await estimateProjectOverviewWorkflowTokens(overviewArgs.include ?? ['stack', 'ci', 'quality', 'architecture']);
846
+ sessionCache?.set('project_overview', overviewArgs, overviewResult, {
847
+ dependsOnAst: true,
848
+ }, ovTokens, overviewWouldBe || ovTokens);
849
+ recordWithTrace({
850
+ tool: 'project_overview',
851
+ path: projectRoot,
852
+ tokensReturned: ovTokens,
853
+ tokensWouldBe: overviewWouldBe || ovTokens,
854
+ timestamp: Date.now(),
855
+ savingsCategory: 'compression',
856
+ args: overviewArgs,
857
+ });
577
858
  return overviewResult;
578
859
  }
579
860
  case 'related_files': {
580
861
  const relArgs = validateRelatedFilesArgs(args);
862
+ const cachedRel = sessionCache?.get('related_files', relArgs);
863
+ if (cachedRel) {
864
+ recordWithTrace({ tool: 'related_files', path: relArgs.path, tokensReturned: cachedRel.tokenEstimate, tokensWouldBe: cachedRel.tokensWouldBe ?? cachedRel.tokenEstimate, timestamp: Date.now(), sessionCacheHit: true, savingsCategory: 'cache', absPath: resolve(projectRoot, relArgs.path), args: relArgs });
865
+ return cachedRel.result;
866
+ }
581
867
  const relResult = await handleRelatedFiles(relArgs, projectRoot, astIndex);
582
868
  const relText = relResult.content[0]?.text ?? '';
583
- analytics.record({ tool: 'related_files', path: relArgs.path, tokensReturned: estimateTokens(relText), tokensWouldBe: estimateTokens(relText), timestamp: Date.now() });
869
+ const relTokens = estimateTokens(relText);
870
+ const relWouldBe = await estimateRelatedFilesWorkflowTokens(relArgs.path, relResult.meta);
871
+ const relDeps = [
872
+ resolve(projectRoot, relArgs.path),
873
+ ...relResult.meta.imports.map(f => resolve(projectRoot, f)),
874
+ ...relResult.meta.importedBy.map(f => resolve(projectRoot, f)),
875
+ ...relResult.meta.tests.map(f => resolve(projectRoot, f)),
876
+ ];
877
+ sessionCache?.set('related_files', relArgs, relResult, {
878
+ files: relDeps,
879
+ dependsOnAst: true,
880
+ }, relTokens, relWouldBe || relTokens);
881
+ recordWithTrace({
882
+ tool: 'related_files',
883
+ path: relArgs.path,
884
+ tokensReturned: relTokens,
885
+ tokensWouldBe: relWouldBe || relTokens,
886
+ timestamp: Date.now(),
887
+ savingsCategory: 'compression',
888
+ absPath: resolve(projectRoot, relArgs.path),
889
+ args: relArgs,
890
+ });
584
891
  return relResult;
585
892
  }
586
893
  case 'outline': {
587
894
  const outlineArgs = validateOutlineArgs(args);
895
+ const cachedOutline = sessionCache?.get('outline', outlineArgs);
896
+ if (cachedOutline) {
897
+ recordWithTrace({ tool: 'outline', path: outlineArgs.path, tokensReturned: cachedOutline.tokenEstimate, tokensWouldBe: cachedOutline.tokensWouldBe ?? cachedOutline.tokenEstimate, timestamp: Date.now(), sessionCacheHit: true, savingsCategory: 'cache', args: outlineArgs });
898
+ return cachedOutline.result;
899
+ }
588
900
  const outlineResult = await handleOutline(outlineArgs, projectRoot, astIndex);
589
901
  const outlineText = outlineResult.content[0]?.text ?? '';
590
- analytics.record({ tool: 'outline', path: outlineArgs.path, tokensReturned: estimateTokens(outlineText), tokensWouldBe: estimateTokens(outlineText), timestamp: Date.now() });
902
+ const outlineTokens = estimateTokens(outlineText);
903
+ const outlineWouldBe = await estimateOutlineWorkflowTokens(outlineArgs.path, outlineArgs.recursive ?? false, outlineArgs.max_depth ?? 2);
904
+ sessionCache?.set('outline', outlineArgs, outlineResult, {
905
+ files: [resolve(projectRoot, outlineArgs.path) + '/'],
906
+ dependsOnAst: true,
907
+ }, outlineTokens, outlineWouldBe || outlineTokens);
908
+ recordWithTrace({
909
+ tool: 'outline',
910
+ path: outlineArgs.path,
911
+ tokensReturned: outlineTokens,
912
+ tokensWouldBe: outlineWouldBe || outlineTokens,
913
+ timestamp: Date.now(),
914
+ savingsCategory: 'compression',
915
+ args: outlineArgs,
916
+ });
591
917
  return outlineResult;
592
918
  }
593
919
  case 'session_analytics':
594
920
  return { content: [{ type: 'text', text: `TOKEN PILOT v${pkgVersion}\n\n${analytics.report()}` }] };
595
921
  case 'find_unused': {
596
922
  const unusedArgs = validateFindUnusedArgs(args);
923
+ const cachedUnused = sessionCache?.get('find_unused', unusedArgs);
924
+ if (cachedUnused) {
925
+ recordWithTrace({ tool: 'find_unused', path: unusedArgs.module ?? 'all', tokensReturned: cachedUnused.tokenEstimate, tokensWouldBe: cachedUnused.tokensWouldBe ?? cachedUnused.tokenEstimate, timestamp: Date.now(), sessionCacheHit: true, savingsCategory: 'cache', args: unusedArgs });
926
+ return cachedUnused.result;
927
+ }
597
928
  const unusedResult = await handleFindUnused(unusedArgs, astIndex);
598
929
  const unusedText = unusedResult.content[0]?.text ?? '';
599
- analytics.record({ tool: 'find_unused', path: unusedArgs.module ?? 'all', tokensReturned: estimateTokens(unusedText), tokensWouldBe: estimateTokens(unusedText), timestamp: Date.now() });
930
+ const unusedTokens = estimateTokens(unusedText);
931
+ const unusedWouldBe = await estimateFindUsagesWorkflowTokens(unusedResult.meta.files);
932
+ sessionCache?.set('find_unused', unusedArgs, unusedResult, { dependsOnAst: true }, unusedTokens, unusedWouldBe || unusedTokens);
933
+ recordWithTrace({ tool: 'find_unused', path: unusedArgs.module ?? 'all', tokensReturned: unusedTokens, tokensWouldBe: unusedWouldBe || unusedTokens, timestamp: Date.now(), savingsCategory: 'compression', args: unusedArgs });
600
934
  return unusedResult;
601
935
  }
602
936
  case 'code_audit': {
603
937
  const auditArgs = validateCodeAuditArgs(args);
938
+ const cachedAudit = sessionCache?.get('code_audit', auditArgs);
939
+ if (cachedAudit) {
940
+ recordWithTrace({ tool: 'code_audit', path: auditArgs.check, tokensReturned: cachedAudit.tokenEstimate, tokensWouldBe: cachedAudit.tokensWouldBe ?? cachedAudit.tokenEstimate, timestamp: Date.now(), sessionCacheHit: true, savingsCategory: 'cache', args: auditArgs });
941
+ return cachedAudit.result;
942
+ }
604
943
  const auditResult = await handleCodeAudit(auditArgs, projectRoot, astIndex);
605
944
  const auditText = auditResult.content[0]?.text ?? '';
606
- analytics.record({ tool: 'code_audit', path: auditArgs.check, tokensReturned: estimateTokens(auditText), tokensWouldBe: estimateTokens(auditText), timestamp: Date.now() });
945
+ const auditTokens = estimateTokens(auditText);
946
+ const auditWouldBe = await estimateFindUsagesWorkflowTokens(auditResult.meta.files);
947
+ sessionCache?.set('code_audit', auditArgs, auditResult, { dependsOnAst: true }, auditTokens, auditWouldBe || auditTokens);
948
+ recordWithTrace({ tool: 'code_audit', path: auditArgs.check, tokensReturned: auditTokens, tokensWouldBe: auditWouldBe || auditTokens, timestamp: Date.now(), savingsCategory: 'compression', args: auditArgs });
607
949
  return auditResult;
608
950
  }
609
951
  case 'module_info': {
610
952
  const moduleArgs = validateModuleInfoArgs(args);
953
+ const cachedModule = sessionCache?.get('module_info', moduleArgs);
954
+ if (cachedModule) {
955
+ recordWithTrace({ tool: 'module_info', path: moduleArgs.module, tokensReturned: cachedModule.tokenEstimate, tokensWouldBe: cachedModule.tokensWouldBe ?? cachedModule.tokenEstimate, timestamp: Date.now(), sessionCacheHit: true, savingsCategory: 'cache', args: moduleArgs });
956
+ return cachedModule.result;
957
+ }
611
958
  const moduleResult = await handleModuleInfo(moduleArgs, projectRoot, astIndex);
612
959
  const moduleText = moduleResult.content[0]?.text ?? '';
613
- // Estimate: manual analysis would require reading all module files + grepping deps
614
- const moduleWouldBe = estimateTokens(moduleText) * 5;
615
- analytics.record({ tool: 'module_info', path: moduleArgs.module, tokensReturned: estimateTokens(moduleText), tokensWouldBe: moduleWouldBe, timestamp: Date.now() });
960
+ const moduleTokens = estimateTokens(moduleText);
961
+ const moduleWouldBe = await estimateFindUsagesWorkflowTokens(moduleResult.meta.files);
962
+ sessionCache?.set('module_info', moduleArgs, moduleResult, { dependsOnAst: true }, moduleTokens, moduleWouldBe || moduleTokens);
963
+ recordWithTrace({ tool: 'module_info', path: moduleArgs.module, tokensReturned: moduleTokens, tokensWouldBe: moduleWouldBe || moduleTokens, timestamp: Date.now(), savingsCategory: 'compression', args: moduleArgs });
616
964
  return moduleResult;
617
965
  }
618
966
  case 'smart_diff': {
@@ -620,17 +968,34 @@ export async function createServer(projectRoot, options) {
620
968
  const sdResult = await handleSmartDiff(sdArgs, projectRoot, astIndex);
621
969
  const sdText = sdResult.content[0]?.text ?? '';
622
970
  const sdTokens = estimateTokens(sdText);
623
- analytics.record({ tool: 'smart_diff', path: sdArgs.path ?? sdArgs.scope ?? 'unstaged', tokensReturned: sdTokens, tokensWouldBe: sdResult.rawTokens || sdTokens, timestamp: Date.now() });
971
+ recordWithTrace({ tool: 'smart_diff', path: sdArgs.path ?? sdArgs.scope ?? 'unstaged', tokensReturned: sdTokens, tokensWouldBe: sdResult.rawTokens || sdTokens, timestamp: Date.now(), savingsCategory: 'compression', args: sdArgs });
624
972
  return { content: sdResult.content };
625
973
  }
626
974
  case 'explore_area': {
627
975
  const eaArgs = validateExploreAreaArgs(args);
976
+ const cachedEa = sessionCache?.get('explore_area', eaArgs);
977
+ if (cachedEa) {
978
+ recordWithTrace({ tool: 'explore_area', path: eaArgs.path, tokensReturned: cachedEa.tokenEstimate, tokensWouldBe: cachedEa.tokensWouldBe ?? cachedEa.tokenEstimate, timestamp: Date.now(), sessionCacheHit: true, savingsCategory: 'cache', args: eaArgs });
979
+ return cachedEa.result;
980
+ }
628
981
  const eaResult = await handleExploreArea(eaArgs, projectRoot, astIndex);
629
982
  const eaText = eaResult.content[0]?.text ?? '';
630
983
  const eaTokens = estimateTokens(eaText);
631
- // Without explore_area, agent would call: outline + related_files + git log = ~3-5x tokens
632
- const eaWouldBe = eaTokens * 4;
633
- analytics.record({ tool: 'explore_area', path: eaArgs.path, tokensReturned: eaTokens, tokensWouldBe: eaWouldBe, timestamp: Date.now() });
984
+ const eaWouldBe = await estimateExploreAreaWorkflowTokens(eaResult.meta);
985
+ sessionCache?.set('explore_area', eaArgs, eaResult, {
986
+ files: [resolve(projectRoot, eaArgs.path) + '/'],
987
+ dependsOnAst: true,
988
+ dependsOnGit: true,
989
+ }, eaTokens, eaWouldBe || eaTokens);
990
+ recordWithTrace({
991
+ tool: 'explore_area',
992
+ path: eaArgs.path,
993
+ tokensReturned: eaTokens,
994
+ tokensWouldBe: eaWouldBe || eaTokens,
995
+ timestamp: Date.now(),
996
+ savingsCategory: 'compression',
997
+ args: eaArgs,
998
+ });
634
999
  return eaResult;
635
1000
  }
636
1001
  case 'smart_log': {
@@ -638,7 +1003,7 @@ export async function createServer(projectRoot, options) {
638
1003
  const slResult = await handleSmartLog(slArgs, projectRoot);
639
1004
  const slText = slResult.content[0]?.text ?? '';
640
1005
  const slTokens = estimateTokens(slText);
641
- analytics.record({ tool: 'smart_log', path: slArgs.path ?? 'all', tokensReturned: slTokens, tokensWouldBe: slResult.rawTokens || slTokens, timestamp: Date.now() });
1006
+ recordWithTrace({ tool: 'smart_log', path: slArgs.path ?? 'all', tokensReturned: slTokens, tokensWouldBe: slResult.rawTokens || slTokens, timestamp: Date.now(), savingsCategory: 'compression', args: slArgs });
642
1007
  return { content: slResult.content };
643
1008
  }
644
1009
  case 'test_summary': {
@@ -646,7 +1011,7 @@ export async function createServer(projectRoot, options) {
646
1011
  const tsResult = await handleTestSummary(tsArgs, projectRoot);
647
1012
  const tsText = tsResult.content[0]?.text ?? '';
648
1013
  const tsTokens = estimateTokens(tsText);
649
- analytics.record({ tool: 'test_summary', path: tsArgs.command, tokensReturned: tsTokens, tokensWouldBe: tsResult.rawTokens || tsTokens, timestamp: Date.now() });
1014
+ recordWithTrace({ tool: 'test_summary', path: tsArgs.command, tokensReturned: tsTokens, tokensWouldBe: tsResult.rawTokens || tsTokens, timestamp: Date.now(), savingsCategory: 'compression', args: tsArgs });
650
1015
  return { content: tsResult.content };
651
1016
  }
652
1017
  default:
package/dist/types.d.ts CHANGED
@@ -119,6 +119,18 @@ export interface TokenPilotConfig {
119
119
  checkOnStartup: boolean;
120
120
  autoUpdate: boolean;
121
121
  };
122
+ sessionCache: {
123
+ enabled: boolean;
124
+ maxEntries: number;
125
+ };
126
+ policies: {
127
+ preferCheapReads: boolean;
128
+ requireReadForEditBeforeEdit: boolean;
129
+ cacheProjectOverview: boolean;
130
+ maxFullFileReads: number;
131
+ warnOnLargeReads: boolean;
132
+ largeReadThreshold: number;
133
+ };
122
134
  ignore: string[];
123
135
  }
124
136
  //# sourceMappingURL=types.d.ts.map