ctxman 3.0.0 → 3.1.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.
Files changed (224) 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 +381 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -67
  18. package/bin/cm-audit.js +121 -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 +687 -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 +772 -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 +116 -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 +312 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +53 -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/ask.js +122 -0
  108. package/lib/rag/sources/FileDataSource.js +12 -9
  109. package/lib/rag/stores/BaseVectorStore.js +5 -5
  110. package/lib/rag/stores/ChromaDBStore.js +10 -10
  111. package/lib/rag/stores/LanceDBStore.js +29 -19
  112. package/lib/rag/stores/PineconeStore.js +11 -9
  113. package/lib/search/bm25.js +433 -0
  114. package/lib/search/code-units.js +201 -0
  115. package/lib/search/symbols.js +189 -0
  116. package/lib/ui/dashboard.js +120 -89
  117. package/lib/ui/index.js +1 -6
  118. package/lib/ui/init-wizard-ui.js +550 -0
  119. package/lib/ui/progress-bar.js +94 -88
  120. package/lib/ui/select-input.js +40 -32
  121. package/lib/ui/template-selector.js +99 -0
  122. package/lib/ui/wizard.js +374 -329
  123. package/lib/utils/claude-token-counter.js +151 -0
  124. package/lib/utils/cli-output.js +17 -0
  125. package/lib/utils/clipboard-utils.js +49 -50
  126. package/lib/utils/config-utils.js +81 -66
  127. package/lib/utils/context-versioning.js +532 -0
  128. package/lib/utils/error-handler.js +112 -112
  129. package/lib/utils/file-utils.js +153 -69
  130. package/lib/utils/format-converter.js +356 -283
  131. package/lib/utils/git-utils.js +366 -294
  132. package/lib/utils/llm-cost-estimator.js +281 -0
  133. package/lib/utils/llm-detector.js +242 -78
  134. package/lib/utils/logger.js +246 -237
  135. package/lib/utils/messages.js +355 -0
  136. package/lib/utils/multi-repo-manager.js +364 -0
  137. package/lib/utils/profile-manager.js +380 -0
  138. package/lib/utils/safe-read.js +61 -0
  139. package/lib/utils/secret-redactor.js +61 -0
  140. package/lib/utils/snapshot-manager.js +679 -0
  141. package/lib/utils/template-manager.js +378 -0
  142. package/lib/utils/token-utils.js +201 -170
  143. package/lib/utils/tokenizer-adapter.js +421 -377
  144. package/lib/utils/updater.js +539 -506
  145. package/lib/watch/FileWatcher.js +12 -8
  146. package/lib/watch/IncrementalAnalyzer.js +7 -7
  147. package/lib/why/coupling.js +81 -0
  148. package/lib/why/errors.js +23 -0
  149. package/lib/why/git.js +169 -0
  150. package/lib/why/history.js +108 -0
  151. package/lib/why/index.js +720 -0
  152. package/lib/why/ownership.js +79 -0
  153. package/lib/why/paths.js +207 -0
  154. package/lib/why/references.js +259 -0
  155. package/lib/why/render.js +722 -0
  156. package/lib/why/search.js +358 -0
  157. package/lib/why/target.js +389 -0
  158. package/lib/wizards/config-generator.js +257 -0
  159. package/lib/wizards/project-detector.js +572 -0
  160. package/package.json +44 -10
  161. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  224. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -0,0 +1,679 @@
