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.
- package/.ctxman/llm-profiles.json +1067 -39
- package/.ctxman/profiles/backend-team.json +13 -0
- package/.ctxman/profiles/default.json +12 -0
- package/.ctxman/profiles/devops-team.json +21 -0
- package/.ctxman/profiles/frontend-team.json +20 -0
- package/.ctxman/templates/bug-fix.json +17 -0
- package/.ctxman/templates/code-review.json +9 -0
- package/.ctxman/templates/documentation.json +9 -0
- package/.ctxman/templates/feature.json +9 -0
- package/.ctxman/templates/full-context.json +9 -0
- package/.ctxman/templates/refactoring.json +18 -0
- package/.ctxman/templates/testing.json +9 -0
- package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
- package/LICENSE +21 -0
- package/README.md +381 -136
- package/bin/cli.js +1880 -529
- package/bin/cm-ask.js +18 -67
- package/bin/cm-audit.js +121 -0
- package/bin/cm-count.js +151 -0
- package/bin/cm-gitingest.js +128 -115
- package/bin/cm-map.js +242 -0
- package/bin/cm-mcp.js +293 -0
- package/bin/cm-pack.js +405 -0
- package/bin/cm-policy.js +170 -0
- package/bin/cm-pr.js +248 -0
- package/bin/cm-search.js +178 -0
- package/bin/cm-update.js +128 -128
- package/bin/cm-why.js +197 -0
- package/bin/mcp-server.js +8 -24
- package/ctxman.js +237 -183
- package/examples/README.md +19 -4
- package/examples/github-actions/ctxman-check.yml +49 -0
- package/examples/wizard-profiles/security-audit/profile.json +7 -1
- package/index.d.ts +1422 -0
- package/index.js +55 -31
- package/lib/agents/pr-failure-agent.js +48 -45
- package/lib/analyzers/ai-suggester.js +451 -0
- package/lib/analyzers/dependency-scanner.js +505 -0
- package/lib/analyzers/duplicate-detector.js +264 -0
- package/lib/analyzers/go-method-analyzer.js +151 -110
- package/lib/analyzers/import-tracker.js +431 -0
- package/lib/analyzers/method-analyzer.js +1055 -434
- package/lib/analyzers/performance-dashboard.js +687 -0
- package/lib/analyzers/token-calculator.js +895 -504
- package/lib/analyzers/unused-exports-detector.js +336 -0
- package/lib/api/mcp/install.js +424 -0
- package/lib/api/mcp/prompts.js +281 -281
- package/lib/api/mcp/resources.js +300 -241
- package/lib/api/mcp/sandbox.js +89 -0
- package/lib/api/mcp/server.js +772 -312
- package/lib/api/mcp/start.js +125 -0
- package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
- package/lib/api/rest/server.js +105 -28
- package/lib/cache/CacheManager.js +9 -17
- package/lib/core/Analyzer.js +16 -10
- package/lib/core/ContextBuilder.js +19 -28
- package/lib/core/Reporter.js +15 -14
- package/lib/core/Scanner.js +81 -12
- package/lib/count/index.js +100 -0
- package/lib/formatters/format-registry.js +394 -372
- package/lib/formatters/gitingest-formatter.js +656 -621
- package/lib/formatters/toon-formatter-v1.3.js +637 -632
- package/lib/formatters/toon-formatter.js +239 -240
- package/lib/integrations/git/BlameTracker.js +4 -4
- package/lib/integrations/git/DiffAnalyzer.js +38 -31
- package/lib/integrations/git/GitClient.js +71 -53
- package/lib/lsp/server.js +311 -0
- package/lib/map/graph.js +299 -0
- package/lib/map/imports.js +233 -0
- package/lib/map/index.js +33 -0
- package/lib/map/rank.js +253 -0
- package/lib/map/render.js +346 -0
- package/lib/map/resolver.js +574 -0
- package/lib/pack/budget.js +111 -0
- package/lib/pack/code-lexer.js +360 -0
- package/lib/pack/compress.js +414 -0
- package/lib/pack/index.js +354 -0
- package/lib/pack/renderers/common.js +134 -0
- package/lib/pack/renderers/index.js +24 -0
- package/lib/pack/renderers/json.js +47 -0
- package/lib/pack/renderers/markdown.js +54 -0
- package/lib/pack/renderers/plain.js +38 -0
- package/lib/pack/renderers/xml.js +85 -0
- package/lib/pack/select.js +177 -0
- package/lib/pack/split.js +182 -0
- package/lib/pack/tree.js +35 -0
- package/lib/parsers/gitignore-parser.js +116 -107
- package/lib/parsers/method-filter-parser.js +50 -33
- package/lib/plugins/ExporterPlugin.js +3 -3
- package/lib/plugins/LanguagePlugin.js +3 -3
- package/lib/plugins/PluginManager.js +7 -13
- package/lib/policy/audit.js +312 -0
- package/lib/policy/enforce.js +125 -0
- package/lib/policy/ignore-files.js +169 -0
- package/lib/policy/index.js +53 -0
- package/lib/policy/policy.js +489 -0
- package/lib/pr/diff-parser.js +267 -0
- package/lib/pr/method-mapper.js +148 -0
- package/lib/pr/pr-context.js +829 -0
- package/lib/pr/pr-git.js +149 -0
- package/lib/pr/pr-renderer.js +644 -0
- package/lib/pr/related-tests.js +296 -0
- package/lib/rag/DataSourcePlugin.js +5 -5
- package/lib/rag/EmbeddingProvider.js +19 -8
- package/lib/rag/Indexer.js +14 -5
- package/lib/rag/VectorStoreFactory.js +3 -3
- package/lib/rag/ask.js +122 -0
- package/lib/rag/sources/FileDataSource.js +12 -9
- package/lib/rag/stores/BaseVectorStore.js +5 -5
- package/lib/rag/stores/ChromaDBStore.js +10 -10
- package/lib/rag/stores/LanceDBStore.js +29 -19
- package/lib/rag/stores/PineconeStore.js +11 -9
- package/lib/search/bm25.js +433 -0
- package/lib/search/code-units.js +201 -0
- package/lib/search/symbols.js +189 -0
- package/lib/ui/dashboard.js +120 -89
- package/lib/ui/index.js +1 -6
- package/lib/ui/init-wizard-ui.js +550 -0
- package/lib/ui/progress-bar.js +94 -88
- package/lib/ui/select-input.js +40 -32
- package/lib/ui/template-selector.js +99 -0
- package/lib/ui/wizard.js +374 -329
- package/lib/utils/claude-token-counter.js +151 -0
- package/lib/utils/cli-output.js +17 -0
- package/lib/utils/clipboard-utils.js +49 -50
- package/lib/utils/config-utils.js +81 -66
- package/lib/utils/context-versioning.js +532 -0
- package/lib/utils/error-handler.js +112 -112
- package/lib/utils/file-utils.js +153 -69
- package/lib/utils/format-converter.js +356 -283
- package/lib/utils/git-utils.js +366 -294
- package/lib/utils/llm-cost-estimator.js +281 -0
- package/lib/utils/llm-detector.js +242 -78
- package/lib/utils/logger.js +246 -237
- package/lib/utils/messages.js +355 -0
- package/lib/utils/multi-repo-manager.js +364 -0
- package/lib/utils/profile-manager.js +380 -0
- package/lib/utils/safe-read.js +61 -0
- package/lib/utils/secret-redactor.js +61 -0
- package/lib/utils/snapshot-manager.js +679 -0
- package/lib/utils/template-manager.js +378 -0
- package/lib/utils/token-utils.js +201 -170
- package/lib/utils/tokenizer-adapter.js +421 -377
- package/lib/utils/updater.js +539 -506
- package/lib/watch/FileWatcher.js +12 -8
- package/lib/watch/IncrementalAnalyzer.js +7 -7
- package/lib/why/coupling.js +81 -0
- package/lib/why/errors.js +23 -0
- package/lib/why/git.js +169 -0
- package/lib/why/history.js +108 -0
- package/lib/why/index.js +720 -0
- package/lib/why/ownership.js +79 -0
- package/lib/why/paths.js +207 -0
- package/lib/why/references.js +259 -0
- package/lib/why/render.js +722 -0
- package/lib/why/search.js +358 -0
- package/lib/why/target.js +389 -0
- package/lib/wizards/config-generator.js +257 -0
- package/lib/wizards/project-detector.js +572 -0
- package/package.json +44 -10
- package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
- package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
- 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
|
+
}
|