ctxman 3.0.0 → 3.1.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.
Files changed (223) hide show
  1. package/.ctxman/llm-profiles.json +1067 -39
  2. package/.ctxman/profiles/backend-team.json +13 -0
  3. package/.ctxman/profiles/default.json +12 -0
  4. package/.ctxman/profiles/devops-team.json +21 -0
  5. package/.ctxman/profiles/frontend-team.json +20 -0
  6. package/.ctxman/templates/bug-fix.json +17 -0
  7. package/.ctxman/templates/code-review.json +9 -0
  8. package/.ctxman/templates/documentation.json +9 -0
  9. package/.ctxman/templates/feature.json +9 -0
  10. package/.ctxman/templates/full-context.json +9 -0
  11. package/.ctxman/templates/refactoring.json +18 -0
  12. package/.ctxman/templates/testing.json +9 -0
  13. package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
  14. package/LICENSE +21 -0
  15. package/README.md +380 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -13
  18. package/bin/cm-audit.js +124 -0
  19. package/bin/cm-count.js +151 -0
  20. package/bin/cm-gitingest.js +128 -115
  21. package/bin/cm-map.js +242 -0
  22. package/bin/cm-mcp.js +293 -0
  23. package/bin/cm-pack.js +405 -0
  24. package/bin/cm-policy.js +170 -0
  25. package/bin/cm-pr.js +248 -0
  26. package/bin/cm-search.js +178 -0
  27. package/bin/cm-update.js +128 -128
  28. package/bin/cm-why.js +197 -0
  29. package/bin/mcp-server.js +8 -24
  30. package/ctxman.js +237 -183
  31. package/examples/README.md +19 -4
  32. package/examples/github-actions/ctxman-check.yml +49 -0
  33. package/examples/wizard-profiles/security-audit/profile.json +7 -1
  34. package/index.d.ts +1422 -0
  35. package/index.js +55 -31
  36. package/lib/agents/pr-failure-agent.js +48 -45
  37. package/lib/analyzers/ai-suggester.js +451 -0
  38. package/lib/analyzers/dependency-scanner.js +505 -0
  39. package/lib/analyzers/duplicate-detector.js +264 -0
  40. package/lib/analyzers/go-method-analyzer.js +151 -110
  41. package/lib/analyzers/import-tracker.js +431 -0
  42. package/lib/analyzers/method-analyzer.js +1055 -434
  43. package/lib/analyzers/performance-dashboard.js +683 -0
  44. package/lib/analyzers/token-calculator.js +895 -504
  45. package/lib/analyzers/unused-exports-detector.js +336 -0
  46. package/lib/api/mcp/install.js +424 -0
  47. package/lib/api/mcp/prompts.js +281 -281
  48. package/lib/api/mcp/resources.js +300 -241
  49. package/lib/api/mcp/sandbox.js +89 -0
  50. package/lib/api/mcp/server.js +675 -312
  51. package/lib/api/mcp/start.js +125 -0
  52. package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
  53. package/lib/api/rest/server.js +105 -28
  54. package/lib/cache/CacheManager.js +9 -17
  55. package/lib/core/Analyzer.js +16 -10
  56. package/lib/core/ContextBuilder.js +19 -28
  57. package/lib/core/Reporter.js +15 -14
  58. package/lib/core/Scanner.js +81 -12
  59. package/lib/count/index.js +100 -0
  60. package/lib/formatters/format-registry.js +394 -372
  61. package/lib/formatters/gitingest-formatter.js +656 -621
  62. package/lib/formatters/toon-formatter-v1.3.js +637 -632
  63. package/lib/formatters/toon-formatter.js +239 -240
  64. package/lib/integrations/git/BlameTracker.js +4 -4
  65. package/lib/integrations/git/DiffAnalyzer.js +38 -31
  66. package/lib/integrations/git/GitClient.js +71 -53
  67. package/lib/lsp/server.js +311 -0
  68. package/lib/map/graph.js +299 -0
  69. package/lib/map/imports.js +233 -0
  70. package/lib/map/index.js +33 -0
  71. package/lib/map/rank.js +253 -0
  72. package/lib/map/render.js +346 -0
  73. package/lib/map/resolver.js +574 -0
  74. package/lib/pack/budget.js +111 -0
  75. package/lib/pack/code-lexer.js +360 -0
  76. package/lib/pack/compress.js +414 -0
  77. package/lib/pack/index.js +354 -0
  78. package/lib/pack/renderers/common.js +134 -0
  79. package/lib/pack/renderers/index.js +24 -0
  80. package/lib/pack/renderers/json.js +47 -0
  81. package/lib/pack/renderers/markdown.js +54 -0
  82. package/lib/pack/renderers/plain.js +38 -0
  83. package/lib/pack/renderers/xml.js +85 -0
  84. package/lib/pack/select.js +177 -0
  85. package/lib/pack/split.js +182 -0
  86. package/lib/pack/tree.js +35 -0
  87. package/lib/parsers/gitignore-parser.js +113 -107
  88. package/lib/parsers/method-filter-parser.js +50 -33
  89. package/lib/plugins/ExporterPlugin.js +3 -3
  90. package/lib/plugins/LanguagePlugin.js +3 -3
  91. package/lib/plugins/PluginManager.js +7 -13
  92. package/lib/policy/audit.js +294 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +52 -0
  96. package/lib/policy/policy.js +489 -0
  97. package/lib/pr/diff-parser.js +267 -0
  98. package/lib/pr/method-mapper.js +148 -0
  99. package/lib/pr/pr-context.js +829 -0
  100. package/lib/pr/pr-git.js +149 -0
  101. package/lib/pr/pr-renderer.js +644 -0
  102. package/lib/pr/related-tests.js +296 -0
  103. package/lib/rag/DataSourcePlugin.js +5 -5
  104. package/lib/rag/EmbeddingProvider.js +19 -8
  105. package/lib/rag/Indexer.js +14 -5
  106. package/lib/rag/VectorStoreFactory.js +3 -3
  107. package/lib/rag/sources/FileDataSource.js +6 -6
  108. package/lib/rag/stores/BaseVectorStore.js +5 -5
  109. package/lib/rag/stores/ChromaDBStore.js +10 -10
  110. package/lib/rag/stores/LanceDBStore.js +29 -19
  111. package/lib/rag/stores/PineconeStore.js +11 -9
  112. package/lib/search/bm25.js +433 -0
  113. package/lib/search/code-units.js +201 -0
  114. package/lib/search/symbols.js +189 -0
  115. package/lib/ui/dashboard.js +120 -89
  116. package/lib/ui/index.js +1 -6
  117. package/lib/ui/init-wizard-ui.js +550 -0
  118. package/lib/ui/progress-bar.js +94 -88
  119. package/lib/ui/select-input.js +40 -32
  120. package/lib/ui/template-selector.js +99 -0
  121. package/lib/ui/wizard.js +374 -329
  122. package/lib/utils/claude-token-counter.js +151 -0
  123. package/lib/utils/cli-output.js +17 -0
  124. package/lib/utils/clipboard-utils.js +49 -50
  125. package/lib/utils/config-utils.js +81 -66
  126. package/lib/utils/context-versioning.js +532 -0
  127. package/lib/utils/error-handler.js +112 -112
  128. package/lib/utils/file-utils.js +153 -69
  129. package/lib/utils/format-converter.js +356 -283
  130. package/lib/utils/git-utils.js +366 -294
  131. package/lib/utils/llm-cost-estimator.js +281 -0
  132. package/lib/utils/llm-detector.js +242 -78
  133. package/lib/utils/logger.js +243 -237
  134. package/lib/utils/messages.js +355 -0
  135. package/lib/utils/multi-repo-manager.js +364 -0
  136. package/lib/utils/profile-manager.js +380 -0
  137. package/lib/utils/safe-read.js +61 -0
  138. package/lib/utils/secret-redactor.js +61 -0
  139. package/lib/utils/snapshot-manager.js +679 -0
  140. package/lib/utils/template-manager.js +378 -0
  141. package/lib/utils/token-utils.js +201 -170
  142. package/lib/utils/tokenizer-adapter.js +421 -377
  143. package/lib/utils/updater.js +539 -506
  144. package/lib/watch/FileWatcher.js +12 -8
  145. package/lib/watch/IncrementalAnalyzer.js +7 -7
  146. package/lib/why/coupling.js +81 -0
  147. package/lib/why/errors.js +23 -0
  148. package/lib/why/git.js +169 -0
  149. package/lib/why/history.js +108 -0
  150. package/lib/why/index.js +720 -0
  151. package/lib/why/ownership.js +79 -0
  152. package/lib/why/paths.js +207 -0
  153. package/lib/why/references.js +259 -0
  154. package/lib/why/render.js +722 -0
  155. package/lib/why/search.js +358 -0
  156. package/lib/why/target.js +389 -0
  157. package/lib/wizards/config-generator.js +255 -0
  158. package/lib/wizards/project-detector.js +571 -0
  159. package/package.json +44 -10
  160. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  161. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -17,7 +17,7 @@ export class Analyzer {
17
17
  methodLevel: false,
18
18
  parallel: false,
19
19
  maxWorkers: 4,
20
- ...options
20
+ ...options,
21
21
  };
