ctxman 3.1.1 → 3.2.8
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 +6 -6
- package/README.md +16 -10
- package/bin/cli.js +109 -10
- package/bin/cm-ask.js +64 -6
- package/bin/cm-audit.js +76 -0
- package/bin/cm-count.js +45 -6
- package/bin/cm-diagnose.js +199 -0
- package/bin/cm-focus.js +322 -0
- package/bin/cm-map.js +71 -18
- package/bin/cm-pack.js +36 -5
- package/bin/cm-policy.js +107 -2
- package/bin/cm-pr.js +18 -3
- package/bin/cm-search.js +84 -19
- package/bin/cm-task.js +192 -0
- package/bin/cm-who.js +119 -0
- package/bin/cm-why.js +41 -5
- package/index.d.ts +940 -1
- package/index.js +105 -1
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +843 -29
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/count/index.js +74 -14
- package/lib/diagnose/index.js +712 -0
- package/lib/focus/index.js +790 -0
- package/lib/map/call-graph.js +570 -0
- package/lib/map/graph.js +30 -5
- package/lib/map/index.js +12 -4
- package/lib/map/rank.js +45 -7
- package/lib/map/render.js +89 -28
- package/lib/pack/index.js +20 -1
- package/lib/pack/select.js +8 -1
- package/lib/policy/audit.js +3 -1
- package/lib/policy/index.js +5 -0
- package/lib/policy/workspace.js +254 -0
- package/lib/pr/pr-context.js +292 -31
- package/lib/pr/pr-renderer.js +25 -10
- package/lib/rag/EmbeddingProvider.js +139 -2
- package/lib/rag/VectorStoreFactory.js +10 -4
- package/lib/rag/ask.js +186 -17
- package/lib/rag/stores/SqliteVectorStore.js +595 -0
- package/lib/search/hybrid.js +324 -0
- package/lib/search/symbols.js +75 -11
- package/lib/task/index.js +630 -0
- package/lib/utils/file-utils.js +1 -0
- package/lib/utils/multi-repo-manager.js +4 -1
- package/lib/watch/FileWatcher.js +121 -31
- package/lib/watch/IncrementalAnalyzer.js +964 -16
- package/lib/who/index.js +893 -0
- package/lib/why/analyzer-bridge.js +223 -0
- package/lib/why/discussions.js +314 -0
- package/lib/why/index.js +356 -30
- package/lib/why/render.js +154 -9
- package/lib/workspace/index.js +1274 -0
- package/package.json +1 -1
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Responsibilities:
|
|
6
6
|
* - Cache analysis results
|
|
7
|
-
* - Invalidate on file changes
|
|
8
|
-
* - Support multiple storage backends
|
|
7
|
+
* - Invalidate on file changes (mtimeMs, byte size, analysis options, tokenizer encoding)
|
|
8
|
+
* - Support multiple storage backends (memory, disk)
|
|
9
9
|
* - TTL-based expiration
|
|
10
10
|
*/
|
|
11
11
|
|
|
@@ -16,6 +16,22 @@ import { getLogger } from '../utils/logger.js';
|
|
|
16
16
|
|
|
17
17
|
const logger = getLogger('CacheManager');
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a Date or numeric timestamp to milliseconds, or null when missing
|
|
21
|
+
* @param {Date|number|undefined|null} value
|
|
22
|
+
* @returns {number|null}
|
|
23
|
+
*/
|
|
24
|
+
function normalizeMtime(value) {
|
|
25
|
+
if (value instanceof Date) {
|
|
26
|
+
const ms = value.getTime();
|
|
27
|
+
return Number.isFinite(ms) ? ms : null;
|
|
28
|
+
}
|
|
29
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
export class CacheManager {
|
|
20
36
|
constructor(options = {}) {
|
|
21
37
|
this.options = {
|
|
@@ -23,6 +39,7 @@ export class CacheManager {
|
|
|
23
39
|
strategy: 'disk', // disk, memory
|
|
24
40
|
ttl: 3600, // 1 hour in seconds
|
|
25
41
|
path: '.context-cache',
|
|
42
|
+
maxEntries: 5000,
|
|
26
43
|
maxSize: 100 * 1024 * 1024, // 100MB
|
|
27
44
|
...options,
|
|
28
45
|
};
|
|
@@ -36,8 +53,8 @@ export class CacheManager {
|
|
|
36
53
|
errors: 0,
|
|
37
54
|
};
|
|
38
55
|
|
|
39
|
-
// Initialize cache directory
|
|
40
|
-
if (this.options.strategy === 'disk') {
|
|
56
|
+
// Initialize cache directory when enabled on disk
|
|
57
|
+
if (this.options.enabled && this.options.strategy === 'disk') {
|
|
41
58
|
this.initCacheDirectory();
|
|
42
59
|
}
|
|
43
60
|
}
|
|
@@ -62,22 +79,29 @@ export class CacheManager {
|
|
|
62
79
|
/**
|
|
63
80
|
* Get cached analysis for file
|
|
64
81
|
* @param {string} filePath
|
|
65
|
-
* @param {number} modifiedTime - File modification timestamp
|
|
66
|
-
* @
|
|
82
|
+
* @param {number|Date} modifiedTime - File modification timestamp
|
|
83
|
+
* @param {object} [meta] - Additional cache key/validation metadata
|
|
84
|
+
* @param {number} [meta.size] - Current file byte size
|
|
85
|
+
* @param {string} [meta.namespace] - Caller namespace ('analyzer', 'token-calculator', ...)
|
|
86
|
+
* @param {string} [meta.encoding] - Tokenizer encoding ('cl100k_base', 'o200k_base', ...)
|
|
87
|
+
* @param {boolean} [meta.methodLevel] - Whether method-level analysis is included
|
|
88
|
+
* @param {string} [meta.extraKey] - Extra discriminator (e.g. method filter signature)
|
|
89
|
+
* @returns {object|null}
|
|
67
90
|
*/
|
|
68
|
-
get(filePath, modifiedTime) {
|
|
91
|
+
get(filePath, modifiedTime, meta = {}) {
|
|
69
92
|
if (!this.options.enabled) {
|
|
70
93
|
return null;
|
|
71
94
|
}
|
|
72
95
|
|
|
73
|
-
const key = this.getCacheKey(filePath);
|
|
96
|
+
const key = this.getCacheKey(filePath, meta);
|
|
97
|
+
const mtimeMs = normalizeMtime(modifiedTime);
|
|
98
|
+
const size = typeof meta?.size === 'number' ? meta.size : null;
|
|
74
99
|
|
|
75
100
|
try {
|
|
76
101
|
if (this.options.strategy === 'memory') {
|
|
77
|
-
return this.getFromMemory(key,
|
|
78
|
-
} else {
|
|
79
|
-
return this.getFromDisk(key, modifiedTime);
|
|
102
|
+
return this.getFromMemory(key, mtimeMs, size);
|
|
80
103
|
}
|
|
104
|
+
return this.getFromDisk(key, mtimeMs, size);
|
|
81
105
|
} catch (error) {
|
|
82
106
|
logger.error(`Cache get error: ${error.message}`);
|
|
83
107
|
this.stats.errors++;
|
|
@@ -88,10 +112,11 @@ export class CacheManager {
|
|
|
88
112
|
/**
|
|
89
113
|
* Get from memory cache
|
|
90
114
|
* @param {string} key
|
|
91
|
-
* @param {number} modifiedTime
|
|
92
|
-
* @
|
|
115
|
+
* @param {number|null} modifiedTime
|
|
116
|
+
* @param {number|null} size
|
|
117
|
+
* @returns {object|null}
|
|
93
118
|
*/
|
|
94
|
-
getFromMemory(key, modifiedTime) {
|
|
119
|
+
getFromMemory(key, modifiedTime, size = null) {
|
|
95
120
|
const cached = this.memoryCache.get(key);
|
|
96
121
|
|
|
97
122
|
if (!cached) {
|
|
@@ -99,25 +124,30 @@ export class CacheManager {
|
|
|
99
124
|
return null;
|
|
100
125
|
}
|
|
101
126
|
|
|
102
|
-
// Check if expired
|
|
103
|
-
if (this.isExpired(cached.timestamp, cached.modifiedTime, modifiedTime)) {
|
|
127
|
+
// Check if expired or invalidated by mtime/size change
|
|
128
|
+
if (this.isExpired(cached.timestamp, cached.modifiedTime, modifiedTime, cached.size, size)) {
|
|
104
129
|
this.memoryCache.delete(key);
|
|
105
130
|
this.stats.misses++;
|
|
106
131
|
return null;
|
|
107
132
|
}
|
|
108
133
|
|
|
134
|
+
// Refresh insertion order for LRU eviction
|
|
135
|
+
this.memoryCache.delete(key);
|
|
136
|
+
this.memoryCache.set(key, cached);
|
|
137
|
+
|
|
109
138
|
this.stats.hits++;
|
|
110
139
|
logger.debug(`Cache hit (memory): ${key}`);
|
|
111
|
-
return cached.data;
|
|
140
|
+
return structuredClone(cached.data);
|
|
112
141
|
}
|
|
113
142
|
|
|
114
143
|
/**
|
|
115
144
|
* Get from disk cache
|
|
116
145
|
* @param {string} key
|
|
117
|
-
* @param {number} modifiedTime
|
|
118
|
-
* @
|
|
146
|
+
* @param {number|null} modifiedTime
|
|
147
|
+
* @param {number|null} size
|
|
148
|
+
* @returns {object|null}
|
|
119
149
|
*/
|
|
120
|
-
getFromDisk(key, modifiedTime) {
|
|
150
|
+
getFromDisk(key, modifiedTime, size = null) {
|
|
121
151
|
const cacheFile = path.join(this.options.path, `${key}.json`);
|
|
122
152
|
|
|
123
153
|
if (!fs.existsSync(cacheFile)) {
|
|
@@ -129,9 +159,13 @@ export class CacheManager {
|
|
|
129
159
|
const content = fs.readFileSync(cacheFile, 'utf-8');
|
|
130
160
|
const cached = JSON.parse(content);
|
|
131
161
|
|
|
132
|
-
// Check if expired
|
|
133
|
-
if (this.isExpired(cached.timestamp, cached.modifiedTime, modifiedTime)) {
|
|
134
|
-
|
|
162
|
+
// Check if expired or invalidated by mtime/size change
|
|
163
|
+
if (this.isExpired(cached.timestamp, cached.modifiedTime, modifiedTime, cached.size, size)) {
|
|
164
|
+
try {
|
|
165
|
+
fs.unlinkSync(cacheFile);
|
|
166
|
+
} catch {
|
|
167
|
+
// Ignore race if already removed
|
|
168
|
+
}
|
|
135
169
|
this.stats.misses++;
|
|
136
170
|
return null;
|
|
137
171
|
}
|
|
@@ -148,19 +182,21 @@ export class CacheManager {
|
|
|
148
182
|
/**
|
|
149
183
|
* Set cache entry
|
|
150
184
|
* @param {string} filePath
|
|
151
|
-
* @param {
|
|
152
|
-
* @param {number} modifiedTime
|
|
185
|
+
* @param {object} analysis
|
|
186
|
+
* @param {number|Date} modifiedTime
|
|
187
|
+
* @param {object} [meta]
|
|
153
188
|
*/
|
|
154
|
-
set(filePath, analysis, modifiedTime) {
|
|
189
|
+
set(filePath, analysis, modifiedTime, meta = {}) {
|
|
155
190
|
if (!this.options.enabled) {
|
|
156
191
|
return;
|
|
157
192
|
}
|
|
158
193
|
|
|
159
|
-
const key = this.getCacheKey(filePath);
|
|
194
|
+
const key = this.getCacheKey(filePath, meta);
|
|
160
195
|
const cacheEntry = {
|
|
161
|
-
data: analysis,
|
|
196
|
+
data: this.options.strategy === 'memory' ? structuredClone(analysis) : analysis,
|
|
162
197
|
timestamp: Date.now(),
|
|
163
|
-
modifiedTime: modifiedTime,
|
|
198
|
+
modifiedTime: normalizeMtime(modifiedTime),
|
|
199
|
+
size: typeof meta?.size === 'number' ? meta.size : null,
|
|
164
200
|
};
|
|
165
201
|
|
|
166
202
|
try {
|
|
@@ -184,10 +220,14 @@ export class CacheManager {
|
|
|
184
220
|
* @param {object} cacheEntry
|
|
185
221
|
*/
|
|
186
222
|
setInMemory(key, cacheEntry) {
|
|
223
|
+
if (this.memoryCache.has(key)) {
|
|
224
|
+
this.memoryCache.delete(key);
|
|
225
|
+
}
|
|
187
226
|
this.memoryCache.set(key, cacheEntry);
|
|
188
227
|
|
|
189
228
|
// Check memory limit
|
|
190
|
-
|
|
229
|
+
const limit = Number.isInteger(this.options.maxEntries) ? this.options.maxEntries : 1000;
|
|
230
|
+
while (this.memoryCache.size > limit) {
|
|
191
231
|
this.evictOldest();
|
|
192
232
|
}
|
|
193
233
|
}
|
|
@@ -198,26 +238,47 @@ export class CacheManager {
|
|
|
198
238
|
* @param {object} cacheEntry
|
|
199
239
|
*/
|
|
200
240
|
setOnDisk(key, cacheEntry) {
|
|
201
|
-
const
|
|
241
|
+
const cacheDir = path.resolve(this.options.path);
|
|
242
|
+
if (!fs.existsSync(cacheDir)) {
|
|
243
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
244
|
+
}
|
|
245
|
+
const cacheFile = path.join(cacheDir, `${key}.json`);
|
|
202
246
|
fs.writeFileSync(cacheFile, JSON.stringify(cacheEntry), 'utf-8');
|
|
203
247
|
}
|
|
204
248
|
|
|
205
249
|
/**
|
|
206
|
-
* Check if cache entry is expired
|
|
250
|
+
* Check if cache entry is expired or invalidated
|
|
207
251
|
* @param {number} cacheTimestamp - When cache was created
|
|
208
|
-
* @param {number} cacheModifiedTime - File modified time when cached
|
|
209
|
-
* @param {number} currentModifiedTime - Current file modified time
|
|
252
|
+
* @param {number|null} cacheModifiedTime - File modified time when cached
|
|
253
|
+
* @param {number|Date|null} currentModifiedTime - Current file modified time
|
|
254
|
+
* @param {number|null} [cachedSize] - File size when cached
|
|
255
|
+
* @param {number|null} [currentSize] - Current file size
|
|
210
256
|
* @returns {boolean}
|
|
211
257
|
*/
|
|
212
|
-
isExpired(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
258
|
+
isExpired(
|
|
259
|
+
cacheTimestamp,
|
|
260
|
+
cacheModifiedTime,
|
|
261
|
+
currentModifiedTime,
|
|
262
|
+
cachedSize = null,
|
|
263
|
+
currentSize = null
|
|
264
|
+
) {
|
|
265
|
+
// Check TTL when positive
|
|
266
|
+
if (this.options.ttl > 0) {
|
|
267
|
+
const age = (Date.now() - cacheTimestamp) / 1000; // seconds
|
|
268
|
+
if (age > this.options.ttl) {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Any modification time mismatch (newer OR reverted to an older commit) invalidates
|
|
274
|
+
const currentMs = normalizeMtime(currentModifiedTime);
|
|
275
|
+
const cachedMs = normalizeMtime(cacheModifiedTime);
|
|
276
|
+
if (currentMs !== null && cachedMs !== null && currentMs !== cachedMs) {
|
|
216
277
|
return true;
|
|
217
278
|
}
|
|
218
279
|
|
|
219
|
-
//
|
|
220
|
-
if (
|
|
280
|
+
// Byte size mismatch invalidates even within identical mtime granularity
|
|
281
|
+
if (currentSize !== null && cachedSize !== null && currentSize !== cachedSize) {
|
|
221
282
|
return true;
|
|
222
283
|
}
|
|
223
284
|
|
|
@@ -225,12 +286,30 @@ export class CacheManager {
|
|
|
225
286
|
}
|
|
226
287
|
|
|
227
288
|
/**
|
|
228
|
-
* Generate cache key from file path
|
|
289
|
+
* Generate cache key from file path and analysis discriminator metadata
|
|
229
290
|
* @param {string} filePath
|
|
291
|
+
* @param {object} [meta]
|
|
230
292
|
* @returns {string}
|
|
231
293
|
*/
|
|
232
|
-
getCacheKey(filePath) {
|
|
233
|
-
|
|
294
|
+
getCacheKey(filePath, meta = {}) {
|
|
295
|
+
const hasMeta =
|
|
296
|
+
meta &&
|
|
297
|
+
(meta.namespace !== undefined ||
|
|
298
|
+
meta.encoding !== undefined ||
|
|
299
|
+
meta.methodLevel !== undefined ||
|
|
300
|
+
meta.extraKey !== undefined);
|
|
301
|
+
|
|
302
|
+
const payload = hasMeta
|
|
303
|
+
? JSON.stringify([
|
|
304
|
+
path.resolve(filePath),
|
|
305
|
+
meta.namespace || '',
|
|
306
|
+
meta.encoding || '',
|
|
307
|
+
Boolean(meta.methodLevel),
|
|
308
|
+
meta.extraKey || '',
|
|
309
|
+
])
|
|
310
|
+
: String(filePath);
|
|
311
|
+
|
|
312
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
234
313
|
}
|
|
235
314
|
|
|
236
315
|
/**
|
|
@@ -256,7 +335,9 @@ export class CacheManager {
|
|
|
256
335
|
if (fs.existsSync(cachePath)) {
|
|
257
336
|
const files = fs.readdirSync(cachePath);
|
|
258
337
|
for (const file of files) {
|
|
259
|
-
|
|
338
|
+
if (file.endsWith('.json')) {
|
|
339
|
+
fs.unlinkSync(path.join(cachePath, file));
|
|
340
|
+
}
|
|
260
341
|
}
|
|
261
342
|
}
|
|
262
343
|
} catch (error) {
|
|
@@ -278,7 +359,7 @@ export class CacheManager {
|
|
|
278
359
|
}
|
|
279
360
|
}
|
|
280
361
|
|
|
281
|
-
if (oldestKey) {
|
|
362
|
+
if (oldestKey !== null) {
|
|
282
363
|
this.memoryCache.delete(oldestKey);
|
|
283
364
|
this.stats.evictions++;
|
|
284
365
|
logger.debug(`Evicted oldest entry: ${oldestKey}`);
|
|
@@ -291,7 +372,7 @@ export class CacheManager {
|
|
|
291
372
|
*/
|
|
292
373
|
getStats() {
|
|
293
374
|
const totalRequests = this.stats.hits + this.stats.misses;
|
|
294
|
-
const hitRate = totalRequests > 0 ? ((this.stats.hits / totalRequests) * 100).toFixed(1) : 0;
|
|
375
|
+
const hitRate = totalRequests > 0 ? ((this.stats.hits / totalRequests) * 100).toFixed(1) : '0';
|
|
295
376
|
|
|
296
377
|
return {
|
|
297
378
|
...this.stats,
|
|
@@ -311,7 +392,7 @@ export class CacheManager {
|
|
|
311
392
|
const cachePath = path.resolve(this.options.path);
|
|
312
393
|
if (!fs.existsSync(cachePath)) return 0;
|
|
313
394
|
|
|
314
|
-
const files = fs.readdirSync(cachePath);
|
|
395
|
+
const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.json'));
|
|
315
396
|
return files.length;
|
|
316
397
|
} catch (_error) {
|
|
317
398
|
return 0;
|
|
@@ -335,6 +416,7 @@ export class CacheManager {
|
|
|
335
416
|
* Prune memory cache
|
|
336
417
|
*/
|
|
337
418
|
pruneMemoryCache() {
|
|
419
|
+
if (this.options.ttl <= 0) return;
|
|
338
420
|
const now = Date.now();
|
|
339
421
|
const ttlMs = this.options.ttl * 1000;
|
|
340
422
|
let pruned = 0;
|
|
@@ -353,14 +435,17 @@ export class CacheManager {
|
|
|
353
435
|
* Prune disk cache
|
|
354
436
|
*/
|
|
355
437
|
pruneDiskCache() {
|
|
438
|
+
if (this.options.ttl <= 0) return;
|
|
356
439
|
try {
|
|
357
440
|
const cachePath = path.resolve(this.options.path);
|
|
441
|
+
if (!fs.existsSync(cachePath)) return;
|
|
358
442
|
const files = fs.readdirSync(cachePath);
|
|
359
443
|
const now = Date.now();
|
|
360
444
|
const ttlMs = this.options.ttl * 1000;
|
|
361
445
|
let pruned = 0;
|
|
362
446
|
|
|
363
447
|
for (const file of files) {
|
|
448
|
+
if (!file.endsWith('.json')) continue;
|
|
364
449
|
const filePath = path.join(cachePath, file);
|
|
365
450
|
const stats = fs.statSync(filePath);
|
|
366
451
|
|
|
@@ -377,4 +462,33 @@ export class CacheManager {
|
|
|
377
462
|
}
|
|
378
463
|
}
|
|
379
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Resolve a `cache` option value (`false`, `true`, `'memory'`, `'disk'`, options object,
|
|
467
|
+
* or a `CacheManager` instance) into a `CacheManager` or `null`.
|
|
468
|
+
* @param {boolean|string|object|CacheManager|null|undefined} cacheOption
|
|
469
|
+
* @param {object} [defaults]
|
|
470
|
+
* @returns {CacheManager|null}
|
|
471
|
+
*/
|
|
472
|
+
export function resolveCacheManager(cacheOption, defaults = {}) {
|
|
473
|
+
if (!cacheOption) return null;
|
|
474
|
+
if (
|
|
475
|
+
cacheOption instanceof CacheManager ||
|
|
476
|
+
(typeof cacheOption === 'object' &&
|
|
477
|
+
typeof cacheOption.get === 'function' &&
|
|
478
|
+
typeof cacheOption.set === 'function')
|
|
479
|
+
) {
|
|
480
|
+
return cacheOption;
|
|
481
|
+
}
|
|
482
|
+
if (cacheOption === true || cacheOption === 'memory') {
|
|
483
|
+
return new CacheManager({ strategy: 'memory', ...defaults });
|
|
484
|
+
}
|
|
485
|
+
if (cacheOption === 'disk') {
|
|
486
|
+
return new CacheManager({ strategy: 'disk', ...defaults });
|
|
487
|
+
}
|
|
488
|
+
if (typeof cacheOption === 'object') {
|
|
489
|
+
return new CacheManager({ ...defaults, ...cacheOption });
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
380
494
|
export default CacheManager;
|
package/lib/core/Analyzer.js
CHANGED
|
@@ -7,6 +7,7 @@ import fs from 'fs';
|
|
|
7
7
|
import TokenUtils from '../utils/token-utils.js';
|
|
8
8
|
import FileUtils from '../utils/file-utils.js';
|
|
9
9
|
import MethodAnalyzer from '../analyzers/method-analyzer.js';
|
|
10
|
+
import { resolveCacheManager } from '../cache/CacheManager.js';
|
|
10
11
|
import { getLogger } from '../utils/logger.js';
|
|
11
12
|
|
|
12
13
|
const logger = getLogger('Analyzer');
|
|
@@ -17,15 +18,21 @@ export class Analyzer {
|
|
|
17
18
|
methodLevel: false,
|
|
18
19
|
parallel: false,
|
|
19
20
|
maxWorkers: 4,
|
|
21
|
+
targetModel: null,
|
|
22
|
+
cache: false,
|
|
20
23
|
...options,
|
|
21
24
|
};
|
|
22
25
|
|
|
26
|
+
this.encoding = options.encoding || TokenUtils.encodingFor(this.options.targetModel);
|
|
27
|
+
this.cache = resolveCacheManager(this.options.cache, {
|
|
28
|
+
...(this.options.cachePath ? { path: this.options.cachePath } : {}),
|
|
29
|
+
});
|
|
23
30
|
this.methodAnalyzer = new MethodAnalyzer();
|
|
24
31
|
this.stats = this.initStats();
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
initStats() {
|
|
28
|
-
|
|
35
|
+
const base = {
|
|
29
36
|
totalFiles: 0,
|
|
30
37
|
totalTokens: 0,
|
|
31
38
|
totalSize: 0,
|
|
@@ -35,6 +42,11 @@ export class Analyzer {
|
|
|
35
42
|
largestFiles: [],
|
|
36
43
|
analysisTime: 0,
|
|
37
44
|
};
|
|
45
|
+
if (this.cache) {
|
|
46
|
+
base.cacheHits = 0;
|
|
47
|
+
base.cacheMisses = 0;
|
|
48
|
+
}
|
|
49
|
+
return base;
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
async analyze(files) {
|
|
@@ -75,16 +87,56 @@ export class Analyzer {
|
|
|
75
87
|
return null;
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
let size = fileInfo.size;
|
|
91
|
+
let modified = fileInfo.modified;
|
|
92
|
+
let mtimeMs =
|
|
93
|
+
modified instanceof Date
|
|
94
|
+
? modified.getTime()
|
|
95
|
+
: typeof modified === 'number'
|
|
96
|
+
? modified
|
|
97
|
+
: null;
|
|
98
|
+
|
|
99
|
+
if (this.cache && (mtimeMs === null || typeof size !== 'number')) {
|
|
100
|
+
const stat = fs.statSync(fileInfo.path);
|
|
101
|
+
mtimeMs = stat.mtimeMs;
|
|
102
|
+
size = stat.size;
|
|
103
|
+
if (modified === undefined) modified = stat.mtime;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const cacheMeta = {
|
|
107
|
+
namespace: 'analyzer',
|
|
108
|
+
size,
|
|
109
|
+
encoding: this.encoding,
|
|
110
|
+
methodLevel: Boolean(this.options.methodLevel),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (this.cache) {
|
|
114
|
+
const cached = this.cache.get(fileInfo.path, mtimeMs, cacheMeta);
|
|
115
|
+
if (cached) {
|
|
116
|
+
this.stats.cacheHits++;
|
|
117
|
+
return {
|
|
118
|
+
...cached,
|
|
119
|
+
path: fileInfo.path,
|
|
120
|
+
relativePath: fileInfo.relativePath,
|
|
121
|
+
name: fileInfo.name ?? cached.name,
|
|
122
|
+
extension: fileInfo.extension ?? cached.extension,
|
|
123
|
+
size: size ?? cached.size,
|
|
124
|
+
modified: modified ?? cached.modified,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
this.stats.cacheMisses++;
|
|
128
|
+
}
|
|
129
|
+
|
|
78
130
|
const content = fs.readFileSync(fileInfo.path, 'utf-8');
|
|
79
|
-
const tokens = TokenUtils.calculate(content, fileInfo.path);
|
|
131
|
+
const tokens = TokenUtils.calculate(content, fileInfo.path, this.encoding);
|
|
80
132
|
|
|
81
133
|
const analysis = {
|
|
82
134
|
path: fileInfo.path,
|
|
83
135
|
relativePath: fileInfo.relativePath,
|
|
84
136
|
name: fileInfo.name,
|
|
85
137
|
extension: fileInfo.extension,
|
|
86
|
-
size:
|
|
87
|
-
modified:
|
|
138
|
+
size: size,
|
|
139
|
+
modified: modified,
|
|
88
140
|
tokens: tokens,
|
|
89
141
|
lines: content.split('\n').length,
|
|
90
142
|
language: this.detectLanguage(fileInfo.extension),
|
|
@@ -95,11 +147,15 @@ export class Analyzer {
|
|
|
95
147
|
const sources = this.methodAnalyzer.extractMethodSources(content, methods, fileInfo.path);
|
|
96
148
|
analysis.methods = methods.map((method, i) => ({
|
|
97
149
|
...method,
|
|
98
|
-
tokens: TokenUtils.calculate(sources[i], fileInfo.path),
|
|
150
|
+
tokens: TokenUtils.calculate(sources[i], fileInfo.path, this.encoding),
|
|
99
151
|
}));
|
|
100
152
|
analysis.methodCount = methods.length;
|
|
101
153
|
}
|
|
102
154
|
|
|
155
|
+
if (this.cache) {
|
|
156
|
+
this.cache.set(fileInfo.path, analysis, mtimeMs, cacheMeta);
|
|
157
|
+
}
|
|
158
|
+
|
|
103
159
|
return analysis;
|
|
104
160
|
} catch (error) {
|
|
105
161
|
logger.error(`Error analyzing file ${fileInfo.relativePath}: ${error.message}`);
|
package/lib/count/index.js
CHANGED
|
@@ -1,40 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Token count for a target model
|
|
3
|
-
* Counts a project
|
|
4
|
-
* available offline (tiktoken for OpenAI models), and, on request,
|
|
5
|
-
* Anthropic's count_tokens API for Claude models.
|
|
3
|
+
* Counts a project, multi-repository workspace, or piped text with the target model's
|
|
4
|
+
* tokenizer where one is available offline (tiktoken for OpenAI models), and, on request,
|
|
5
|
+
* exactly through Anthropic's count_tokens API for Claude models.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
9
10
|
import Scanner from '../core/Scanner.js';
|
|
10
11
|
import TokenUtils from '../utils/token-utils.js';
|
|
11
12
|
import { LLMDetector } from '../utils/llm-detector.js';
|
|
12
13
|
import { ClaudeTokenCounter } from '../utils/claude-token-counter.js';
|
|
13
14
|
import { redactSecrets } from '../utils/secret-redactor.js';
|
|
15
|
+
import { resolveWorkspaceRepos } from '../workspace/index.js';
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
|
-
* Read the documents to count
|
|
18
|
+
* Read the documents to count from a single project or multi-repository workspace
|
|
17
19
|
* @param {string} root - Project root (ignored when `text` is given)
|
|
18
20
|
* @param {string} [text] - A single piped text instead of the project files
|
|
19
|
-
* @
|
|
21
|
+
* @param {Array<string|{root: string, alias?: string}>} [repos] - Additional repository roots
|
|
22
|
+
* @returns {{documents: {path: string, content: string, repo?: string}[], resolvedRepos: {root: string, alias: string}[] | null}}
|
|
20
23
|
*/
|
|
21
|
-
function readDocuments(root, text) {
|
|
22
|
-
if (text !== undefined)
|
|
23
|
-
|
|
24
|
+
function readDocuments(root, text, repos) {
|
|
25
|
+
if (text !== undefined) {
|
|
26
|
+
return {
|
|
27
|
+
documents: [{ path: '<stdin>', content: text }],
|
|
28
|
+
resolvedRepos: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const extraRepos = Array.isArray(repos) ? repos.filter(Boolean) : [];
|
|
33
|
+
if (extraRepos.length > 0) {
|
|
34
|
+
const resolvedRepos = resolveWorkspaceRepos([root, ...extraRepos]);
|
|
35
|
+
const documents = [];
|
|
36
|
+
for (const repo of resolvedRepos) {
|
|
37
|
+
const scanned = new Scanner(repo.root, { policy: repo.policy }).scan();
|
|
38
|
+
for (const file of scanned) {
|
|
39
|
+
try {
|
|
40
|
+
const relPosix = file.relativePath.split(path.sep).join('/');
|
|
41
|
+
documents.push({
|
|
42
|
+
path: `${repo.alias}/${relPosix}`,
|
|
43
|
+
repo: repo.alias,
|
|
44
|
+
content: fs.readFileSync(file.path, 'utf8'),
|
|
45
|
+
});
|
|
46
|
+
} catch (_error) {
|
|
47
|
+
// Skip unreadable files
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { documents, resolvedRepos };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const documents = new Scanner(root).scan().flatMap((file) => {
|
|
24
55
|
try {
|
|
25
56
|
return [{ path: file.relativePath, content: fs.readFileSync(file.path, 'utf8') }];
|
|
26
57
|
} catch (_error) {
|
|
27
58
|
return [];
|
|
28
59
|
}
|
|
29
60
|
});
|
|
61
|
+
return { documents, resolvedRepos: null };
|
|
30
62
|
}
|
|
31
63
|
|
|
32
64
|
/**
|
|
33
|
-
* Count tokens for a target model
|
|
65
|
+
* Count tokens for a target model across a project, multi-repo workspace, or piped text
|
|
34
66
|
* @param {string} root - Project root
|
|
35
67
|
* @param {object} [options]
|
|
36
68
|
* @param {string} [options.model] - Target model id or alias (default: DEFAULT_TARGET_MODEL)
|
|
37
69
|
* @param {string} [options.text] - Count this text instead of the project
|
|
70
|
+
* @param {Array<string|{root: string, alias?: string}>} [options.repos] - Additional workspace repository roots
|
|
38
71
|
* @param {boolean} [options.api=false] - Count exactly with the Anthropic API (Claude only)
|
|
39
72
|
* @param {object} [options.client] - Anthropic client stand-in (tests)
|
|
40
73
|
* @param {(done: number, total: number) => void} [options.onProgress]
|
|
@@ -45,12 +78,13 @@ export async function countForModel(root, options = {}) {
|
|
|
45
78
|
const profile = LLMDetector.getProfile(requested);
|
|
46
79
|
const modelId = profile.id || requested;
|
|
47
80
|
const encoding = TokenUtils.encodingFor(modelId);
|
|
48
|
-
const documents = readDocuments(root, options.text);
|
|
81
|
+
const { documents, resolvedRepos } = readDocuments(root, options.text, options.repos);
|
|
49
82
|
|
|
50
83
|
const files = documents
|
|
51
84
|
.map((doc) => ({
|
|
52
85
|
path: doc.path,
|
|
53
86
|
tokens: TokenUtils.calculate(doc.content, doc.path, encoding),
|
|
87
|
+
...(doc.repo ? { repo: doc.repo } : {}),
|
|
54
88
|
}))
|
|
55
89
|
.sort((a, b) => b.tokens - a.tokens);
|
|
56
90
|
const localTokens = files.reduce((sum, file) => sum + file.tokens, 0);
|
|
@@ -67,7 +101,7 @@ export async function countForModel(root, options = {}) {
|
|
|
67
101
|
// tiktoken matches OpenAI models whose encoding is published; others are approximations
|
|
68
102
|
exact: TokenUtils.isExact() && profile.tokenizer?.exact === true,
|
|
69
103
|
contextWindow: profile.contextWindow,
|
|
70
|
-
largestFiles: files,
|
|
104
|
+
largestFiles: files.map(({ path: filePath, tokens }) => ({ path: filePath, tokens })),
|
|
71
105
|
};
|
|
72
106
|
|
|
73
107
|
if (options.api) {
|
|
@@ -89,11 +123,37 @@ export async function countForModel(root, options = {}) {
|
|
|
89
123
|
result.method = 'anthropic-api';
|
|
90
124
|
result.exact = true;
|
|
91
125
|
result.requests = requests;
|
|
92
|
-
result.ratio = localTokens > 0 ? total / localTokens :
|
|
126
|
+
result.ratio = localTokens > 0 ? total / localTokens : 1;
|
|
127
|
+
// The API counts batches, not single files; scale each file's local count by the
|
|
128
|
+
// observed ratio so the per-file breakdown matches the model too
|
|
129
|
+
result.largestFiles = files.map(({ path: filePath, tokens }) => ({
|
|
130
|
+
path: filePath,
|
|
131
|
+
tokens: Math.round(tokens * result.ratio),
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const window = result.contextWindow || 0;
|
|
136
|
+
result.fits = window > 0 ? result.tokens <= window : true;
|
|
137
|
+
result.percentOfWindow = window > 0 ? (result.tokens / window) * 100 : 0;
|
|
138
|
+
|
|
139
|
+
if (resolvedRepos) {
|
|
140
|
+
const ratio = result.method === 'anthropic-api' && result.ratio ? result.ratio : 1;
|
|
141
|
+
result.repos = resolvedRepos.map((repo) => {
|
|
142
|
+
const repoFiles = files.filter((f) => f.repo === repo.alias);
|
|
143
|
+
const repoLocalTokens = repoFiles.reduce((sum, f) => sum + f.tokens, 0);
|
|
144
|
+
const repoTokens =
|
|
145
|
+
result.method === 'anthropic-api' ? Math.round(repoLocalTokens * ratio) : repoLocalTokens;
|
|
146
|
+
return {
|
|
147
|
+
alias: repo.alias,
|
|
148
|
+
root: repo.root,
|
|
149
|
+
files: repoFiles.length,
|
|
150
|
+
localTokens: repoLocalTokens,
|
|
151
|
+
tokens: repoTokens,
|
|
152
|
+
percentOfWindow: window > 0 ? (repoTokens / window) * 100 : 0,
|
|
153
|
+
};
|
|
154
|
+
});
|
|
93
155
|
}
|
|
94
156
|
|
|
95
|
-
result.fits = result.tokens <= result.contextWindow;
|
|
96
|
-
result.percentOfWindow = (result.tokens / result.contextWindow) * 100;
|
|
97
157
|
return result;
|
|
98
158
|
}
|
|
99
159
|
|