@statforge/claudestat 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -547
- package/dist/config.d.ts +1 -0
- package/dist/config.js +6 -0
- package/dist/cost-projector.d.ts +24 -0
- package/dist/cost-projector.js +133 -0
- package/dist/daemon.js +1 -1
- package/dist/db.d.ts +4 -0
- package/dist/db.js +14 -6
- package/dist/enricher.d.ts +18 -26
- package/dist/enricher.js +113 -333
- package/dist/index.js +48 -7
- package/dist/insights.js +0 -2
- package/dist/meta-stats.js +1 -1
- package/dist/middleware/rate-limiter.js +1 -1
- package/dist/paths.d.ts +17 -0
- package/dist/paths.js +44 -0
- package/dist/quota-tracker.js +0 -1
- package/dist/roast.js +0 -2
- package/dist/routes/events.js +29 -5
- package/dist/routes/misc.js +4 -22
- package/dist/routes/stream.d.ts +1 -1
- package/dist/routes/stream.js +3 -3
- package/dist/service.js +11 -7
- package/dist/watchers/adapter.d.ts +37 -0
- package/dist/watchers/adapter.js +31 -0
- package/dist/watchers/amp.d.ts +8 -0
- package/dist/watchers/amp.js +42 -0
- package/dist/watchers/claude-code.d.ts +17 -0
- package/dist/watchers/claude-code.js +300 -0
- package/dist/watchers/codebuff.d.ts +8 -0
- package/dist/watchers/codebuff.js +42 -0
- package/dist/watchers/codex.d.ts +9 -0
- package/dist/watchers/codex.js +43 -0
- package/dist/watchers/droid.d.ts +8 -0
- package/dist/watchers/droid.js +42 -0
- package/dist/watchers/opencode.d.ts +9 -0
- package/dist/watchers/opencode.js +43 -0
- package/hooks/event.js +4 -9
- package/package.json +10 -2
package/dist/enricher.js
CHANGED
|
@@ -1,347 +1,129 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* enricher.ts —
|
|
3
|
+
* enricher.ts — Watcher multi-CLI usando adapters
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* En lugar de hardcodear la lógica de Claude Code, usa el adapter pattern
|
|
6
|
+
* para soportar múltiples coding CLIs (Claude Code, Codex, OpenCode, etc.).
|
|
7
7
|
*
|
|
8
|
-
* Cada
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* message.model
|
|
14
|
-
*
|
|
15
|
-
* El enricher observa cambios en esos archivos (con chokidar),
|
|
16
|
-
* calcula el coste acumulado por sesión y llama al callback
|
|
17
|
-
* para que el daemon actualice la DB y haga broadcast via SSE.
|
|
8
|
+
* Cada adapter implementa WatcherAdapter (src/watchers/adapter.ts):
|
|
9
|
+
* - detect() → si el CLI está instalado
|
|
10
|
+
* - getWatchPaths() → qué archivos observar
|
|
11
|
+
* - parseEvent() → parsear una línea de trace
|
|
12
|
+
* - getSessionCost() → calcular costos acumulados
|
|
18
13
|
*/
|
|
19
14
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
20
15
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
21
16
|
};
|
|
22
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
-
exports.getContextWindow =
|
|
24
|
-
exports.getAllBlockCostsForSession = getAllBlockCostsForSession;
|
|
25
|
-
exports.getSessionPrompts = getSessionPrompts;
|
|
18
|
+
exports.getSessionPrompts = exports.getContextWindow = exports.getAllBlockCostsForSession = void 0;
|
|
26
19
|
exports.startEnricher = startEnricher;
|
|
27
20
|
exports.stopEnricher = stopEnricher;
|
|
28
21
|
exports.cleanupSession = cleanupSession;
|
|
29
22
|
exports.processLatestForSession = processLatestForSession;
|
|
30
|
-
const promises_1 = __importDefault(require("fs/promises"));
|
|
31
|
-
const fs_1 = __importDefault(require("fs"));
|
|
32
|
-
const path_1 = __importDefault(require("path"));
|
|
33
23
|
const chokidar_1 = __importDefault(require("chokidar"));
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
25
|
+
const fs_1 = __importDefault(require("fs"));
|
|
26
|
+
const adapter_1 = require("./watchers/adapter");
|
|
27
|
+
require("./watchers/claude-code");
|
|
28
|
+
require("./watchers/codex");
|
|
29
|
+
require("./watchers/opencode");
|
|
30
|
+
require("./watchers/amp");
|
|
31
|
+
require("./watchers/droid");
|
|
32
|
+
require("./watchers/codebuff");
|
|
33
|
+
// Re-export Claude Code-specific utilities for routes/stream and routes/misc
|
|
34
|
+
var claude_code_1 = require("./watchers/claude-code");
|
|
35
|
+
Object.defineProperty(exports, "getAllBlockCostsForSession", { enumerable: true, get: function () { return claude_code_1.getAllBlockCostsForSession; } });
|
|
36
|
+
Object.defineProperty(exports, "getContextWindow", { enumerable: true, get: function () { return claude_code_1.getContextWindow; } });
|
|
37
|
+
Object.defineProperty(exports, "getSessionPrompts", { enumerable: true, get: function () { return claude_code_1.getSessionPrompts; } });
|
|
38
|
+
const prevContextBySession = new Map();
|
|
39
|
+
let watcher = null;
|
|
40
|
+
const pendingFiles = new Map();
|
|
41
|
+
const fileLocks = new Map();
|
|
42
|
+
// ─── Adapter lookup por filePath ───────────────────────────────────────────────
|
|
43
|
+
const adapterByDir = new Map();
|
|
44
|
+
function findAdapter(filePath) {
|
|
45
|
+
for (const [dir, adapter] of adapterByDir) {
|
|
46
|
+
if (filePath.startsWith(dir))
|
|
47
|
+
return adapter;
|
|
53
48
|
}
|
|
49
|
+
return undefined;
|
|
54
50
|
}
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
// ─── Procesamiento de archivos ─────────────────────────────────────────────────
|
|
52
|
+
async function processFile(filePath) {
|
|
57
53
|
if (fileLocks.has(filePath))
|
|
58
54
|
return null;
|
|
59
55
|
fileLocks.set(filePath, Promise.resolve());
|
|
60
|
-
let fileContent;
|
|
61
56
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const totals = {
|
|
73
|
-
input_tokens: 0, output_tokens: 0,
|
|
74
|
-
cache_read: 0, cache_creation: 0, cost_usd: 0,
|
|
75
|
-
context_used: 0, context_window: 200000
|
|
76
|
-
};
|
|
77
|
-
let lastInputUsd = 0;
|
|
78
|
-
let lastOutputUsd = 0;
|
|
79
|
-
let lastInputTokens = 0;
|
|
80
|
-
let lastOutputTokens = 0;
|
|
81
|
-
let lastModel = undefined;
|
|
82
|
-
let firstTs = undefined;
|
|
83
|
-
for (const raw of fileContent.split('\n')) {
|
|
84
|
-
const line = raw.trim();
|
|
85
|
-
if (!line)
|
|
86
|
-
continue;
|
|
87
|
-
try {
|
|
88
|
-
const obj = JSON.parse(line);
|
|
89
|
-
if (obj.type !== 'assistant')
|
|
90
|
-
continue;
|
|
91
|
-
const msg = obj.message;
|
|
92
|
-
if (!msg?.usage)
|
|
93
|
-
continue;
|
|
94
|
-
const usage = msg.usage;
|
|
95
|
-
const model = msg.model ?? 'claude-sonnet-4-6';
|
|
96
|
-
if (firstTs === undefined && obj.timestamp) {
|
|
97
|
-
try {
|
|
98
|
-
firstTs = new Date(obj.timestamp).getTime();
|
|
99
|
-
}
|
|
100
|
-
catch (e) { /* ignore invalid timestamp */ }
|
|
101
|
-
}
|
|
102
|
-
totals.input_tokens += usage.input_tokens ?? 0;
|
|
103
|
-
totals.output_tokens += usage.output_tokens ?? 0;
|
|
104
|
-
totals.cache_read += usage.cache_read_input_tokens ?? 0;
|
|
105
|
-
totals.cache_creation += usage.cache_creation_input_tokens ?? 0;
|
|
106
|
-
const resolvedModel = model ?? 'claude-sonnet-4-6';
|
|
107
|
-
totals.cost_usd += (0, pricing_1.calcCost)(resolvedModel, usage);
|
|
108
|
-
totals.context_used = (usage.input_tokens ?? 0)
|
|
109
|
-
+ (usage.cache_read_input_tokens ?? 0)
|
|
110
|
-
+ (usage.cache_creation_input_tokens ?? 0);
|
|
111
|
-
totals.context_window = getContextWindow(resolvedModel);
|
|
112
|
-
const price = pricing_1.PRICING[resolvedModel] ?? pricing_1.DEFAULT_PRICING;
|
|
113
|
-
const M = 1000000;
|
|
114
|
-
lastInputUsd = ((usage.input_tokens ?? 0) * price.input +
|
|
115
|
-
(usage.cache_read_input_tokens ?? 0) * price.cacheRead +
|
|
116
|
-
(usage.cache_creation_input_tokens ?? 0) * price.cacheCreate) / M;
|
|
117
|
-
lastOutputUsd = ((usage.output_tokens ?? 0) * price.output) / M;
|
|
118
|
-
lastInputTokens = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
119
|
-
lastOutputTokens = usage.output_tokens ?? 0;
|
|
120
|
-
lastModel = model ?? lastModel;
|
|
121
|
-
}
|
|
122
|
-
catch (e) {
|
|
123
|
-
console.warn('[enricher] Error calculating cost:', e);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
if (lastInputUsd + lastOutputUsd > 0) {
|
|
127
|
-
totals.lastEntry = {
|
|
128
|
-
inputUsd: lastInputUsd,
|
|
129
|
-
outputUsd: lastOutputUsd,
|
|
130
|
-
totalUsd: lastInputUsd + lastOutputUsd,
|
|
131
|
-
inputTokens: lastInputTokens,
|
|
132
|
-
outputTokens: lastOutputTokens,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
totals.lastModel = lastModel;
|
|
136
|
-
totals.firstTs = firstTs;
|
|
137
|
-
fileOffsets.set(filePath, { offset: currentSize, lastAccess: Date.now() });
|
|
138
|
-
fileLocks.delete(filePath);
|
|
139
|
-
return totals;
|
|
140
|
-
}
|
|
141
|
-
const blockCostCache = new Map();
|
|
142
|
-
const costCacheLocks = new Map(); // Simple lock flag
|
|
143
|
-
const BLOCK_COST_TTL = 5 * 60000;
|
|
144
|
-
async function getAllBlockCostsForSession(sessionId) {
|
|
145
|
-
// Return cached if available and not expired
|
|
146
|
-
const cached = blockCostCache.get(sessionId);
|
|
147
|
-
if (cached && Date.now() - cached.ts < BLOCK_COST_TTL)
|
|
148
|
-
return cached.data;
|
|
149
|
-
// Skip if already calculating for this session
|
|
150
|
-
if (costCacheLocks.get(sessionId))
|
|
151
|
-
return cached?.data ?? [];
|
|
152
|
-
costCacheLocks.set(sessionId, true);
|
|
153
|
-
try {
|
|
154
|
-
if (!fs_1.default.existsSync(PROJECTS_DIR))
|
|
155
|
-
return [];
|
|
156
|
-
const dirs = await promises_1.default.readdir(PROJECTS_DIR);
|
|
157
|
-
for (const dir of dirs) {
|
|
158
|
-
const dirPath = path_1.default.join(PROJECTS_DIR, dir);
|
|
159
|
-
try {
|
|
160
|
-
const stat = await promises_1.default.stat(dirPath);
|
|
161
|
-
if (!stat.isDirectory())
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
const filePath = path_1.default.join(dirPath, `${sessionId}.jsonl`);
|
|
168
|
-
try {
|
|
169
|
-
await promises_1.default.access(filePath);
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
const result = [];
|
|
175
|
-
let current = null;
|
|
176
|
-
const content = await promises_1.default.readFile(filePath, 'utf8');
|
|
177
|
-
for (const raw of content.split('\n')) {
|
|
178
|
-
const line = raw.trim();
|
|
179
|
-
if (!line)
|
|
180
|
-
continue;
|
|
181
|
-
try {
|
|
182
|
-
const obj = JSON.parse(line);
|
|
183
|
-
if (obj.type === 'human' || obj.type === 'user') {
|
|
184
|
-
const msgContent = obj.message?.content;
|
|
185
|
-
if (Array.isArray(msgContent) && msgContent[0]?.type === 'tool_result')
|
|
186
|
-
continue;
|
|
187
|
-
const text = typeof msgContent === 'string' ? msgContent
|
|
188
|
-
: Array.isArray(msgContent)
|
|
189
|
-
? (msgContent.find((c) => c?.type === 'text')?.text ?? '')
|
|
190
|
-
: '';
|
|
191
|
-
if (text.includes('<system-reminder>') || text.includes('<command-name>'))
|
|
192
|
-
continue;
|
|
193
|
-
current = { inputUsd: 0, outputUsd: 0, totalUsd: 0, inputTokens: 0, outputTokens: 0 };
|
|
194
|
-
result.push(current);
|
|
195
|
-
}
|
|
196
|
-
if (obj.type === 'assistant' && current) {
|
|
197
|
-
const usage = obj.message?.usage;
|
|
198
|
-
const model = obj.message?.model ?? 'claude-sonnet-4-6';
|
|
199
|
-
if (!usage)
|
|
200
|
-
continue;
|
|
201
|
-
const price = pricing_1.PRICING[model] ?? pricing_1.DEFAULT_PRICING;
|
|
202
|
-
const M = 1000000;
|
|
203
|
-
const inUsd = ((usage.input_tokens ?? 0) * price.input +
|
|
204
|
-
(usage.cache_read_input_tokens ?? 0) * price.cacheRead +
|
|
205
|
-
(usage.cache_creation_input_tokens ?? 0) * price.cacheCreate) / M;
|
|
206
|
-
const outUsd = ((usage.output_tokens ?? 0) * price.output) / M;
|
|
207
|
-
const inTok = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
208
|
-
const outTok = usage.output_tokens ?? 0;
|
|
209
|
-
current.inputUsd += inUsd;
|
|
210
|
-
current.outputUsd += outUsd;
|
|
211
|
-
current.totalUsd += inUsd + outUsd;
|
|
212
|
-
current.inputTokens += inTok;
|
|
213
|
-
current.outputTokens += outTok;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
catch (e) {
|
|
217
|
-
console.warn('[enricher] Error reading JSONL block:', e);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
const filtered = result.filter(b => b.totalUsd > 0);
|
|
221
|
-
blockCostCache.set(sessionId, { data: filtered, ts: Date.now() });
|
|
222
|
-
return filtered;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
catch (e) {
|
|
226
|
-
console.warn('[enricher] Error calculating block costs:', e);
|
|
57
|
+
const adapter = findAdapter(filePath);
|
|
58
|
+
if (!adapter)
|
|
59
|
+
return null;
|
|
60
|
+
const sessionId = path_1.default.basename(filePath, '.jsonl');
|
|
61
|
+
if (!sessionId.includes('-') || sessionId.length < 10)
|
|
62
|
+
return null;
|
|
63
|
+
const cost = await adapter.getSessionCost(filePath);
|
|
64
|
+
if (!cost || cost.cost_usd < 0)
|
|
65
|
+
return null;
|
|
66
|
+
return { sessionId, cost, source: adapter.name };
|
|
227
67
|
}
|
|
228
68
|
finally {
|
|
229
|
-
|
|
69
|
+
fileLocks.delete(filePath);
|
|
230
70
|
}
|
|
231
|
-
return cached?.data ?? [];
|
|
232
71
|
}
|
|
233
|
-
|
|
234
|
-
try {
|
|
235
|
-
if (!fs_1.default.existsSync(PROJECTS_DIR))
|
|
236
|
-
return [];
|
|
237
|
-
const dirs = await promises_1.default.readdir(PROJECTS_DIR);
|
|
238
|
-
for (const dir of dirs) {
|
|
239
|
-
const dirPath = path_1.default.join(PROJECTS_DIR, dir);
|
|
240
|
-
try {
|
|
241
|
-
const stat = await promises_1.default.stat(dirPath);
|
|
242
|
-
if (!stat.isDirectory())
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
catch {
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
const candidates = [
|
|
249
|
-
path_1.default.join(dirPath, `${sessionId}.jsonl`),
|
|
250
|
-
];
|
|
251
|
-
for (const file of candidates) {
|
|
252
|
-
try {
|
|
253
|
-
await promises_1.default.access(file);
|
|
254
|
-
}
|
|
255
|
-
catch {
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
const results = [];
|
|
259
|
-
const content = await promises_1.default.readFile(file, 'utf8');
|
|
260
|
-
let index = 0;
|
|
261
|
-
for (const raw of content.split('\n')) {
|
|
262
|
-
const line = raw.trim();
|
|
263
|
-
if (!line)
|
|
264
|
-
continue;
|
|
265
|
-
try {
|
|
266
|
-
const obj = JSON.parse(line);
|
|
267
|
-
if (obj.type !== 'human' && obj.type !== 'user')
|
|
268
|
-
continue;
|
|
269
|
-
const ts = obj.timestamp ? new Date(obj.timestamp).getTime() : 0;
|
|
270
|
-
if (!ts || isNaN(ts))
|
|
271
|
-
continue;
|
|
272
|
-
const msgContent = obj.message?.content;
|
|
273
|
-
let text = '';
|
|
274
|
-
if (typeof msgContent === 'string') {
|
|
275
|
-
text = msgContent;
|
|
276
|
-
}
|
|
277
|
-
else if (Array.isArray(msgContent)) {
|
|
278
|
-
const textBlocks = msgContent.filter(c => c?.type === 'text');
|
|
279
|
-
if (textBlocks.length === 0)
|
|
280
|
-
continue;
|
|
281
|
-
text = textBlocks.map((c) => c.text ?? '').join('\n').trim();
|
|
282
|
-
}
|
|
283
|
-
if (text.includes('<command-name>') ||
|
|
284
|
-
text.includes('<local-command-stdout>') ||
|
|
285
|
-
text.includes('<system-reminder>') ||
|
|
286
|
-
text.length === 0)
|
|
287
|
-
continue;
|
|
288
|
-
index++;
|
|
289
|
-
results.push({ index, ts, text });
|
|
290
|
-
}
|
|
291
|
-
catch { }
|
|
292
|
-
}
|
|
293
|
-
return results;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
catch { }
|
|
298
|
-
return [];
|
|
299
|
-
}
|
|
300
|
-
// ─── Watcher ─────────────────────────────────────────────────────────────────
|
|
301
|
-
const PROJECTS_DIR = path_1.default.join((0, paths_1.getClaudeDir)(), 'projects');
|
|
302
|
-
const prevContextBySession = new Map();
|
|
303
|
-
let watcher = null;
|
|
304
|
-
const pendingFiles = new Map();
|
|
72
|
+
// ─── Start / Stop ──────────────────────────────────────────────────────────────
|
|
305
73
|
let offsetCleanupInterval = null;
|
|
306
|
-
function startEnricher(onUpdate, onCompact
|
|
307
|
-
|
|
308
|
-
|
|
74
|
+
function startEnricher(onUpdate, onCompact) {
|
|
75
|
+
const adapters = (0, adapter_1.getActiveAdapters)();
|
|
76
|
+
if (adapters.length === 0) {
|
|
77
|
+
console.warn('[enricher] No supported CLI tools detected');
|
|
309
78
|
return;
|
|
310
79
|
}
|
|
311
|
-
|
|
80
|
+
// Index directories for adapter lookup
|
|
81
|
+
for (const a of adapters) {
|
|
82
|
+
for (const watchPath of a.getWatchPaths()) {
|
|
83
|
+
// Extract base directory from glob pattern
|
|
84
|
+
const baseDir = watchPath.split('/**')[0];
|
|
85
|
+
if (baseDir)
|
|
86
|
+
adapterByDir.set(baseDir, a);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const watchPaths = adapters.flatMap(a => a.getWatchPaths());
|
|
90
|
+
console.log(`[enricher] Watching ${adapters.map(a => a.label).join(', ')}`);
|
|
91
|
+
watcher = chokidar_1.default.watch(watchPaths, {
|
|
312
92
|
persistent: true,
|
|
313
93
|
ignoreInitial: true,
|
|
314
94
|
awaitWriteFinish: {
|
|
315
95
|
stabilityThreshold: 200,
|
|
316
|
-
pollInterval: 100
|
|
317
|
-
}
|
|
96
|
+
pollInterval: 100,
|
|
97
|
+
},
|
|
318
98
|
});
|
|
319
99
|
const handleFile = (filePath) => {
|
|
320
|
-
const sessionId = path_1.default.basename(filePath, '.jsonl');
|
|
321
|
-
if (!sessionId.includes('-') || sessionId.length < 10)
|
|
322
|
-
return;
|
|
323
100
|
const existing = pendingFiles.get(filePath);
|
|
324
101
|
if (existing)
|
|
325
102
|
clearTimeout(existing);
|
|
326
|
-
const timer = setTimeout(() => {
|
|
103
|
+
const timer = setTimeout(async () => {
|
|
327
104
|
pendingFiles.delete(filePath);
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
105
|
+
const result = await processFile(filePath);
|
|
106
|
+
if (!result)
|
|
107
|
+
return;
|
|
108
|
+
const { sessionId, cost, source } = result;
|
|
109
|
+
const prev = prevContextBySession.get(sessionId);
|
|
110
|
+
if (onCompact && prev !== undefined && prev > 140000 && cost.context_used < prev * 0.5) {
|
|
111
|
+
onCompact(sessionId);
|
|
112
|
+
}
|
|
113
|
+
prevContextBySession.set(sessionId, cost.context_used);
|
|
114
|
+
onUpdate(sessionId, cost, source);
|
|
338
115
|
}, 100);
|
|
339
116
|
pendingFiles.set(filePath, timer);
|
|
340
117
|
};
|
|
341
118
|
watcher.on('change', handleFile);
|
|
342
119
|
watcher.on('add', handleFile);
|
|
343
|
-
offsetCleanupInterval = setInterval(
|
|
344
|
-
|
|
120
|
+
offsetCleanupInterval = setInterval(() => {
|
|
121
|
+
for (const [, adapter] of adapterByDir) {
|
|
122
|
+
if (typeof adapter.constructor?.name === 'undefined') {
|
|
123
|
+
// cleanup per-adapter state if needed
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}, 5 * 60000);
|
|
345
127
|
}
|
|
346
128
|
function stopEnricher() {
|
|
347
129
|
if (watcher) {
|
|
@@ -355,46 +137,44 @@ function stopEnricher() {
|
|
|
355
137
|
for (const [, timer] of pendingFiles)
|
|
356
138
|
clearTimeout(timer);
|
|
357
139
|
pendingFiles.clear();
|
|
358
|
-
fileOffsets.clear();
|
|
359
140
|
prevContextBySession.clear();
|
|
360
|
-
|
|
141
|
+
adapterByDir.clear();
|
|
361
142
|
console.log('[enricher] Stopped');
|
|
362
143
|
}
|
|
363
144
|
function cleanupSession(sessionId) {
|
|
364
|
-
blockCostCache.delete(sessionId);
|
|
365
145
|
prevContextBySession.delete(sessionId);
|
|
366
|
-
for (const [key, entry] of fileOffsets) {
|
|
367
|
-
if (key.includes(sessionId))
|
|
368
|
-
fileOffsets.delete(key);
|
|
369
|
-
}
|
|
370
146
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (!stat.isDirectory())
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
catch {
|
|
147
|
+
// ─── Legacy: processLatestForSession (now adapter-agnostic) ─────────────────────
|
|
148
|
+
async function processLatestForSession(sessionId, onUpdate, source) {
|
|
149
|
+
const adapters = source
|
|
150
|
+
? [(0, adapter_1.getAdapter)(source)].filter(Boolean)
|
|
151
|
+
: (0, adapter_1.getActiveAdapters)();
|
|
152
|
+
for (const adapter of adapters) {
|
|
153
|
+
for (const watchPath of adapter.getWatchPaths()) {
|
|
154
|
+
const baseDir = watchPath.split('/**')[0];
|
|
155
|
+
if (!baseDir)
|
|
384
156
|
continue;
|
|
385
|
-
|
|
386
|
-
const filePath = path_1.default.join(dirPath, `${sessionId}.jsonl`);
|
|
387
|
-
try {
|
|
388
|
-
await promises_1.default.access(filePath);
|
|
389
|
-
}
|
|
390
|
-
catch {
|
|
157
|
+
if (!fs_1.default.existsSync(baseDir))
|
|
391
158
|
continue;
|
|
159
|
+
const dirs = fs_1.default.readdirSync(baseDir);
|
|
160
|
+
for (const dir of dirs) {
|
|
161
|
+
const dirPath = path_1.default.join(baseDir, dir);
|
|
162
|
+
try {
|
|
163
|
+
if (!fs_1.default.statSync(dirPath).isDirectory())
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const filePath = path_1.default.join(dirPath, `${sessionId}.jsonl`);
|
|
170
|
+
if (!fs_1.default.existsSync(filePath))
|
|
171
|
+
continue;
|
|
172
|
+
const cost = await adapter.getSessionCost(filePath);
|
|
173
|
+
if (cost && cost.cost_usd >= 0) {
|
|
174
|
+
onUpdate(sessionId, cost, adapter.name);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
392
177
|
}
|
|
393
|
-
const cost = await processJSONL(filePath);
|
|
394
|
-
if (cost && cost.cost_usd >= 0)
|
|
395
|
-
onUpdate(sessionId, cost);
|
|
396
|
-
return;
|
|
397
178
|
}
|
|
398
179
|
}
|
|
399
|
-
catch { }
|
|
400
180
|
}
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ const export_1 = require("./export");
|
|
|
29
29
|
const config_1 = require("./config");
|
|
30
30
|
const doctor_1 = require("./doctor");
|
|
31
31
|
const roast_1 = require("./roast");
|
|
32
|
+
const cost_projector_1 = require("./cost-projector");
|
|
32
33
|
const insights_1 = require("./insights");
|
|
33
34
|
const paths_1 = require("./paths");
|
|
34
35
|
const quota_tracker_1 = require("./quota-tracker");
|
|
@@ -200,11 +201,20 @@ program
|
|
|
200
201
|
console.log('✅ claudestat fully removed');
|
|
201
202
|
process.exit(0);
|
|
202
203
|
}
|
|
203
|
-
|
|
204
|
-
(0, install_1.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
// 1. Wizard: Node check + plan + config + hooks + MCP
|
|
205
|
+
await (0, install_1.runWizard)();
|
|
206
|
+
// 2. Start daemon now
|
|
207
|
+
const daemonRunning = await fetch('http://localhost:7337/health', {
|
|
208
|
+
signal: AbortSignal.timeout(2000),
|
|
209
|
+
}).then(r => r.ok).catch(() => false);
|
|
210
|
+
if (!daemonRunning) {
|
|
211
|
+
spawnDaemon();
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
console.log('✅ Daemon already running');
|
|
215
|
+
console.log(' Dashboard → http://localhost:7337');
|
|
216
|
+
}
|
|
217
|
+
console.log('\n Run \x1b[36mclaudestat watch\x1b[0m to see live activity');
|
|
208
218
|
process.exit(0);
|
|
209
219
|
});
|
|
210
220
|
program
|
|
@@ -238,14 +248,13 @@ program
|
|
|
238
248
|
.action(async (opts) => {
|
|
239
249
|
try {
|
|
240
250
|
await (0, quota_tracker_1.refreshFromApi)(); // refresh disk cache on demand; daemon reads from disk
|
|
241
|
-
const [quotaRes
|
|
251
|
+
const [quotaRes] = await Promise.all([
|
|
242
252
|
fetch('http://localhost:7337/quota'),
|
|
243
253
|
fetch('http://localhost:7337/health'),
|
|
244
254
|
]);
|
|
245
255
|
if (!quotaRes.ok)
|
|
246
256
|
throw new Error('Daemon unavailable');
|
|
247
257
|
const q = await quotaRes.json();
|
|
248
|
-
const _h = await healthRes.json().catch(() => ({}));
|
|
249
258
|
if (opts.json) {
|
|
250
259
|
console.log(JSON.stringify({
|
|
251
260
|
cyclePrompts: q.cyclePrompts,
|
|
@@ -315,6 +324,7 @@ program
|
|
|
315
324
|
.option('--threshold <number>', 'Quota percentage to trigger the kill switch (default: 95)')
|
|
316
325
|
.option('--plan <plan>', 'Force plan detection: pro|max5|max20|auto')
|
|
317
326
|
.option('--alerts <bool>', 'Enable/disable daemon rate limit alerts: true|false')
|
|
327
|
+
.option('--session-limit <usd>', 'Alert when a session exceeds this cost in USD (0 = disabled)')
|
|
318
328
|
.action((opts) => {
|
|
319
329
|
const cfg = (0, config_1.readConfig)();
|
|
320
330
|
let changed = false;
|
|
@@ -344,6 +354,15 @@ program
|
|
|
344
354
|
cfg.alertsEnabled = opts.alerts === 'true';
|
|
345
355
|
changed = true;
|
|
346
356
|
}
|
|
357
|
+
if (opts.sessionLimit !== undefined) {
|
|
358
|
+
const v = parseFloat(opts.sessionLimit);
|
|
359
|
+
if (!isNaN(v) && v >= 0) {
|
|
360
|
+
cfg.sessionCostLimitUsd = v;
|
|
361
|
+
changed = true;
|
|
362
|
+
}
|
|
363
|
+
else
|
|
364
|
+
console.warn(' ⚠️ session-limit must be a number >= 0 (e.g. 5 for $5)');
|
|
365
|
+
}
|
|
347
366
|
if (changed) {
|
|
348
367
|
(0, config_1.writeConfig)(cfg);
|
|
349
368
|
console.log('✅ Config saved to ~/.claudestat/config.json');
|
|
@@ -373,6 +392,7 @@ program
|
|
|
373
392
|
if (cfg.killSwitchEnabled) {
|
|
374
393
|
lines.push(` ${bar(cfg.killSwitchThreshold)}`);
|
|
375
394
|
}
|
|
395
|
+
lines.push(` Session limit ${cfg.sessionCostLimitUsd > 0 ? `${Y}$${cfg.sessionCostLimitUsd.toFixed(2)}${R}` : `${D}OFF${R}`}`);
|
|
376
396
|
lines.push('');
|
|
377
397
|
lines.push(` Cycle thresholds ${cfg.warnThresholds.join('%, ')}%`);
|
|
378
398
|
lines.push(` ${D}yellow${R} ${bar(cfg.warnThresholds[0], 8)} ${D}orange${R} ${bar(cfg.warnThresholds[1], 8)} ${D}red${R} ${bar(cfg.warnThresholds[2], 8)}`);
|
|
@@ -536,4 +556,25 @@ program
|
|
|
536
556
|
process.exit(1);
|
|
537
557
|
}
|
|
538
558
|
});
|
|
559
|
+
program
|
|
560
|
+
.command('project')
|
|
561
|
+
.description('Show cost projection with linear regression')
|
|
562
|
+
.option('--days <number>', 'Look back N days for data (default 90)')
|
|
563
|
+
.option('--json', 'Output raw JSON')
|
|
564
|
+
.action((opts) => {
|
|
565
|
+
try {
|
|
566
|
+
const days = Math.max(7, Math.min(365, parseInt(opts.days ?? '90', 10) || 90));
|
|
567
|
+
const p = (0, cost_projector_1.computeProjection)(days);
|
|
568
|
+
if (opts.json) {
|
|
569
|
+
console.log(JSON.stringify(p, null, 2));
|
|
570
|
+
process.exit(0);
|
|
571
|
+
}
|
|
572
|
+
console.log((0, cost_projector_1.formatProjection)(p));
|
|
573
|
+
process.exit(0);
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
console.error('\n❌ Error:', err.message);
|
|
577
|
+
process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
539
580
|
program.parse();
|
package/dist/insights.js
CHANGED
|
@@ -223,8 +223,6 @@ function renderWeeklyInsight(d) {
|
|
|
223
223
|
const R = '\x1b[0m';
|
|
224
224
|
const B = '\x1b[1m';
|
|
225
225
|
const D = '\x1b[2m';
|
|
226
|
-
const G = '\x1b[32m';
|
|
227
|
-
const Y = '\x1b[33m';
|
|
228
226
|
const C = '\x1b[36m';
|
|
229
227
|
const bar = (pct, width = 20) => {
|
|
230
228
|
const filled = Math.round(Math.min(pct, 100) / 100 * width);
|
package/dist/meta-stats.js
CHANGED
|
@@ -74,7 +74,7 @@ function fmtTok(n) {
|
|
|
74
74
|
* - {project}/AGENTS.md — instructions for agent mode
|
|
75
75
|
* - {project}/.claude/CLAUDE.md — alternative project-level location
|
|
76
76
|
*/
|
|
77
|
-
function resolveContextCandidates(
|
|
77
|
+
function resolveContextCandidates(_homeDir, projectCwd) {
|
|
78
78
|
const claudeDir = (0, paths_1.getClaudeDir)();
|
|
79
79
|
const candidates = [
|
|
80
80
|
{ label: 'CLAUDE.md (global)', filePath: path_1.default.join(claudeDir, 'CLAUDE.md') },
|
|
@@ -23,7 +23,7 @@ const rateLimitCleanupInterval = setInterval(() => {
|
|
|
23
23
|
const now = Date.now();
|
|
24
24
|
rateLimitMap.forEach((v, k) => { if (now - v.windowStart > RATE_LIMIT_WINDOW_MS)
|
|
25
25
|
rateLimitMap.delete(k); });
|
|
26
|
-
}, 5 * 60000);
|
|
26
|
+
}, 5 * 60000).unref();
|
|
27
27
|
function stopRateLimiter() {
|
|
28
28
|
clearInterval(rateLimitCleanupInterval);
|
|
29
29
|
rateLimitMap.clear();
|