22
22
 
23
23
  this.methodAnalyzer = new MethodAnalyzer();
@@ -30,9 +30,10 @@ export class Analyzer {
30
30
  totalTokens: 0,
31
31
  totalSize: 0,
32
32
  totalMethods: 0,
33
+ totalLines: 0,
33
34
  byLanguage: {},
34
35
  largestFiles: [],
35
- analysisTime: 0
36
+ analysisTime: 0,
36
37
  };
37
38
  }
38
39
 
@@ -49,7 +50,7 @@ export class Analyzer {
49
50
 
50
51
  return {
51
52
  files: results,
52
- stats: this.getStats()
53
+ stats: this.getStats(),
53
54
  };
54
55
  }
55
56
 
@@ -83,19 +84,23 @@ export class Analyzer {
83
84
  name: fileInfo.name,
84
85
  extension: fileInfo.extension,
85
86
  size: fileInfo.size,
87
+ modified: fileInfo.modified,
86
88
  tokens: tokens,
87
89
  lines: content.split('\n').length,
88
- language: this.detectLanguage(fileInfo.extension)
90
+ language: this.detectLanguage(fileInfo.extension),
89
91
  };
90
92
 
91
93
  if (this.options.methodLevel) {
92
94
  const methods = this.methodAnalyzer.extractMethods(content, fileInfo.path);
93
- analysis.methods = methods;
95
+ const sources = this.methodAnalyzer.extractMethodSources(content, methods, fileInfo.path);
96
+ analysis.methods = methods.map((method, i) => ({
97
+ ...method,
98
+ tokens: TokenUtils.calculate(sources[i], fileInfo.path),
99
+ }));
94
100
  analysis.methodCount = methods.length;
95
101
  }