1
+ /**
2
+ * Snapshot Manager - Context Snapshot & Diff (FEAT-003)
3
+ * Track token growth over time with snapshots
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import crypto from 'crypto';
9
+
10
+ const SNAPSHOT_DIR = '.ctxman/snapshots';
11
+
12
+ /**
13
+ * SnapshotStore - Handles snapshot storage operations
14
+ */
15
+ export class SnapshotStore {
16
+ constructor(projectRoot) {
17
+ this.projectRoot = projectRoot;
18
+ this.snapshotDir = path.join(projectRoot, SNAPSHOT_DIR);
19
+ }
20
+
21
+ /**
22
+ * Initialize snapshot directory
23
+ */
24
+ async init() {
25
+ await fs.promises.mkdir(this.snapshotDir, { recursive: true });
26
+ }
27
+
28
+ /**
29
+ * Generate a unique snapshot ID
30
+ * @returns {string} Snapshot ID in format snap-YYYYMMDD-HHMMSS-RAND
31
+ */
32
+ generateId() {
33
+ const now = new Date();
34
+ const year = now.getFullYear();
35
+ const month = String(now.getMonth() + 1).padStart(2, '0');
36
+ const day = String(now.getDate()).padStart(2, '0');
37
+ const hours = String(now.getHours()).padStart(2, '0');
38
+ const minutes = String(now.getMinutes()).padStart(2, '0');
39
+ const seconds = String(now.getSeconds()).padStart(2, '0');
40
+ const random = crypto.randomBytes(3).toString('hex');
41
+ return `snap-${year}${month}${day}-${hours}${minutes}${seconds}-${random}`;
42
+ }
43
+
44
+ /**
45
+ * Save a snapshot
46
+ * @param {object} analysis - Analysis results from TokenCalculator
47
+ * @param {string} message - Optional message for the snapshot
48
+ * @returns {object} Saved snapshot info
49
+ */
50
+ async save(analysis, message = '') {
51
+ await this.init();
52
+
53
+ const id = this.generateId();
54
+ const timestamp = new Date().toISOString();
55
+ const filename = `${id}.json`;
56
+ const filepath = path.join(this.snapshotDir, filename);
57
+
58
+ // Build snapshot object
59
+ const snapshot = {
60
+ id,
61
+ timestamp,
62
+ version: '1.0',
63
+ message,
64
+ summary: {
65
+ totalFiles: analysis.totalFiles || 0,
66
+ totalTokens: analysis.totalTokens || 0,
67
+ totalBytes: analysis.totalBytes || 0,
68
+ totalLines: analysis.totalLines || 0,
69
+ },
70
+ byExtension: analysis.byExtension || {},
71
+ byDirectory: analysis.byDirectory || {},
72
+ largestFiles: (analysis.largestFiles || []).map((f) => ({
73
+ path: f.relativePath || f.path,
74
+ tokens: f.tokens,
75
+ lines: f.lines,
76
+ extension: f.extension,
77
+ })),
78
+ files: (analysis.largestFiles || []).map((f) => ({
79
+ path: f.relativePath || f.path,
80
+ tokens: f.tokens,
81
+ lines: f.lines,
82
+ extension: f.extension,
83
+ hash: this.hashFile(f),
84
+ })),
85
+ };
86
+
87
+ await fs.promises.writeFile(filepath, JSON.stringify(snapshot, null, 2));
88
+
89
+ return { id, filename, snapshot };
90
+ }
91
+
92
+ /**
93
+ * Generate a hash for a file entry
94
+ * @param {object} file - File info
95
+ * @returns {string} Hash string
96
+ */
97
+ hashFile(file) {
98
+ const content = `${file.path}:${file.tokens}:${file.lines}`;
99
+ return crypto.createHash('md5').update(content).digest('hex').substring(0, 8);
100
+ }
101
+
102
+ /**
103
+ * List all snapshots
104
+ * @param {object} options - List options
105
+ * @returns {string[]} Array of snapshot filenames
106
+ */
107
+ async list(options = {}) {
108
+ try {
109
+ await this.init();
110
+ const files = await fs.promises.readdir(this.snapshotDir);
111
+ const snapshots = files
112
+ .filter((f) => f.startsWith('snap-') && f.endsWith('.json'))
113
+ .sort()
114
+ .reverse();
115
+
116
+ if (options.limit) {
117
+ return snapshots.slice(0, options.limit);
118
+ }
119
+ return snapshots;
120
+ } catch (_error) {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Load a snapshot by filename
127
+ * @param {string} filename - Snapshot filename
128
+ * @returns {object} Snapshot data
129
+ */
130
+ async load(filename) {
131
+ const filepath = path.join(this.snapshotDir, filename);
132
+ const content = await fs.promises.readFile(filepath, 'utf-8');
133
+ return JSON.parse(content);
134
+ }
135
+
136
+ /**
137
+ * Load a snapshot by ID
138
+ * @param {string} id - Snapshot ID (e.g., snap-20250115-143000)
139
+ * @returns {object|null} Snapshot data or null if not found
140
+ */
141
+ async loadById(id) {
142
+ const filename = id.endsWith('.json') ? id : `${id}.json`;
143
+ try {
144
+ return await this.load(filename);
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Delete a snapshot
152
+ * @param {string} id - Snapshot ID
153
+ * @returns {boolean} True if deleted
154
+ */
155
+ async delete(id) {
156
+ const filename = id.endsWith('.json') ? id : `${id}.json`;
157
+ const filepath = path.join(this.snapshotDir, filename);
158
+ try {
159
+ await fs.promises.unlink(filepath);
160
+ return true;
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Get snapshot count
168
+ * @returns {number} Number of snapshots
169
+ */
170
+ async count() {
171
+ const snapshots = await this.list();
172
+ return snapshots.length;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * SnapshotDiff - Compare snapshots and analyze trends
178
+ */
179
+ export class SnapshotDiff {
180
+ /**
181
+ * Compare two snapshots
182
+ * @param {object} oldSnapshot - Earlier snapshot
183
+ * @param {object} newSnapshot - Later snapshot
184
+ * @returns {object} Comparison result
185
+ */
186
+ compare(oldSnapshot, newSnapshot) {
187
+ const oldFiles = new Map((oldSnapshot.files || []).map((f) => [f.path, f]));
188
+ const newFiles = new Map((newSnapshot.files || []).map((f) => [f.path, f]));
189
+
190
+ const added = [];
191
+ const removed = [];
192
+ const modified = [];
193
+ const unchanged = [];
194
+
195
+ // Find added and modified files
196
+ for (const [filePath, file] of newFiles) {
197
+ if (!oldFiles.has(filePath)) {
198
+ added.push(file);
199
+ } else {
200
+ const oldFile = oldFiles.get(filePath);
201
+ if (oldFile.tokens !== file.tokens || oldFile.lines !== file.lines) {
202
+ modified.push({
203
+ path: filePath,
204
+ oldTokens: oldFile.tokens,
205
+ newTokens: file.tokens,
206
+ tokenDiff: file.tokens - oldFile.tokens,
207
+ oldLines: oldFile.lines,
208
+ newLines: file.lines,
209
+ lineDiff: file.lines - oldFile.lines,
210
+ });
211
+ } else {
212
+ unchanged.push(file);
213
+ }
214
+ }
215
+ }
216
+
217
+ // Find removed files
218
+ for (const [filePath, file] of oldFiles) {
219
+ if (!newFiles.has(filePath)) {
220
+ removed.push(file);
221
+ }
222
+ }
223
+
224
+ // Calculate total token diff
225
+ const oldTotal = oldSnapshot.summary?.totalTokens || 0;
226
+ const newTotal = newSnapshot.summary?.totalTokens || 0;
227
+ const totalTokenDiff = newTotal - oldTotal;
228
+ const percentChange = oldTotal > 0 ? (totalTokenDiff / oldTotal) * 100 : 0;
229
+
230
+ // Calculate extension changes
231
+ const extensionChanges = this.calculateExtensionChanges(
232
+ oldSnapshot.byExtension || {},
233
+ newSnapshot.byExtension || {}
234
+ );
235
+
236
+ return {
237
+ oldSnapshot: {
238
+ id: oldSnapshot.id,
239
+ timestamp: oldSnapshot.timestamp,
240
+ summary: oldSnapshot.summary,
241
+ },
242
+ newSnapshot: {
243
+ id: newSnapshot.id,
244
+ timestamp: newSnapshot.timestamp,
245
+ summary: newSnapshot.summary,
246
+ },
247
+ added,
248
+ removed,
249
+ modified,
250
+ unchanged,
251
+ totalTokenDiff,
252
+ percentChange,
253
+ extensionChanges,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Calculate changes by extension
259
+ * @param {object} oldExtensions - Old extension stats
260
+ * @param {object} newExtensions - New extension stats
261
+ * @returns {object[]} Extension changes
262
+ */
263
+ calculateExtensionChanges(oldExtensions, newExtensions) {
264
+ const changes = [];
265
+ const allExtensions = new Set([...Object.keys(oldExtensions), ...Object.keys(newExtensions)]);
266
+
267
+ for (const ext of allExtensions) {
268
+ const oldStats = oldExtensions[ext] || { tokens: 0, count: 0 };
269
+ const newStats = newExtensions[ext] || { tokens: 0, count: 0 };
270
+ const tokenDiff = newStats.tokens - oldStats.tokens;
271
+ const percentChange = oldStats.tokens > 0 ? (tokenDiff / oldStats.tokens) * 100 : 0;
272
+
273
+ changes.push({
274
+ extension: ext,
275
+ oldTokens: oldStats.tokens,
276
+ newTokens: newStats.tokens,
277
+ tokenDiff,
278
+ percentChange,
279
+ oldCount: oldStats.count,
280
+ newCount: newStats.count,
281
+ });
282
+ }
283
+
284
+ return changes.sort((a, b) => Math.abs(b.tokenDiff) - Math.abs(a.tokenDiff));
285
+ }
286
+
287
+ /**
288
+ * Analyze trend across multiple snapshots
289
+ * @param {object[]} snapshots - Array of snapshots (oldest to newest)
290
+ * @returns {object|null} Trend analysis
291
+ */
292
+ analyzeTrend(snapshots) {
293
+ if (!snapshots || snapshots.length < 2) return null;
294
+
295
+ // Sort by timestamp (oldest first)
296
+ const sorted = [...snapshots].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
297
+
298
+ const changes = [];
299
+ for (let i = 1; i < sorted.length; i++) {
300
+ const diff = this.compare(sorted[i - 1], sorted[i]);
301
+ changes.push({
302
+ date: new Date(sorted[i].timestamp),
303
+ snapshotId: sorted[i].id,
304
+ tokenDiff: diff.totalTokenDiff,
305
+ percentChange: diff.percentChange,
306
+ totalTokens: sorted[i].summary.totalTokens,
307
+ });
308
+ }
309
+
310
+ // Calculate average daily growth
311
+ const firstDate = new Date(sorted[0].timestamp);
312
+ const lastDate = new Date(sorted[sorted.length - 1].timestamp);
313
+ const totalDays = Math.max(1, (lastDate - firstDate) / (1000 * 60 * 60 * 24));
314
+ const totalGrowth = changes.reduce((sum, c) => sum + c.tokenDiff, 0);
315
+ const dailyGrowth = totalGrowth / totalDays;
316
+
317
+ return {
318
+ snapshotCount: snapshots.length,
319
+ timeSpan: {
320
+ start: firstDate,
321
+ end: lastDate,
322
+ days: Math.round(totalDays),
323
+ },
324
+ changes,
325
+ averageDailyGrowth: Math.round(dailyGrowth),
326
+ projectedMonthlyGrowth: Math.round(dailyGrowth * 30),
327
+ projectedSixMonthGrowth: Math.round(dailyGrowth * 180),
328
+ totalGrowth,
329
+ growthRate:
330
+ sorted[0].summary.totalTokens > 0
331
+ ? ((sorted[sorted.length - 1].summary.totalTokens - sorted[0].summary.totalTokens) /
332
+ sorted[0].summary.totalTokens) *
333
+ 100
334
+ : 0,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Generate ASCII trend chart
340
+ * @param {object[]} changes - Changes array from trend analysis
341
+ * @param {number} width - Chart width
342
+ * @returns {string} ASCII chart
343
+ */
344
+ generateTrendChart(changes, width = 50) {
345
+ if (!changes || changes.length === 0) return '';
346
+
347
+ const tokens = changes.map((c) => c.totalTokens);
348
+ const maxToken = Math.max(...tokens);
349
+ const minToken = Math.min(...tokens);
350
+ const range = maxToken - minToken || 1;
351
+
352
+ let chart = '\n📈 Token Growth Trend\n';
353
+ chart += '─'.repeat(width + 20) + '\n';
354
+
355
+ for (let i = 0; i < changes.length; i++) {
356
+ const change = changes[i];
357
+ const barLength = Math.round(((change.totalTokens - minToken) / range) * width);
358
+ const bar = '█'.repeat(Math.max(1, barLength));
359
+ const date = change.date.toISOString().split('T')[0];
360
+ const diff = change.tokenDiff >= 0 ? `+${change.tokenDiff}` : change.tokenDiff;
361
+
362
+ chart += `${date} │${bar} ${change.totalTokens.toLocaleString()} (${diff})\n`;
363
+ }
364
+
365
+ chart += '─'.repeat(width + 20) + '\n';
366
+ return chart;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * SnapshotManager - Main class for snapshot operations
372
+ */
373
+ export default class SnapshotManager {
374
+ constructor(projectRoot) {
375
+ this.projectRoot = projectRoot;
376
+ this.store = new SnapshotStore(projectRoot);
377
+ this.diff = new SnapshotDiff();
378
+ }
379
+
380
+ /**
381
+ * Create a new snapshot
382
+ * @param {object} analysis - Analysis results
383
+ * @param {string} message - Optional message
384
+ * @returns {object} Created snapshot info
385
+ */
386
+ async createSnapshot(analysis, message = '') {
387
+ const { id, snapshot } = await this.store.save(analysis, message);
388
+ return { id, snapshot };
389
+ }
390
+
391
+ /**
392
+ * List all snapshots with formatted output
393
+ * @returns {string} Formatted snapshot list
394
+ */
395
+ async listSnapshots() {
396
+ const snapshots = await this.store.list();
397
+
398
+ if (snapshots.length === 0) {
399
+ return '\n📷 No snapshots found.\n\n Create one with: ctxman --snapshot "message"\n\n';
400
+ }
401
+
402
+ let output = '\n📷 Saved Snapshots\n';
403
+ output += '═'.repeat(60) + '\n\n';
404
+
405
+ for (const filename of snapshots) {
406
+ try {
407
+ const snapshot = await this.store.load(filename);
408
+ const date = new Date(snapshot.timestamp);
409
+ const dateStr = date.toISOString().split('T')[0];
410
+ const timeStr = date.toTimeString().split(' ')[0];
411
+ const tokens = (snapshot.summary?.totalTokens || 0).toLocaleString();
412
+ const files = snapshot.summary?.totalFiles || 0;
413
+ const message = snapshot.message || '';
414
+
415
+ output += `📸 ${snapshot.id}\n`;
416
+ output += ` Date: ${dateStr} ${timeStr}\n`;
417
+ output += ` Files: ${files.toLocaleString()} | Tokens: ${tokens}\n`;
418
+ if (message) {
419
+ output += ` Message: ${message}\n`;
420
+ }
421
+ output += '\n';
422
+ } catch {
423
+ // Skip invalid snapshots
424
+ }
425
+ }
426
+
427
+ return output;
428
+ }
429
+
430
+ /**
431
+ * Get snapshots for programmatic use
432
+ * @returns {object[]} Array of snapshot info
433
+ */
434
+ async getSnapshots() {
435
+ const filenames = await this.store.list();
436
+ const snapshots = [];
437
+
438
+ for (const filename of filenames) {
439
+ try {
440
+ const snapshot = await this.store.load(filename);
441
+ snapshots.push({
442
+ id: snapshot.id,
443
+ timestamp: snapshot.timestamp,
444
+ message: snapshot.message || '',
445
+ totalFiles: snapshot.summary?.totalFiles || 0,
446
+ totalTokens: snapshot.summary?.totalTokens || 0,
447
+ });
448
+ } catch {
449
+ // Skip invalid snapshots
450
+ }
451
+ }
452
+
453
+ return snapshots;
454
+ }
455
+
456
+ /**
457
+ * Compare two snapshots
458
+ * @param {string} id1 - First snapshot ID
459
+ * @param {string} id2 - Second snapshot ID
460
+ * @returns {object|null} Comparison result
461
+ */
462
+ async compareSnapshots(id1, id2) {
463
+ const snap1 = await this.store.loadById(id1);
464
+ const snap2 = await this.store.loadById(id2);
465
+
466
+ if (!snap1 || !snap2) {
467
+ return null;
468
+ }
469
+
470
+ return this.diff.compare(snap1, snap2);
471
+ }
472
+
473
+ /**
474
+ * Compare with last snapshot
475
+ * @param {object} currentAnalysis - Current analysis results
476
+ * @returns {object|null} Comparison result
477
+ */
478
+ async compareWithLast(currentAnalysis) {
479
+ const snapshots = await this.store.list({ limit: 1 });
480
+
481
+ if (snapshots.length === 0) {
482
+ return null;
483
+ }
484
+
485
+ const lastSnapshot = await this.store.load(snapshots[0]);
486
+ const currentSnapshot = {
487
+ id: 'current',
488
+ timestamp: new Date().toISOString(),
489
+ summary: {
490
+ totalFiles: currentAnalysis.totalFiles || 0,
491
+ totalTokens: currentAnalysis.totalTokens || 0,
492
+ totalBytes: currentAnalysis.totalBytes || 0,
493
+ totalLines: currentAnalysis.totalLines || 0,
494
+ },
495
+ byExtension: currentAnalysis.byExtension || {},
496
+ files: (currentAnalysis.largestFiles || []).map((f) => ({
497
+ path: f.relativePath || f.path,
498
+ tokens: f.tokens,
499
+ lines: f.lines,
500
+ extension: f.extension,
501
+ })),
502
+ };
503
+
504
+ return this.diff.compare(lastSnapshot, currentSnapshot);
505
+ }
506
+
507
+ /**
508
+ * Get trend analysis
509
+ * @param {number} limit - Maximum snapshots to analyze
510
+ * @returns {object|null} Trend analysis
511
+ */
512
+ async getTrend(limit = 10) {
513
+ const filenames = await this.store.list({ limit });
514
+ const snapshots = [];
515
+
516
+ for (const filename of filenames) {
517
+ try {
518
+ const snapshot = await this.store.load(filename);
519
+ snapshots.push(snapshot);
520
+ } catch {
521
+ // Skip invalid snapshots
522
+ }
523
+ }
524
+
525
+ if (snapshots.length < 2) {
526
+ return null;
527
+ }
528
+
529
+ // Sort oldest to newest
530
+ snapshots.reverse();
531
+
532
+ return this.diff.analyzeTrend(snapshots);
533
+ }
534
+
535
+ /**
536
+ * Format diff output for display
537
+ * @param {object} comparison - Comparison result
538
+ * @returns {string} Formatted output
539
+ */
540
+ formatDiff(comparison) {
541
+ if (!comparison) {
542
+ return '\n⚠️ No snapshots available for comparison.\n\n';
543
+ }
544
+
545
+ const formatDate = (timestamp) => {
546
+ const date = new Date(timestamp);
547
+ return date.toISOString().split('T')[0];
548
+ };
549
+
550
+ let output = '\n📊 Snapshot Comparison\n';
551
+ output += '═'.repeat(60) + '\n';
552
+ output += ` From: ${comparison.oldSnapshot.id || 'N/A'} (${formatDate(comparison.oldSnapshot.timestamp)})\n`;
553
+ output += ` To: ${comparison.newSnapshot.id || 'N/A'} (${formatDate(comparison.newSnapshot.timestamp)})\n\n`;
554
+
555
+ // Token changes
556
+ const oldTokens = comparison.oldSnapshot.summary?.totalTokens || 0;
557
+ const newTokens = comparison.newSnapshot.summary?.totalTokens || 0;
558
+ const tokenDiff = comparison.totalTokenDiff;
559
+ const percentChange = comparison.percentChange;
560
+ const tokenSign = tokenDiff >= 0 ? '+' : '';
561
+
562
+ output += ` Files: ${(comparison.oldSnapshot.summary?.totalFiles || 0).toLocaleString()} → ${(comparison.newSnapshot.summary?.totalFiles || 0).toLocaleString()}`;
563
+ const fileDiff =
564
+ (comparison.newSnapshot.summary?.totalFiles || 0) -
565
+ (comparison.oldSnapshot.summary?.totalFiles || 0);
566
+ output += ` (${tokenSign}${fileDiff}, ${tokenSign}${percentChange.toFixed(1)}%)\n`;
567
+
568
+ output += ` Tokens: ${this.formatTokens(oldTokens)} → ${this.formatTokens(newTokens)}`;
569
+ output += ` (${tokenSign}${this.formatTokens(Math.abs(tokenDiff))}, ${tokenSign}${percentChange.toFixed(1)}%)\n\n`;
570
+
571
+ // Extension changes
572
+ if (comparison.extensionChanges && comparison.extensionChanges.length > 0) {
573
+ const growing = comparison.extensionChanges.filter((e) => e.tokenDiff > 0).slice(0, 5);
574
+
575
+ if (growing.length > 0) {
576
+ output += ' 📈 Growth by Extension:\n';
577
+ output += ' ' + '─'.repeat(50) + '\n';
578
+ for (const ext of growing) {
579
+ const sign = ext.tokenDiff >= 0 ? '+' : '';
580
+ const extLabel = ext.extension || '(no ext)';
581
+ output += ` ${extLabel.padEnd(8)} ${sign}${ext.tokenDiff.toLocaleString()} tokens (${sign}${ext.percentChange.toFixed(0)}%)\n`;
582
+ }
583
+ output += '\n';
584
+ }
585
+ }
586
+
587
+ // Top growing files
588
+ if (comparison.modified && comparison.modified.length > 0) {
589
+ const topGrowth = comparison.modified
590
+ .filter((f) => f.tokenDiff > 0)
591
+ .sort((a, b) => b.tokenDiff - a.tokenDiff)
592
+ .slice(0, 5);
593
+
594
+ if (topGrowth.length > 0) {
595
+ output += ' 📁 Top Growing Files:\n';
596
+ output += ' ' + '─'.repeat(50) + '\n';
597
+ for (const file of topGrowth) {
598
+ output += ` +${file.tokenDiff.toLocaleString().padStart(8)} ${file.path}\n`;
599
+ }
600
+ output += '\n';
601
+ }
602
+ }
603
+
604
+ // Added files summary
605
+ if (comparison.added && comparison.added.length > 0) {
606
+ output += ` ✅ Added: ${comparison.added.length} files (+${comparison.added.reduce((s, f) => s + (f.tokens || 0), 0).toLocaleString()} tokens)\n`;
607
+ }
608
+
609
+ // Removed files summary
610
+ if (comparison.removed && comparison.removed.length > 0) {
611
+ output += ` ❌ Removed: ${comparison.removed.length} files (-${comparison.removed.reduce((s, f) => s + (f.tokens || 0), 0).toLocaleString()} tokens)\n`;
612
+ }
613
+
614
+ output += '\n';
615
+ return output;
616
+ }
617
+
618
+ /**
619
+ * Format trend output for display
620
+ * @param {object} trend - Trend analysis result
621
+ * @returns {string} Formatted output
622
+ */
623
+ formatTrend(trend) {
624
+ if (!trend) {
625
+ return '\n⚠️ Need at least 2 snapshots for trend analysis.\n\n';
626
+ }
627
+
628
+ let output = '\n📈 Token Growth Trend\n';
629
+ output += '═'.repeat(60) + '\n\n';
630
+
631
+ output += ` Snapshots analyzed: ${trend.snapshotCount}\n`;
632
+ output += ` Time span: ${trend.timeSpan.days} days\n\n`;
633
+
634
+ output += ` Average daily growth: ${trend.averageDailyGrowth.toLocaleString()} tokens\n`;
635
+ output += ` Projected monthly: +${trend.projectedMonthlyGrowth.toLocaleString()} tokens\n`;
636
+ output += ` Projected 6 months: +${trend.projectedSixMonthGrowth.toLocaleString()} tokens\n\n`;
637
+
638
+ output += ` Total growth: ${trend.totalGrowth >= 0 ? '+' : ''}${trend.totalGrowth.toLocaleString()} tokens\n`;
639
+ output += ` Growth rate: ${trend.growthRate >= 0 ? '+' : ''}${trend.growthRate.toFixed(1)}%\n\n`;
640
+
641
+ // Add chart
642
+ output += this.diff.generateTrendChart(trend.changes);
643
+
644
+ return output;
645
+ }
646
+
647
+ /**
648
+ * Format tokens for display (K, M suffixes)
649
+ * @param {number} tokens - Token count
650
+ * @returns {string} Formatted string
651
+ */
652
+ formatTokens(tokens) {
653
+ if (tokens >= 1000000) {
654
+ return `${(tokens / 1000000).toFixed(1)}M`;
655
+ }
656
+ if (tokens >= 1000) {
657
+ return `${(tokens / 1000).toFixed(0)}K`;
658
+ }
659
+ return tokens.toLocaleString();
660
+ }
661
+
662
+ /**
663
+ * Export snapshot as JSON
664
+ * @param {string} id - Snapshot ID
665
+ * @returns {object|null} Snapshot data or null
666
+ */
667
+ async exportSnapshot(id) {
668
+ return await this.store.loadById(id);
669
+ }
670
+
671
+ /**
672
+ * Delete a snapshot
673
+ * @param {string} id - Snapshot ID
674
+ * @returns {boolean} True if deleted
675
+ */
676
+ async deleteSnapshot(id) {
677
+ return await this.store.delete(id);
678
+ }
679
+ }