@statforge/claudestat 1.6.1 → 1.8.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 +36 -3
- package/dashboard/dist/assets/AnalyticsView-DDGLDoCN.js +7 -0
- package/dashboard/dist/assets/HistoryView-DkPfrNrv.js +1 -0
- package/dashboard/dist/assets/LineChart-BOWYkkEW.js +2 -0
- package/dashboard/dist/assets/ProjectsView-VRoRiEL4.js +6 -0
- package/dashboard/dist/assets/SystemView-B2zbIxhY.js +1 -0
- package/dashboard/dist/assets/TopView-C2qdsy0Y.js +1 -0
- package/dashboard/dist/assets/index-CMhe3KaT.js +84 -0
- package/dashboard/dist/assets/shared-BbBtsdh1.js +1 -0
- package/dashboard/dist/assets/{vendor-lucide-Cym0q5l_.js → vendor-lucide-ClCW-axQ.js} +79 -64
- package/dashboard/dist/assets/{vendor-react-B_Jzs0gY.js → vendor-react-gHSHIE2L.js} +1 -1
- package/dashboard/dist/index.html +3 -3
- package/dist/config.d.ts +7 -0
- package/dist/config.js +36 -0
- package/dist/daemon.js +113 -9
- package/dist/db.d.ts +87 -2
- package/dist/db.js +325 -65
- package/dist/doctor.js +21 -3
- package/dist/enricher.d.ts +3 -2
- package/dist/enricher.js +10 -5
- package/dist/export.d.ts +2 -1
- package/dist/export.js +41 -6
- package/dist/index.js +406 -20
- package/dist/insights.d.ts +1 -0
- package/dist/insights.js +26 -0
- package/dist/install.js +28 -1
- package/dist/intelligence.d.ts +66 -4
- package/dist/intelligence.js +205 -17
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +49 -0
- package/dist/notifier.d.ts +15 -0
- package/dist/notifier.js +26 -0
- package/dist/paths.d.ts +23 -0
- package/dist/paths.js +42 -0
- package/dist/pricing.d.ts +2 -0
- package/dist/pricing.js +12 -1
- package/dist/routes/events.js +136 -5
- package/dist/routes/helpers.d.ts +5 -0
- package/dist/routes/helpers.js +21 -1
- package/dist/routes/history.js +6 -2
- package/dist/routes/intents.d.ts +1 -0
- package/dist/routes/intents.js +155 -0
- package/dist/routes/misc.js +150 -4
- package/dist/routes/opencode-reader.js +39 -3
- package/dist/routes/projects.js +19 -1
- package/dist/routes/replay.d.ts +1 -0
- package/dist/routes/replay.js +29 -0
- package/dist/routes/reports.js +7 -0
- package/dist/routes/top.js +8 -1
- package/dist/service.js +11 -0
- package/dist/watchers/adapter.d.ts +1 -0
- package/dist/watchers/claude-code.d.ts +16 -1
- package/dist/watchers/claude-code.js +201 -76
- package/dist/watchers/opencode.d.ts +1 -0
- package/dist/watchers/opencode.js +152 -14
- package/hooks/event.js +44 -26
- package/package.json +1 -1
- package/dashboard/dist/assets/AnalyticsView-5bUM3UHp.js +0 -8
- package/dashboard/dist/assets/HistoryView-C-AsEqos.js +0 -1
- package/dashboard/dist/assets/ProjectsView-D9bZBdY2.js +0 -6
- package/dashboard/dist/assets/SystemView-DIYDCCF3.js +0 -1
- package/dashboard/dist/assets/TopView-DhdLpsiA.js +0 -1
- package/dashboard/dist/assets/index-DgbWvj42.js +0 -84
package/dist/install.js
CHANGED
|
@@ -23,6 +23,7 @@ const readline_1 = __importDefault(require("readline"));
|
|
|
23
23
|
const child_process_1 = require("child_process");
|
|
24
24
|
const paths_1 = require("./paths");
|
|
25
25
|
const config_1 = require("./config");
|
|
26
|
+
const doctor_1 = require("./doctor");
|
|
26
27
|
const CLAUDESTAT_DIR = (0, paths_1.getClaudestatDir)();
|
|
27
28
|
const CLAUDE_SETTINGS = path_1.default.join((0, paths_1.getClaudeDir)(), 'settings.json');
|
|
28
29
|
const HOOKS_DIR = path_1.default.join(CLAUDESTAT_DIR, 'hooks');
|
|
@@ -143,9 +144,29 @@ async function runWizard() {
|
|
|
143
144
|
plan = planMap[input.trim()] ?? 'pro';
|
|
144
145
|
}
|
|
145
146
|
console.log(`✓ Plan: ${plan}`);
|
|
147
|
+
// Paso 3b: puerto
|
|
148
|
+
let port = 7337;
|
|
149
|
+
if (!nonInteractive) {
|
|
150
|
+
const portInput = await new Promise(resolve => rl.question(`Port for daemon [default: 7337]: `, resolve));
|
|
151
|
+
const parsed = parseInt(portInput.trim(), 10);
|
|
152
|
+
if (!isNaN(parsed) && parsed >= 1024 && parsed <= 65535) {
|
|
153
|
+
port = parsed;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
console.log(`✓ Port: ${port}`);
|
|
157
|
+
// Paso 3c: kill-switch threshold
|
|
158
|
+
let threshold = 95;
|
|
159
|
+
if (!nonInteractive) {
|
|
160
|
+
const tInput = await new Promise(resolve => rl.question(`Quota threshold for kill-switch warning [default: 95%]: `, resolve));
|
|
161
|
+
const parsed = parseInt(tInput.trim(), 10);
|
|
162
|
+
if (!isNaN(parsed) && parsed >= 50 && parsed <= 100) {
|
|
163
|
+
threshold = parsed;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
console.log(`✓ Kill-switch threshold: ${threshold}%`);
|
|
146
167
|
// Paso 4: crear config inicial
|
|
147
168
|
const cfg = (0, config_1.readConfig)();
|
|
148
|
-
(0, config_1.writeConfig)({ ...cfg, plan: plan });
|
|
169
|
+
(0, config_1.writeConfig)({ ...cfg, plan: plan, port, killSwitchThreshold: threshold });
|
|
149
170
|
console.log(`✓ Config created → ${CONFIG_PATH}\n`);
|
|
150
171
|
}
|
|
151
172
|
finally {
|
|
@@ -155,6 +176,12 @@ async function runWizard() {
|
|
|
155
176
|
installHooks();
|
|
156
177
|
// Paso 6: registrar MCP server en Claude Code
|
|
157
178
|
installMcp();
|
|
179
|
+
// Paso final: doctor
|
|
180
|
+
console.log('\n🩺 Running doctor to verify installation...\n');
|
|
181
|
+
try {
|
|
182
|
+
await (0, doctor_1.runDoctor)();
|
|
183
|
+
}
|
|
184
|
+
catch { }
|
|
158
185
|
}
|
|
159
186
|
function installMcp() {
|
|
160
187
|
const nodeExec = process.execPath;
|
package/dist/intelligence.d.ts
CHANGED
|
@@ -11,19 +11,30 @@ export interface LoopAlert {
|
|
|
11
11
|
count: number;
|
|
12
12
|
windowMs: number;
|
|
13
13
|
ts: number;
|
|
14
|
+
context: LoopContext;
|
|
15
|
+
}
|
|
16
|
+
export interface LoopContext {
|
|
17
|
+
repeatedFiles: string[];
|
|
18
|
+
repeatedCommands: string[];
|
|
19
|
+
estimatedCostUsd: number;
|
|
14
20
|
}
|
|
15
21
|
export interface IntelligenceReport {
|
|
16
22
|
loops: LoopAlert[];
|
|
17
23
|
efficiencyScore: number;
|
|
18
24
|
summary: string;
|
|
25
|
+
exactRetries: number;
|
|
26
|
+
errorRate: number;
|
|
27
|
+
fileChurnScore: number;
|
|
28
|
+
seqCycleCount: number;
|
|
19
29
|
}
|
|
20
30
|
/**
|
|
21
|
-
* Detecta loops: cuando el mismo tool se llama ≥
|
|
22
|
-
* dentro de
|
|
31
|
+
* Detecta loops: cuando el mismo tool se llama ≥ threshold veces
|
|
32
|
+
* dentro de windowMs. Evita alertas duplicadas con COOLDOWN_MS = windowMs.
|
|
23
33
|
*
|
|
24
34
|
* Algoritmo: ventana deslizante sobre eventos ordenados por timestamp.
|
|
35
|
+
* Captura contexto (archivos repetidos, comandos Bash repetidos) para cada alerta.
|
|
25
36
|
*/
|
|
26
|
-
export declare function detectLoops(events: EventRow[]): LoopAlert[];
|
|
37
|
+
export declare function detectLoops(events: EventRow[], threshold?: number, windowMs?: number): LoopAlert[];
|
|
27
38
|
/**
|
|
28
39
|
* Calcula un score de 0-100 basado en:
|
|
29
40
|
* - Loops detectados → -10 por loop, cap -25
|
|
@@ -39,7 +50,58 @@ export declare function detectLoops(events: EventRow[]): LoopAlert[];
|
|
|
39
50
|
* 20 tools, 0 loops, $0.30 → 100 - 0 - 0 - 0 = 100
|
|
40
51
|
*/
|
|
41
52
|
export declare function calcEfficiencyScore(events: EventRow[], loops: LoopAlert[], costUsd: number): number;
|
|
53
|
+
/**
|
|
54
|
+
* Counts exact duplicate tool calls: same tool_name + same tool_input.
|
|
55
|
+
* Only Done events with non-null tool_name are considered.
|
|
56
|
+
*/
|
|
57
|
+
export declare function detectExactRetries(events: EventRow[]): number;
|
|
58
|
+
/**
|
|
59
|
+
* Fraction of Done calls whose tool_response matches an error pattern.
|
|
60
|
+
* Returns 0 if there are no Done calls with a response.
|
|
61
|
+
*/
|
|
62
|
+
export declare function computeErrorRate(events: EventRow[]): number;
|
|
63
|
+
/**
|
|
64
|
+
* Diversity of file access: unique_files / total_file_calls.
|
|
65
|
+
* Returns 1.0 when there are no file tool calls (no penalty).
|
|
66
|
+
* Lower values indicate the agent is revisiting the same files repeatedly.
|
|
67
|
+
*/
|
|
68
|
+
export declare function computeFileChurn(events: EventRow[]): number;
|
|
69
|
+
/**
|
|
70
|
+
* Counts A→B→A triplets in the Done event sequence within a 5-minute window.
|
|
71
|
+
* Indicates the agent oscillates between two tools without making progress.
|
|
72
|
+
*/
|
|
73
|
+
export declare function detectSeqCycles(events: EventRow[]): number;
|
|
74
|
+
export interface SemanticLoop {
|
|
75
|
+
type: 'tool_sequence' | 'error_persistence';
|
|
76
|
+
turn_start: number;
|
|
77
|
+
count: number;
|
|
78
|
+
detail: string;
|
|
79
|
+
}
|
|
80
|
+
interface TurnLike {
|
|
81
|
+
turn_index: number;
|
|
82
|
+
tool_calls?: string[];
|
|
83
|
+
error_count: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Detects two kinds of semantic loops using assistant_turns data:
|
|
87
|
+
* - tool_sequence: same tool_calls signature repeated in ≥2 of the last 5 turns
|
|
88
|
+
* - error_persistence: ≥3 consecutive turns with error_count > 0
|
|
89
|
+
*/
|
|
90
|
+
export declare function analyzeSemanticLoops(turns: TurnLike[]): SemanticLoop[];
|
|
91
|
+
export interface SaturationPrediction {
|
|
92
|
+
minutesLeft: number;
|
|
93
|
+
pctPerMin: number;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Linear regression over context_used samples → predicts minutes until 90% of context_window.
|
|
97
|
+
* Returns null if insufficient data or trend is flat/negative.
|
|
98
|
+
*/
|
|
99
|
+
export declare function predictSaturation(samples: Array<{
|
|
100
|
+
ts: number;
|
|
101
|
+
context_used: number;
|
|
102
|
+
}>, contextWindow: number): SaturationPrediction | null;
|
|
42
103
|
/**
|
|
43
104
|
* Genera el reporte completo de inteligencia para una sesión.
|
|
44
105
|
*/
|
|
45
|
-
export declare function analyzeSession(events: EventRow[], costUsd: number): IntelligenceReport;
|
|
106
|
+
export declare function analyzeSession(events: EventRow[], costUsd: number, threshold?: number, windowMs?: number): IntelligenceReport;
|
|
107
|
+
export {};
|
package/dist/intelligence.js
CHANGED
|
@@ -9,37 +9,70 @@
|
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.detectLoops = detectLoops;
|
|
11
11
|
exports.calcEfficiencyScore = calcEfficiencyScore;
|
|
12
|
+
exports.detectExactRetries = detectExactRetries;
|
|
13
|
+
exports.computeErrorRate = computeErrorRate;
|
|
14
|
+
exports.computeFileChurn = computeFileChurn;
|
|
15
|
+
exports.detectSeqCycles = detectSeqCycles;
|
|
16
|
+
exports.analyzeSemanticLoops = analyzeSemanticLoops;
|
|
17
|
+
exports.predictSaturation = predictSaturation;
|
|
12
18
|
exports.analyzeSession = analyzeSession;
|
|
13
19
|
// ─── Detección de loops ───────────────────────────────────────────────────────
|
|
14
|
-
const
|
|
15
|
-
const LOOP_WINDOW_MS = 120000; // ventana de tiempo: 2 minutos (antes 60s → demasiados falsos positivos en coding)
|
|
16
|
-
const LOOP_COOLDOWN_MS = 120000; // cooldown entre alertas del mismo tool: 2 min (antes 15s → re-alertaba constantemente)
|
|
20
|
+
const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep']);
|
|
17
21
|
/**
|
|
18
|
-
* Detecta loops: cuando el mismo tool se llama ≥
|
|
19
|
-
* dentro de
|
|
22
|
+
* Detecta loops: cuando el mismo tool se llama ≥ threshold veces
|
|
23
|
+
* dentro de windowMs. Evita alertas duplicadas con COOLDOWN_MS = windowMs.
|
|
20
24
|
*
|
|
21
25
|
* Algoritmo: ventana deslizante sobre eventos ordenados por timestamp.
|
|
26
|
+
* Captura contexto (archivos repetidos, comandos Bash repetidos) para cada alerta.
|
|
22
27
|
*/
|
|
23
|
-
function detectLoops(events) {
|
|
28
|
+
function detectLoops(events, threshold = 8, windowMs = 120000) {
|
|
29
|
+
const COOLDOWN_MS = windowMs; // cooldown = mismo tamaño que la ventana
|
|
24
30
|
const alerts = [];
|
|
25
|
-
const windowsByTool = new Map();
|
|
31
|
+
const windowsByTool = new Map();
|
|
26
32
|
for (const ev of events) {
|
|
27
|
-
// Solo contamos tool calls (PreToolUse o Done — uno de los dos)
|
|
28
33
|
if (ev.type !== 'Done' && ev.type !== 'PreToolUse')
|
|
29
34
|
continue;
|
|
30
35
|
if (!ev.tool_name)
|
|
31
36
|
continue;
|
|
32
37
|
const toolName = ev.tool_name;
|
|
33
38
|
const ts = ev.ts;
|
|
34
|
-
|
|
35
|
-
const window = (windowsByTool.get(toolName) || []).filter(t => t >= ts - LOOP_WINDOW_MS);
|
|
39
|
+
const window = (windowsByTool.get(toolName) || []).filter(t => t >= ts - windowMs);
|
|
36
40
|
window.push(ts);
|
|
37
41
|
windowsByTool.set(toolName, window);
|
|
38
|
-
if (window.length >=
|
|
39
|
-
// Verificar cooldown: no alertar si ya alertamos recientemente para este tool
|
|
42
|
+
if (window.length >= threshold) {
|
|
40
43
|
const lastAlert = [...alerts].reverse().find(a => a.toolName === toolName);
|
|
41
|
-
if (!lastAlert || ts - lastAlert.ts >=
|
|
42
|
-
|
|
44
|
+
if (!lastAlert || ts - lastAlert.ts >= COOLDOWN_MS) {
|
|
45
|
+
const windowStart = ts - windowMs;
|
|
46
|
+
const loopEvents = events.filter(e => e.ts >= windowStart && e.ts <= ts && e.type === 'Done');
|
|
47
|
+
const repeatedFiles = new Set();
|
|
48
|
+
const repeatedCommands = new Set();
|
|
49
|
+
for (const le of loopEvents) {
|
|
50
|
+
if (!le.tool_input)
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
const inp = JSON.parse(le.tool_input);
|
|
54
|
+
if (FILE_TOOLS.has(le.tool_name ?? '')) {
|
|
55
|
+
const fp = inp.file_path ?? inp.path ?? inp.pattern;
|
|
56
|
+
if (typeof fp === 'string')
|
|
57
|
+
repeatedFiles.add(fp);
|
|
58
|
+
}
|
|
59
|
+
if (le.tool_name === 'Bash' && typeof inp.command === 'string') {
|
|
60
|
+
repeatedCommands.add(inp.command.slice(0, 80));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
}
|
|
65
|
+
alerts.push({
|
|
66
|
+
toolName,
|
|
67
|
+
count: window.length,
|
|
68
|
+
windowMs,
|
|
69
|
+
ts,
|
|
70
|
+
context: {
|
|
71
|
+
repeatedFiles: [...repeatedFiles].slice(0, 10),
|
|
72
|
+
repeatedCommands: [...repeatedCommands].slice(0, 5),
|
|
73
|
+
estimatedCostUsd: 0,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
43
76
|
}
|
|
44
77
|
}
|
|
45
78
|
}
|
|
@@ -78,14 +111,169 @@ function calcEfficiencyScore(events, loops, costUsd) {
|
|
|
78
111
|
score -= 5;
|
|
79
112
|
return Math.max(0, Math.min(100, score));
|
|
80
113
|
}
|
|
114
|
+
// ─── Semantic metrics ─────────────────────────────────────────────────────────
|
|
115
|
+
const ERROR_PATTERN = /error:|ENOENT|EACCES|EPERM|permission denied|failed|No such file/i;
|
|
116
|
+
/**
|
|
117
|
+
* Counts exact duplicate tool calls: same tool_name + same tool_input.
|
|
118
|
+
* Only Done events with non-null tool_name are considered.
|
|
119
|
+
*/
|
|
120
|
+
function detectExactRetries(events) {
|
|
121
|
+
const seen = new Map();
|
|
122
|
+
for (const e of events) {
|
|
123
|
+
if (e.type !== 'Done' || !e.tool_name)
|
|
124
|
+
continue;
|
|
125
|
+
const key = `${e.tool_name}:${e.tool_input ?? ''}`;
|
|
126
|
+
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
127
|
+
}
|
|
128
|
+
let retries = 0;
|
|
129
|
+
for (const count of seen.values()) {
|
|
130
|
+
if (count > 1)
|
|
131
|
+
retries += count - 1;
|
|
132
|
+
}
|
|
133
|
+
return retries;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Fraction of Done calls whose tool_response matches an error pattern.
|
|
137
|
+
* Returns 0 if there are no Done calls with a response.
|
|
138
|
+
*/
|
|
139
|
+
function computeErrorRate(events) {
|
|
140
|
+
const done = events.filter(e => e.type === 'Done' && e.tool_response != null);
|
|
141
|
+
if (done.length === 0)
|
|
142
|
+
return 0;
|
|
143
|
+
const errored = done.filter(e => ERROR_PATTERN.test(e.tool_response));
|
|
144
|
+
return errored.length / done.length;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Diversity of file access: unique_files / total_file_calls.
|
|
148
|
+
* Returns 1.0 when there are no file tool calls (no penalty).
|
|
149
|
+
* Lower values indicate the agent is revisiting the same files repeatedly.
|
|
150
|
+
*/
|
|
151
|
+
function computeFileChurn(events) {
|
|
152
|
+
const fileCalls = events.filter(e => e.type === 'Done' && FILE_TOOLS.has(e.tool_name ?? ''));
|
|
153
|
+
if (fileCalls.length === 0)
|
|
154
|
+
return 1.0;
|
|
155
|
+
const uniquePaths = new Set();
|
|
156
|
+
for (const e of fileCalls) {
|
|
157
|
+
if (!e.tool_input)
|
|
158
|
+
continue;
|
|
159
|
+
try {
|
|
160
|
+
const inp = JSON.parse(e.tool_input);
|
|
161
|
+
const fp = inp.file_path ?? inp.path ?? inp.pattern;
|
|
162
|
+
if (typeof fp === 'string')
|
|
163
|
+
uniquePaths.add(fp);
|
|
164
|
+
}
|
|
165
|
+
catch { /* malformed input */ }
|
|
166
|
+
}
|
|
167
|
+
return uniquePaths.size / fileCalls.length;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Counts A→B→A triplets in the Done event sequence within a 5-minute window.
|
|
171
|
+
* Indicates the agent oscillates between two tools without making progress.
|
|
172
|
+
*/
|
|
173
|
+
function detectSeqCycles(events) {
|
|
174
|
+
const SEQ_WINDOW_MS = 5 * 60000;
|
|
175
|
+
const done = events.filter(e => e.type === 'Done' && e.tool_name);
|
|
176
|
+
let cycles = 0;
|
|
177
|
+
for (let i = 2; i < done.length; i++) {
|
|
178
|
+
if (done[i].tool_name === done[i - 2].tool_name &&
|
|
179
|
+
done[i].ts - done[i - 2].ts <= SEQ_WINDOW_MS) {
|
|
180
|
+
cycles++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return cycles;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Detects two kinds of semantic loops using assistant_turns data:
|
|
187
|
+
* - tool_sequence: same tool_calls signature repeated in ≥2 of the last 5 turns
|
|
188
|
+
* - error_persistence: ≥3 consecutive turns with error_count > 0
|
|
189
|
+
*/
|
|
190
|
+
function analyzeSemanticLoops(turns) {
|
|
191
|
+
const loops = [];
|
|
192
|
+
// ── error_persistence ────────────────────────────────────────────────────────
|
|
193
|
+
const ERROR_RUN_MIN = 3;
|
|
194
|
+
let runStart = -1;
|
|
195
|
+
let runLen = 0;
|
|
196
|
+
for (let i = 0; i < turns.length; i++) {
|
|
197
|
+
if (turns[i].error_count > 0) {
|
|
198
|
+
if (runLen === 0)
|
|
199
|
+
runStart = turns[i].turn_index;
|
|
200
|
+
runLen++;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
if (runLen >= ERROR_RUN_MIN) {
|
|
204
|
+
loops.push({ type: 'error_persistence', turn_start: runStart, count: runLen, detail: `${runLen} consecutive error turns` });
|
|
205
|
+
}
|
|
206
|
+
runLen = 0;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (runLen >= ERROR_RUN_MIN) {
|
|
210
|
+
loops.push({ type: 'error_persistence', turn_start: runStart, count: runLen, detail: `${runLen} consecutive error turns` });
|
|
211
|
+
}
|
|
212
|
+
// ── tool_sequence: same tool_calls fingerprint in ≥2 of last 5 turns ────────
|
|
213
|
+
const WINDOW = 5;
|
|
214
|
+
const fingerprint = (t) => (t.tool_calls ?? []).join(',');
|
|
215
|
+
for (let i = 1; i < turns.length; i++) {
|
|
216
|
+
const fp = fingerprint(turns[i]);
|
|
217
|
+
if (!fp)
|
|
218
|
+
continue;
|
|
219
|
+
const window = turns.slice(Math.max(0, i - WINDOW + 1), i);
|
|
220
|
+
const matches = window.filter(t => fingerprint(t) === fp);
|
|
221
|
+
if (matches.length >= 1) {
|
|
222
|
+
const alreadyLogged = loops.some(l => l.type === 'tool_sequence' && l.turn_start === turns[i - matches.length].turn_index);
|
|
223
|
+
if (!alreadyLogged) {
|
|
224
|
+
const label = (turns[i].tool_calls ?? []).join('→') || 'empty';
|
|
225
|
+
loops.push({
|
|
226
|
+
type: 'tool_sequence',
|
|
227
|
+
turn_start: turns[i - matches.length].turn_index,
|
|
228
|
+
count: matches.length + 1,
|
|
229
|
+
detail: `${label} ×${matches.length + 1}`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return loops;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Linear regression over context_used samples → predicts minutes until 90% of context_window.
|
|
238
|
+
* Returns null if insufficient data or trend is flat/negative.
|
|
239
|
+
*/
|
|
240
|
+
function predictSaturation(samples, contextWindow) {
|
|
241
|
+
if (samples.length < 3 || contextWindow <= 0)
|
|
242
|
+
return null;
|
|
243
|
+
const n = samples.length;
|
|
244
|
+
const t0 = samples[0].ts;
|
|
245
|
+
const xs = samples.map(s => (s.ts - t0) / 60000); // minutes from first sample
|
|
246
|
+
const ys = samples.map(s => s.context_used / contextWindow); // 0-1 fraction
|
|
247
|
+
const sumX = xs.reduce((a, b) => a + b, 0);
|
|
248
|
+
const sumY = ys.reduce((a, b) => a + b, 0);
|
|
249
|
+
const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0);
|
|
250
|
+
const sumX2 = xs.reduce((a, x) => a + x * x, 0);
|
|
251
|
+
const denom = n * sumX2 - sumX * sumX;
|
|
252
|
+
if (Math.abs(denom) < 1e-9)
|
|
253
|
+
return null;
|
|
254
|
+
const slope = (n * sumXY - sumX * sumY) / denom; // fraction per minute
|
|
255
|
+
const intercept = (sumY - slope * sumX) / n;
|
|
256
|
+
if (slope <= 0)
|
|
257
|
+
return null; // context shrinking or flat → no saturation risk
|
|
258
|
+
const target = 0.9;
|
|
259
|
+
const currentX = xs[n - 1];
|
|
260
|
+
const minutesLeft = (target - (intercept + slope * currentX)) / slope;
|
|
261
|
+
if (minutesLeft < 0)
|
|
262
|
+
return null; // already past target
|
|
263
|
+
return { minutesLeft: Math.round(minutesLeft), pctPerMin: Math.round(slope * 100 * 10) / 10 };
|
|
264
|
+
}
|
|
81
265
|
/**
|
|
82
266
|
* Genera el reporte completo de inteligencia para una sesión.
|
|
83
267
|
*/
|
|
84
|
-
function analyzeSession(events, costUsd) {
|
|
85
|
-
const loops = detectLoops(events);
|
|
268
|
+
function analyzeSession(events, costUsd, threshold, windowMs) {
|
|
269
|
+
const loops = detectLoops(events, threshold, windowMs);
|
|
86
270
|
const efficiencyScore = calcEfficiencyScore(events, loops, costUsd);
|
|
271
|
+
const exactRetries = detectExactRetries(events);
|
|
272
|
+
const errorRate = computeErrorRate(events);
|
|
273
|
+
const fileChurnScore = computeFileChurn(events);
|
|
274
|
+
const seqCycleCount = detectSeqCycles(events);
|
|
87
275
|
const summary = buildSummary(loops, efficiencyScore, costUsd, events);
|
|
88
|
-
return { loops, efficiencyScore, summary };
|
|
276
|
+
return { loops, efficiencyScore, summary, exactRetries, errorRate, fileChurnScore, seqCycleCount };
|
|
89
277
|
}
|
|
90
278
|
function buildSummary(loops, score, costUsd, events) {
|
|
91
279
|
const parts = [];
|
package/dist/logger.d.ts
ADDED
package/dist/logger.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.logger = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const paths_1 = require("./paths");
|
|
9
|
+
const config_1 = require("./config");
|
|
10
|
+
const LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
11
|
+
const MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
|
12
|
+
const MAX_FILES = 3;
|
|
13
|
+
function rotate(logFile) {
|
|
14
|
+
for (let i = MAX_FILES - 2; i >= 0; i--) {
|
|
15
|
+
const src = i === 0 ? logFile : `${logFile}.${i}`;
|
|
16
|
+
const dst = `${logFile}.${i + 1}`;
|
|
17
|
+
try {
|
|
18
|
+
if (fs_1.default.existsSync(src))
|
|
19
|
+
fs_1.default.renameSync(src, dst);
|
|
20
|
+
}
|
|
21
|
+
catch { }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function write(level, message) {
|
|
25
|
+
try {
|
|
26
|
+
const cfg = (0, config_1.readConfig)();
|
|
27
|
+
const minRank = LEVEL_RANK[cfg.logLevel] ?? 1;
|
|
28
|
+
if ((LEVEL_RANK[level] ?? 0) < minRank)
|
|
29
|
+
return;
|
|
30
|
+
const logFile = (0, paths_1.getDaemonLogFile)();
|
|
31
|
+
fs_1.default.mkdirSync((0, paths_1.getClaudestatDir)(), { recursive: true });
|
|
32
|
+
try {
|
|
33
|
+
const stat = fs_1.default.statSync(logFile);
|
|
34
|
+
if (stat.size >= MAX_SIZE_BYTES)
|
|
35
|
+
rotate(logFile);
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
const ts = new Date().toISOString();
|
|
39
|
+
const line = `${ts} [${level.toUpperCase()}] ${message}\n`;
|
|
40
|
+
fs_1.default.appendFileSync(logFile, line);
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
}
|
|
44
|
+
exports.logger = {
|
|
45
|
+
debug: (msg) => write('debug', msg),
|
|
46
|
+
info: (msg) => write('info', msg),
|
|
47
|
+
warn: (msg) => write('warn', msg),
|
|
48
|
+
error: (msg) => write('error', msg),
|
|
49
|
+
};
|
package/dist/notifier.d.ts
CHANGED
|
@@ -1 +1,16 @@
|
|
|
1
1
|
export declare function sendDesktopNotification(title: string, body: string): void;
|
|
2
|
+
export interface AlertPayload {
|
|
3
|
+
title: string;
|
|
4
|
+
body: string;
|
|
5
|
+
level: 'yellow' | 'orange' | 'red';
|
|
6
|
+
cyclePct: number;
|
|
7
|
+
weeklyPct?: number;
|
|
8
|
+
burnRate?: number;
|
|
9
|
+
resetInMins: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Sends an alert to a webhook URL (Slack, Discord, n8n, etc.).
|
|
13
|
+
* Payload format is compatible with Slack incoming webhooks and Discord webhooks.
|
|
14
|
+
* Fire-and-forget: errors are silently ignored.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sendWebhookAlert(url: string, payload: AlertPayload): void;
|
package/dist/notifier.js
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.sendDesktopNotification = sendDesktopNotification;
|
|
7
|
+
exports.sendWebhookAlert = sendWebhookAlert;
|
|
7
8
|
const child_process_1 = require("child_process");
|
|
8
9
|
const os_1 = __importDefault(require("os"));
|
|
9
10
|
function sendDesktopNotification(title, body) {
|
|
@@ -38,3 +39,28 @@ function sendDesktopNotification(title, body) {
|
|
|
38
39
|
// notification unavailable — silent fallback
|
|
39
40
|
}
|
|
40
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Sends an alert to a webhook URL (Slack, Discord, n8n, etc.).
|
|
44
|
+
* Payload format is compatible with Slack incoming webhooks and Discord webhooks.
|
|
45
|
+
* Fire-and-forget: errors are silently ignored.
|
|
46
|
+
*/
|
|
47
|
+
function sendWebhookAlert(url, payload) {
|
|
48
|
+
const emoji = payload.level === 'red' ? '🔴' : payload.level === 'orange' ? '🟠' : '🟡';
|
|
49
|
+
const body = JSON.stringify({
|
|
50
|
+
text: `${emoji} *${payload.title}*\n${payload.body}`,
|
|
51
|
+
attachments: [{
|
|
52
|
+
color: payload.level === 'red' ? '#ff0000' : payload.level === 'orange' ? '#ff8800' : '#ffcc00',
|
|
53
|
+
fields: [
|
|
54
|
+
{ title: '5h cycle', value: `${payload.cyclePct}%`, short: true },
|
|
55
|
+
{ title: 'Resets in', value: `${payload.resetInMins}m`, short: true },
|
|
56
|
+
...(payload.burnRate ? [{ title: 'Burn rate', value: `${payload.burnRate.toLocaleString()} tok/min`, short: true }] : []),
|
|
57
|
+
],
|
|
58
|
+
}],
|
|
59
|
+
});
|
|
60
|
+
fetch(url, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body,
|
|
64
|
+
signal: AbortSignal.timeout(5000),
|
|
65
|
+
}).catch(() => { });
|
|
66
|
+
}
|
package/dist/paths.d.ts
CHANGED
|
@@ -72,6 +72,11 @@ export declare function homeSlugRegex(): RegExp;
|
|
|
72
72
|
* Windows: C:\Users\db → C--Users-db
|
|
73
73
|
*/
|
|
74
74
|
export declare function getHomeSlug(): string;
|
|
75
|
+
/**
|
|
76
|
+
* Returns the OpenCode config directory.
|
|
77
|
+
* Can be overridden via OPENCODE_CONFIG_DIR env var.
|
|
78
|
+
*/
|
|
79
|
+
export declare function getOpencodeDir(): string;
|
|
75
80
|
/**
|
|
76
81
|
* Returns the OpenCode SQLite database path.
|
|
77
82
|
* Can be overridden via OPENCODE_DB env var.
|
|
@@ -95,6 +100,24 @@ export declare function whichAllCmd(name: string): string;
|
|
|
95
100
|
* Windows: netstat -ano | findstr :<port>
|
|
96
101
|
*/
|
|
97
102
|
export declare function portCheckCmd(port: number): string;
|
|
103
|
+
/**
|
|
104
|
+
* Returns the port file path. The daemon writes the active port here on startup
|
|
105
|
+
* so the hook script (vanilla JS, no imports) can read it without parsing config.
|
|
106
|
+
*/
|
|
107
|
+
export declare function getPortFile(): string;
|
|
108
|
+
/**
|
|
109
|
+
* Writes the active port to disk. Called by the daemon after successful bind.
|
|
110
|
+
*/
|
|
111
|
+
export declare function writePortFile(port: number): void;
|
|
112
|
+
/**
|
|
113
|
+
* Returns the path of the pause signal file.
|
|
114
|
+
* When this file exists, the hook shows a warning instead of blocking.
|
|
115
|
+
*/
|
|
116
|
+
export declare function getPauseSignalFile(): string;
|
|
117
|
+
/**
|
|
118
|
+
* Returns the daemon log file path.
|
|
119
|
+
*/
|
|
120
|
+
export declare function getDaemonLogFile(): string;
|
|
98
121
|
/**
|
|
99
122
|
* Returns true if running on Windows.
|
|
100
123
|
*/
|
package/dist/paths.js
CHANGED
|
@@ -26,10 +26,15 @@ exports.encodeClaudePath = encodeClaudePath;
|
|
|
26
26
|
exports.decodeClaudePath = decodeClaudePath;
|
|
27
27
|
exports.homeSlugRegex = homeSlugRegex;
|
|
28
28
|
exports.getHomeSlug = getHomeSlug;
|
|
29
|
+
exports.getOpencodeDir = getOpencodeDir;
|
|
29
30
|
exports.getOpencodeDb = getOpencodeDb;
|
|
30
31
|
exports.whichCmd = whichCmd;
|
|
31
32
|
exports.whichAllCmd = whichAllCmd;
|
|
32
33
|
exports.portCheckCmd = portCheckCmd;
|
|
34
|
+
exports.getPortFile = getPortFile;
|
|
35
|
+
exports.writePortFile = writePortFile;
|
|
36
|
+
exports.getPauseSignalFile = getPauseSignalFile;
|
|
37
|
+
exports.getDaemonLogFile = getDaemonLogFile;
|
|
33
38
|
const os_1 = __importDefault(require("os"));
|
|
34
39
|
const path_1 = __importDefault(require("path"));
|
|
35
40
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -147,6 +152,13 @@ function getHomeSlug() {
|
|
|
147
152
|
return encodeClaudePath(os_1.default.homedir());
|
|
148
153
|
}
|
|
149
154
|
// ─── OpenCode data directory ───────────────────────────────────────────────────
|
|
155
|
+
/**
|
|
156
|
+
* Returns the OpenCode config directory.
|
|
157
|
+
* Can be overridden via OPENCODE_CONFIG_DIR env var.
|
|
158
|
+
*/
|
|
159
|
+
function getOpencodeDir() {
|
|
160
|
+
return process.env.OPENCODE_CONFIG_DIR ?? path_1.default.join(os_1.default.homedir(), '.config', 'opencode');
|
|
161
|
+
}
|
|
150
162
|
/**
|
|
151
163
|
* Returns the OpenCode SQLite database path.
|
|
152
164
|
* Can be overridden via OPENCODE_DB env var.
|
|
@@ -181,6 +193,36 @@ function portCheckCmd(port) {
|
|
|
181
193
|
? `netstat -ano | findstr :${port}`
|
|
182
194
|
: `lsof -i :${port}`;
|
|
183
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Returns the port file path. The daemon writes the active port here on startup
|
|
198
|
+
* so the hook script (vanilla JS, no imports) can read it without parsing config.
|
|
199
|
+
*/
|
|
200
|
+
function getPortFile() {
|
|
201
|
+
return path_1.default.join(getClaudestatDir(), 'port');
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Writes the active port to disk. Called by the daemon after successful bind.
|
|
205
|
+
*/
|
|
206
|
+
function writePortFile(port) {
|
|
207
|
+
try {
|
|
208
|
+
fs_1.default.mkdirSync(getClaudestatDir(), { recursive: true });
|
|
209
|
+
fs_1.default.writeFileSync(getPortFile(), String(port));
|
|
210
|
+
}
|
|
211
|
+
catch { }
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Returns the path of the pause signal file.
|
|
215
|
+
* When this file exists, the hook shows a warning instead of blocking.
|
|
216
|
+
*/
|
|
217
|
+
function getPauseSignalFile() {
|
|
218
|
+
return path_1.default.join(getClaudestatDir(), 'pause.signal');
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Returns the daemon log file path.
|
|
222
|
+
*/
|
|
223
|
+
function getDaemonLogFile() {
|
|
224
|
+
return path_1.default.join(getClaudestatDir(), 'daemon.log');
|
|
225
|
+
}
|
|
184
226
|
/**
|
|
185
227
|
* Returns true if running on Windows.
|
|
186
228
|
*/
|
package/dist/pricing.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface ModelPricing {
|
|
|
12
12
|
}
|
|
13
13
|
export declare const PRICING: Record<string, ModelPricing>;
|
|
14
14
|
export declare const DEFAULT_PRICING: ModelPricing;
|
|
15
|
+
export declare const KNOWN_CONTEXT_WINDOWS: Record<string, number>;
|
|
16
|
+
export declare function getContextWindow(model: string): number;
|
|
15
17
|
export declare function calcCost(model: string, usage: {
|
|
16
18
|
input_tokens: number;
|
|
17
19
|
output_tokens: number;
|
package/dist/pricing.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Prices are in USD per million tokens.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.DEFAULT_PRICING = exports.PRICING = void 0;
|
|
9
|
+
exports.KNOWN_CONTEXT_WINDOWS = exports.DEFAULT_PRICING = exports.PRICING = void 0;
|
|
10
|
+
exports.getContextWindow = getContextWindow;
|
|
10
11
|
exports.calcCost = calcCost;
|
|
11
12
|
exports.PRICING = {
|
|
12
13
|
'claude-opus-4-6': { input: 15, output: 75, cacheRead: 1.50, cacheCreate: 18.75 },
|
|
@@ -15,6 +16,16 @@ exports.PRICING = {
|
|
|
15
16
|
'claude-haiku-4-5-20251001': { input: 0.80, output: 4, cacheRead: 0.08, cacheCreate: 1.00 },
|
|
16
17
|
};
|
|
17
18
|
exports.DEFAULT_PRICING = exports.PRICING['claude-sonnet-4-6'];
|
|
19
|
+
exports.KNOWN_CONTEXT_WINDOWS = {
|
|
20
|
+
'claude-opus-4-6': 200000,
|
|
21
|
+
'claude-sonnet-4-6': 200000,
|
|
22
|
+
'claude-haiku-4-5': 200000,
|
|
23
|
+
'claude-haiku-4-5-20251001': 200000,
|
|
24
|
+
'deepseek-v4-flash-free': 1000000,
|
|
25
|
+
};
|
|
26
|
+
function getContextWindow(model) {
|
|
27
|
+
return exports.KNOWN_CONTEXT_WINDOWS[model] ?? 200000;
|
|
28
|
+
}
|
|
18
29
|
function calcCost(model, usage) {
|
|
19
30
|
const price = exports.PRICING[model] ?? exports.DEFAULT_PRICING;
|
|
20
31
|
const M = 1000000;
|