96
102
 
97
103
  return analysis;
98
-
99
104
  } catch (error) {
100
105
  logger.error(`Error analyzing file ${fileInfo.relativePath}: ${error.message}`);
101
106
  return null;
@@ -106,12 +111,13 @@ export class Analyzer {
106
111
  this.stats.totalFiles++;
107
112
  this.stats.totalTokens += analysis.tokens;
108
113
  this.stats.totalSize += analysis.size;
114
+ this.stats.totalLines += analysis.lines || 0;
109
115
 
110
116
  if (!this.stats.byLanguage[analysis.language]) {
111
117
  this.stats.byLanguage[analysis.language] = {
112
118
  files: 0,
113
119
  tokens: 0,
114
- size: 0
120
+ size: 0,
115
121
  };
116
122
  }
117
123
 
@@ -122,7 +128,7 @@ export class Analyzer {
122
128
  this.stats.largestFiles.push({
123
129
  path: analysis.relativePath,
124
130
  tokens: analysis.tokens,
125
- size: analysis.size
131
+ size: analysis.size,
126
132
  });
127
133
 
128
134
  this.stats.largestFiles.sort((a, b) => b.tokens - a.tokens);
@@ -152,7 +158,7 @@ export class Analyzer {
152
158
  '.cpp': 'C++',
153
159
  '.h': 'C/C++',
154
160
  '.hpp': 'C++',
155
- '.scala': 'Scala'
161
+ '.scala': 'Scala',
156
162
  };
157
163
 
158
164
  return languageMap[extension] || 'Other';
@@ -171,7 +177,7 @@ export class Analyzer {
171
177
  .map(([language, stats]) => ({
172
178
  language,
173
179
  ...stats,
174
- percentage: (stats.tokens / this.stats.totalTokens) * 100
180
+ percentage: (stats.tokens / this.stats.totalTokens) * 100,
175
181
  }))
176
182
  .sort((a, b) => b.tokens - a.tokens);
177
183
  }
@@ -24,7 +24,7 @@ export class ContextBuilder {
24
24
  includeTests: true,
25
25
  includeDocumentation: true,
26
26
  priorityStrategy: 'balanced', // balanced, changed-first, core-first
27
- ...options
27
+ ...options,
28
28
  };
29
29
  }
30
30
 
@@ -49,7 +49,7 @@ export class ContextBuilder {
49
49
  metadata: this.buildMetadata(stats),
50
50
  files: this.buildFileList(filteredFiles),
51
51
  methods: this.options.methodLevel ? this.buildMethodList(filteredFiles) : null,
52
- statistics: stats
52
+ statistics: stats,
53
53
  };
54
54
 
55
55
  // Optimize for target LLM if specified
@@ -76,8 +76,8 @@ export class ContextBuilder {
76
76
  configuration: {
77
77
  includeTests: this.options.includeTests,
78
78
  includeDocumentation: this.options.includeDocumentation,
79
- methodLevel: this.options.methodLevel
80
- }
79
+ methodLevel: this.options.methodLevel,
80
+ },
81
81
  };
82
82
  }
83
83
 
@@ -103,7 +103,7 @@ export class ContextBuilder {
103
103
  path: file.relativePath,
104
104
  tokens: file.tokens,
105
105
  size: file.size,
106
- language: file.language
106
+ language: file.language,
107
107
  });
108
108
  }
109
109
 
@@ -120,11 +120,11 @@ export class ContextBuilder {
120
120
 
121
121
  for (const file of files) {
122
122
  if (file.methods && file.methods.length > 0) {
123
- methods[file.relativePath] = file.methods.map(method => ({
123
+ methods[file.relativePath] = file.methods.map((method) => ({
124
124
  name: method.name,
125
125
  line: method.line,
126
126
  tokens: method.tokens || 0,
127
- type: method.type || 'function'
127
+ type: method.type || 'function',
128
128
  }));
129
129
  }
130
130
  }
@@ -149,26 +149,15 @@ export class ContextBuilder {
149
149
  return files;
150
150
  }
151
151
 
152
- // Apply priority strategy
153
- const prioritized = this.prioritizeFiles(files);
154
-
155
- // Select files until token budget reached
152
+ // Pack by priority. A file that does not fit is skipped whole (nothing truncates file
153
+ // content), and smaller files after it can still use the remaining budget.
156
154
  const selected = [];
157
155
  let runningTotal = 0;
158
156
 
159
- for (const file of prioritized) {
157
+ for (const file of this.prioritizeFiles(files)) {
160
158
  if (runningTotal + file.tokens <= targetTokens) {
161
159
  selected.push(file);
162
160
  runningTotal += file.tokens;
163
- } else {
164
- // Check if we can include part of the file
165
- const remainingBudget = targetTokens - runningTotal;
166
- if (remainingBudget > 1000) { // Minimum 1k tokens
167
- logger.info(`Partial file inclusion: ${file.relativePath} (${remainingBudget} tokens)`);
168
- // Note: Actual implementation would truncate file content
169
- selected.push(file);
170
- }
171
- break;
172
161
  }
173
162
  }
174
163
 
@@ -183,11 +172,13 @@ export class ContextBuilder {
183
172
  */
184
173
  prioritizeFiles(files) {
185
174
  const strategy = this.options.priorityStrategy;
175
+ // Sort a copy: the caller's analysis result keeps its order
176
+ const sorted = [...files];
186
177
 
187
178
  switch (strategy) {
188
179
  case 'changed-first':
189
- // Prioritize recently changed files (requires git metadata)
190
- return files.sort((a, b) => {
180
+ // Most recently modified files first (mtime from the scanner)
181
+ return sorted.sort((a, b) => {
191
182
  const aModified = a.modified || 0;
192
183
  const bModified = b.modified || 0;
193
184
  return bModified - aModified;
@@ -195,7 +186,7 @@ export class ContextBuilder {
195
186
 
196
187
  case 'core-first':
197
188
  // Prioritize core application files (heuristic-based)
198
- return files.sort((a, b) => {
189
+ return sorted.sort((a, b) => {
199
190
  const aCoreScore = this.calculateCoreScore(a);
200
191
  const bCoreScore = this.calculateCoreScore(b);
201
192
  return bCoreScore - aCoreScore;
@@ -204,7 +195,7 @@ export class ContextBuilder {
204
195
  case 'balanced':
205
196
  default:
206
197
  // Balance between size and importance
207
- return files.sort((a, b) => {
198
+ return sorted.sort((a, b) => {
208
199
  const aScore = this.calculateBalancedScore(a);
209
200
  const bScore = this.calculateBalancedScore(b);
210
201
  return bScore - aScore;
@@ -277,7 +268,7 @@ export class ContextBuilder {
277
268
  name: profile.name,
278
269
  vendor: profile.vendor,
279
270
  contextWindow: profile.contextWindow,
280
- recommendedInput: profile.maxRecommendedInput
271
+ recommendedInput: profile.maxRecommendedInput,
281
272
  };
282
273
 
283
274
  return optimized;
@@ -292,7 +283,7 @@ export class ContextBuilder {
292
283
  const compact = {};
293
284
 
294
285
  for (const [dir, files] of Object.entries(context.files)) {
295
- const fileNames = files.map(f => f.name);
286
+ const fileNames = files.map((f) => f.name);
296
287
  compact[dir] = fileNames;
297
288
  }
298
289
 
@@ -306,7 +297,7 @@ export class ContextBuilder {
306
297
  */
307
298
  getSummary(context) {
308
299
  const meta = context.metadata;
309
- const stats = context.statistics;
300
+ const _stats = context.statistics;
310
301
 
311
302
  let summary = `Context Summary:\n`;
312
303
  summary += ` Files: ${meta.totalFiles}\n`;
@@ -24,7 +24,7 @@ export class Reporter {
24
24
  outputPath: null,
25
25
  clipboard: false,
26
26
  verbose: true,
27
- ...options
27
+ ...options,
28
28
  };
29
29
 
30
30
  this.formatRegistry = new FormatRegistry();
@@ -79,7 +79,7 @@ export class Reporter {
79
79
  .slice(0, 5);
80
80
 
81
81
  for (const [lang, langStats] of languages) {
82
- const percentage = (langStats.tokens / stats.totalTokens * 100).toFixed(1);
82
+ const percentage = ((langStats.tokens / stats.totalTokens) * 100).toFixed(1);
83
83
  const bar = '▓'.repeat(Math.round(percentage / 2));
84
84
  console.log(` ${lang.padEnd(15)} ${percentage.padStart(5)}% ${bar}`);
85
85
  }
@@ -91,8 +91,10 @@ export class Reporter {
91
91
  console.log('🏆 LARGEST FILES (by tokens):');
92
92
  for (let i = 0; i < Math.min(5, stats.largestFiles.length); i++) {
93
93
  const file = stats.largestFiles[i];
94
- const percentage = (file.tokens / stats.totalTokens * 100).toFixed(1);
95
- console.log(` ${(i + 1)}. ${file.path.padEnd(40)} ${file.tokens.toLocaleString().padStart(8)} tokens (${percentage}%)`);
94
+ const percentage = ((file.tokens / stats.totalTokens) * 100).toFixed(1);
95
+ console.log(
96
+ ` ${i + 1}. ${file.path.padEnd(40)} ${file.tokens.toLocaleString().padStart(8)} tokens (${percentage}%)`
97
+ );
96
98
  }
97
99
  console.log();
98
100
  }
@@ -114,7 +116,7 @@ export class Reporter {
114
116
  console.log(` Analysis Time: ${stats.analysisTime}ms`);
115
117
  if (stats.cacheHits !== undefined) {
116
118
  const totalCache = stats.cacheHits + stats.cacheMisses;
117
- const hitRate = totalCache > 0 ? (stats.cacheHits / totalCache * 100).toFixed(1) : 0;
119
+ const hitRate = totalCache > 0 ? ((stats.cacheHits / totalCache) * 100).toFixed(1) : 0;
118
120
  console.log(` Cache Hit Rate: ${hitRate}% (${stats.cacheHits}/${totalCache})`);
119
121
  }
120
122
  console.log();
@@ -129,7 +131,10 @@ export class Reporter {
129
131
  async exportToFile(context, outputPath) {
130
132
  try {
131
133
  const format = this.detectFormatFromPath(outputPath);
132
- const content = this.formatRegistry.encode(format, context);
134
+ const content =
135
+ format === 'summary'
136
+ ? this.generateSummary(context)
137
+ : this.formatRegistry.encode(format, context);
133
138
 
134
139
  fs.writeFileSync(outputPath, content, 'utf-8');
135
140
 
@@ -139,7 +144,6 @@ export class Reporter {
139
144
  console.log(`💾 Report saved: ${outputPath}`);
140
145
  console.log(` Format: ${format.toUpperCase()}`);
141
146
  console.log(` Size: ${size} KB`);
142
-
143
147
  } catch (error) {
144
148
  logger.error(`Failed to export to file: ${error.message}`);
145
149
  throw error;
@@ -169,7 +173,6 @@ export class Reporter {
169
173
  const fallbackPath = path.join(process.cwd(), `context-${Date.now()}.${format}`);
170
174
  await this.exportToFile(context, fallbackPath);
171
175
  }
172
-
173
176
  } catch (error) {
174
177
  logger.error(`Failed to export to clipboard: ${error.message}`);
175
178
  throw error;
@@ -192,7 +195,8 @@ export class Reporter {
192
195
  '.md': 'markdown',
193
196
  '.csv': 'csv',
194
197
  '.xml': 'xml',
195
- '.txt': 'gitingest'
198
+ // A GitIngest digest needs file contents, which a Context does not carry
199
+ '.txt': 'summary',
196
200
  };
197
201
 
198
202
  return formatMap[ext] || this.options.format || 'toon';
@@ -216,7 +220,7 @@ export class Reporter {
216
220
  */
217
221
  generateSummary(context) {
218
222
  const meta = context.metadata;
219
- const stats = context.statistics;
223
+ const _stats = context.statistics;
220
224
 
221
225
  let summary = 'Ctxman Analysis Summary\n';
222
226
  summary += '='.repeat(50) + '\n\n';
@@ -250,10 +254,7 @@ export class Reporter {
250
254
  const results = [];
251
255
 
252
256
  for (const format of formats) {
253
- const outputPath = path.join(
254
- process.cwd(),
255
- `context-${Date.now()}.${format}`
256
- );
257
+ const outputPath = path.join(process.cwd(), `context-${Date.now()}.${format}`);
257
258
 
258
259
  try {
259
260
  await this.exportToFile(context, outputPath);
@@ -8,17 +8,29 @@ import path from 'path';
8
8
  import GitIgnoreParser from '../parsers/gitignore-parser.js';
9
9
  import FileUtils from '../utils/file-utils.js';
10
10
  import { getLogger } from '../utils/logger.js';
11
+ import { loadPolicy } from '../policy/policy.js';
12
+ import { loadIgnoreFileRules, resolveIgnoreFileNames } from '../policy/ignore-files.js';
11
13
 
12
14
  const logger = getLogger('Scanner');
13
15
 
14
16
  export class Scanner {
17
+ /**
18
+ * @param {string} rootPath - Project root
19
+ * @param {object} [options]
20
+ * @param {import('../policy/policy.js').Policy|false|null} [options.policy] - Export policy
21
+ * whose deny rules drop files; loaded from the project (.ctxman/policy.json and
22
+ * CTXMAN_POLICY) when not given, and turned off with false
23
+ * @param {string|string[]} [options.respectIgnoreFiles] - AI-tool ignore files to honour
24
+ * ("all", ".cursorignore,.aiexclude" or an array), in addition to the policy's list
25
+ * @param {object} [options.env=process.env] - Environment for loading the policy
26
+ */
15
27
  constructor(rootPath, options = {}) {
16
28
  this.rootPath = rootPath;
17
29
  this.options = {
18
30
  respectGitignore: true,
19
31
  followSymlinks: false,
20
32
  maxDepth: Infinity,
21
- ...options
33
+ ...options,
22
34
  };
23
35
 
24
36
  // Initialize GitIgnoreParser with correct file paths
@@ -31,14 +43,23 @@ export class Scanner {
31
43
  filesScanned: 0,
32
44
  directoriesTraversed: 0,
33
45
  filesIgnored: 0,
34
- errors: 0
46
+ filesDenied: 0,
47
+ errors: 0,
35
48
  };
49
+ // Loaded on first use, so constructing a Scanner reads no policy files
50
+ this._policy = undefined;
51
+ this._ignoreFileRules = undefined;
36
52
  }
37
53
 
38
54
  scan() {
39
55
  logger.info(`Starting scan: ${this.rootPath}`);
40
56
  const startTime = Date.now();
41
57
 
58
+ // Load here, outside scanDirectory's error handling: an invalid policy must fail the
59
+ // scan instead of yielding an empty file list
60
+ this.getPolicy();
61
+ this.getIgnoreFileRules();
62
+
42
63
  const files = this.scanDirectory(this.rootPath, 0);
43
64
 
44
65
  const elapsed = Date.now() - startTime;
@@ -62,25 +83,32 @@ export class Scanner {
62
83
  const fullPath = path.join(dirPath, entry.name);
63
84
  const relativePath = path.relative(this.rootPath, fullPath);
64
85
 
65
- if (this.shouldIgnore(relativePath)) {
86
+ if (this.shouldIgnore(relativePath, fullPath)) {
66
87
  this.stats.filesIgnored++;
67
88
  continue;
68
89
  }
69
90
 
70
91
  if (entry.isDirectory()) {
92
+ if (FileUtils.isSkippedDirectory(entry.name)) continue;
71
93
  const subFiles = this.scanDirectory(fullPath, depth + 1);
72
94
  files.push(...subFiles);
73
95
  } else if (entry.isFile()) {
74
96
  // Check if it's a text file (not binary)
75
97
  try {
76
- if (FileUtils.isText(fullPath)) {
77
- const fileInfo = this.getFileInfo(fullPath, relativePath);
78
- files.push(fileInfo);
79
- this.stats.filesScanned++;
98
+ if (this.getPolicy()?.isAuditLog(relativePath)) {
99
+ this.stats.filesIgnored++;
100
+ } else if (FileUtils.isText(fullPath)) {
101
+ if (this.getPolicy()?.isDenied(relativePath)) {
102
+ this.stats.filesDenied++;
103
+ } else {
104
+ const fileInfo = this.getFileInfo(fullPath, relativePath);
105
+ files.push(fileInfo);
106
+ this.stats.filesScanned++;
107
+ }
80
108
  } else {
81
109
  this.stats.filesIgnored++;
82
110
  }
83
- } catch (error) {
111
+ } catch (_error) {
84
112
  // Skip files that can't be processed
85
113
  this.stats.filesIgnored++;
86
114
  }
@@ -104,12 +132,52 @@ export class Scanner {
104
132
  extension: path.extname(fullPath),
105
133
  size: stats.size,
106
134
  modified: stats.mtime,
107
- created: stats.birthtime
135
+ created: stats.birthtime,
108
136
  };
109
137
  }
110
138
 
111
- shouldIgnore(relativePath) {
112
- return this.gitIgnore.isIgnored(null, relativePath);
139
+ /**
140
+ * Whether the export policy denies a project-relative path
141
+ * @param {string} relativePath
142
+ * @returns {boolean}
143
+ */
144
+ isDenied(relativePath) {
145
+ return Boolean(this.getPolicy()?.isDenied(relativePath));
146
+ }
147
+
148
+ shouldIgnore(relativePath, fullPath = path.join(this.rootPath, relativePath)) {
149
+ if (this.gitIgnore.isIgnored(fullPath, relativePath)) return true;
150
+ return Boolean(this.getIgnoreFileRules()?.isIgnored(relativePath));
151
+ }
152
+
153
+ /**
154
+ * Effective export policy, or null when none applies
155
+ * @returns {import('../policy/policy.js').Policy|null}
156
+ */
157
+ getPolicy() {
158
+ if (this._policy === undefined) {
159
+ const { policy, env } = this.options;
160
+ const loaded = policy === undefined ? loadPolicy(this.rootPath, { env }) : policy;
161
+ this._policy = loaded?.active ? loaded : null;
162
+ }
163
+ return this._policy;
164
+ }
165
+
166
+ /**
167
+ * AI-tool ignore files from options.respectIgnoreFiles and the policy's ignoreFiles
168
+ * @returns {import('../policy/ignore-files.js').IgnoreFileRules|null}
169
+ */
170
+ getIgnoreFileRules() {
171
+ if (this._ignoreFileRules === undefined) {
172
+ const names = [
173
+ ...new Set([
174
+ ...resolveIgnoreFileNames(this.options.respectIgnoreFiles),
175
+ ...(this.getPolicy()?.ignoreFiles ?? []),
176
+ ]),
177
+ ];
178
+ this._ignoreFileRules = names.length > 0 ? loadIgnoreFileRules(this.rootPath, names) : null;
179
+ }
180
+ return this._ignoreFileRules;
113
181
  }
114
182
 
115
183
  getStats() {
@@ -121,7 +189,8 @@ export class Scanner {
121
189
  filesScanned: 0,
122
190
  directoriesTraversed: 0,
123
191
  filesIgnored: 0,
124
- errors: 0
192
+ filesDenied: 0,
193
+ errors: 0,
125
194
  };
126
195
  }
127
196
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Token count for a target model
3
+ * Counts a project (or a piped text) with the target model's tokenizer where one is
4
+ * available offline (tiktoken for OpenAI models), and, on request, exactly through
5
+ * Anthropic's count_tokens API for Claude models.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import Scanner from '../core/Scanner.js';
10
+ import TokenUtils from '../utils/token-utils.js';
11
+ import { LLMDetector } from '../utils/llm-detector.js';
12
+ import { ClaudeTokenCounter } from '../utils/claude-token-counter.js';
13
+ import { redactSecrets } from '../utils/secret-redactor.js';
14
+
15
+ /**
16
+ * Read the documents to count
17
+ * @param {string} root - Project root (ignored when `text` is given)
18
+ * @param {string} [text] - A single piped text instead of the project files
19
+ * @returns {{path: string, content: string}[]}
20
+ */
21
+ function readDocuments(root, text) {
22
+ if (text !== undefined) return [{ path: '<stdin>', content: text }];
23
+ return new Scanner(root).scan().flatMap((file) => {
24
+ try {
25
+ return [{ path: file.relativePath, content: fs.readFileSync(file.path, 'utf8') }];
26
+ } catch (_error) {
27
+ return [];
28
+ }
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Count tokens for a target model
34
+ * @param {string} root - Project root
35
+ * @param {object} [options]
36
+ * @param {string} [options.model] - Target model id or alias (default: DEFAULT_TARGET_MODEL)
37
+ * @param {string} [options.text] - Count this text instead of the project
38
+ * @param {boolean} [options.api=false] - Count exactly with the Anthropic API (Claude only)
39
+ * @param {object} [options.client] - Anthropic client stand-in (tests)
40
+ * @param {(done: number, total: number) => void} [options.onProgress]
41
+ * @returns {Promise<object>} Count result
42
+ */
43
+ export async function countForModel(root, options = {}) {
44
+ const requested = options.model || LLMDetector.getDefaultModelId();
45
+ const profile = LLMDetector.getProfile(requested);
46
+ const modelId = profile.id || requested;
47
+ const encoding = TokenUtils.encodingFor(modelId);
48
+ const documents = readDocuments(root, options.text);
49
+
50
+ const files = documents
51
+ .map((doc) => ({
52
+ path: doc.path,
53
+ tokens: TokenUtils.calculate(doc.content, doc.path, encoding),
54
+ }))
55
+ .sort((a, b) => b.tokens - a.tokens);
56
+ const localTokens = files.reduce((sum, file) => sum + file.tokens, 0);
57
+
58
+ const result = {
59
+ model: modelId,
60
+ modelName: profile.name,
61
+ vendor: profile.vendor,
62
+ files: files.length,
63
+ encoding: TokenUtils.isExact() ? encoding : 'estimate',
64
+ localTokens,
65
+ tokens: localTokens,
66
+ method: 'local',
67
+ // tiktoken matches OpenAI models whose encoding is published; others are approximations
68
+ exact: TokenUtils.isExact() && profile.tokenizer?.exact === true,
69
+ contextWindow: profile.contextWindow,
70
+ largestFiles: files,
71
+ };
72
+
73
+ if (options.api) {
74
+ if (profile.vendor !== 'Anthropic') {
75
+ throw new Error(
76
+ `--api counts with Anthropic's API, so it needs a Claude model (got ${requested})`
77
+ );
78
+ }
79
+ const counter = await ClaudeTokenCounter.create({ model: modelId, client: options.client });
80
+ // Secrets never leave the machine, even for counting
81
+ const redacted = documents.map((doc) => ({
82
+ path: doc.path,
83
+ content: redactSecrets(doc.content).text,
84
+ }));
85
+ const { total, requests } = await counter.countDocuments(redacted, {
86
+ onProgress: options.onProgress,
87
+ });
88
+ result.tokens = total;
89
+ result.method = 'anthropic-api';
90
+ result.exact = true;
91
+ result.requests = requests;
92
+ result.ratio = localTokens > 0 ? total / localTokens : null;
93
+ }
94
+
95
+ result.fits = result.tokens <= result.contextWindow;
96
+ result.percentOfWindow = (result.tokens / result.contextWindow) * 100;
97
+ return result;
98
+ }
99
+
100
+ export default countForModel;