@statforge/claudestat 1.6.1 → 1.7.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 +3 -1
- 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/daemon.js +57 -1
- package/dist/db.d.ts +76 -2
- package/dist/db.js +295 -65
- package/dist/doctor.js +1 -1
- package/dist/enricher.d.ts +3 -2
- package/dist/enricher.js +10 -5
- package/dist/index.js +12 -1
- package/dist/intelligence.d.ts +55 -0
- package/dist/intelligence.js +163 -1
- package/dist/paths.d.ts +5 -0
- package/dist/paths.js +8 -0
- package/dist/pricing.d.ts +2 -0
- package/dist/pricing.js +12 -1
- package/dist/routes/events.js +136 -5
- 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 +131 -3
- package/dist/routes/opencode-reader.js +39 -3
- package/dist/routes/projects.js +10 -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/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/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/intelligence.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ export interface IntelligenceReport {
|
|
|
16
16
|
loops: LoopAlert[];
|
|
17
17
|
efficiencyScore: number;
|
|
18
18
|
summary: string;
|
|
19
|
+
exactRetries: number;
|
|
20
|
+
errorRate: number;
|
|
21
|
+
fileChurnScore: number;
|
|
22
|
+
seqCycleCount: number;
|
|
19
23
|
}
|
|
20
24
|
/**
|
|
21
25
|
* Detecta loops: cuando el mismo tool se llama ≥ LOOP_THRESHOLD veces
|
|
@@ -39,7 +43,58 @@ export declare function detectLoops(events: EventRow[]): LoopAlert[];
|
|
|
39
43
|
* 20 tools, 0 loops, $0.30 → 100 - 0 - 0 - 0 = 100
|
|
40
44
|
*/
|
|
41
45
|
export declare function calcEfficiencyScore(events: EventRow[], loops: LoopAlert[], costUsd: number): number;
|
|
46
|
+
/**
|
|
47
|
+
* Counts exact duplicate tool calls: same tool_name + same tool_input.
|
|
48
|
+
* Only Done events with non-null tool_name are considered.
|
|
49
|
+
*/
|
|
50
|
+
export declare function detectExactRetries(events: EventRow[]): number;
|
|
51
|
+
/**
|
|
52
|
+
* Fraction of Done calls whose tool_response matches an error pattern.
|
|
53
|
+
* Returns 0 if there are no Done calls with a response.
|
|
54
|
+
*/
|
|
55
|
+
export declare function computeErrorRate(events: EventRow[]): number;
|
|
56
|
+
/**
|
|
57
|
+
* Diversity of file access: unique_files / total_file_calls.
|
|
58
|
+
* Returns 1.0 when there are no file tool calls (no penalty).
|
|
59
|
+
* Lower values indicate the agent is revisiting the same files repeatedly.
|
|
60
|
+
*/
|
|
61
|
+
export declare function computeFileChurn(events: EventRow[]): number;
|
|
62
|
+
/**
|
|
63
|
+
* Counts A→B→A triplets in the Done event sequence within a 5-minute window.
|
|
64
|
+
* Indicates the agent oscillates between two tools without making progress.
|
|
65
|
+
*/
|
|
66
|
+
export declare function detectSeqCycles(events: EventRow[]): number;
|
|
67
|
+
export interface SemanticLoop {
|
|
68
|
+
type: 'tool_sequence' | 'error_persistence';
|
|
69
|
+
turn_start: number;
|
|
70
|
+
count: number;
|
|
71
|
+
detail: string;
|
|
72
|
+
}
|
|
73
|
+
interface TurnLike {
|
|
74
|
+
turn_index: number;
|
|
75
|
+
tool_calls?: string[];
|
|
76
|
+
error_count: number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Detects two kinds of semantic loops using assistant_turns data:
|
|
80
|
+
* - tool_sequence: same tool_calls signature repeated in ≥2 of the last 5 turns
|
|
81
|
+
* - error_persistence: ≥3 consecutive turns with error_count > 0
|
|
82
|
+
*/
|
|
83
|
+
export declare function analyzeSemanticLoops(turns: TurnLike[]): SemanticLoop[];
|
|
84
|
+
export interface SaturationPrediction {
|
|
85
|
+
minutesLeft: number;
|
|
86
|
+
pctPerMin: number;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Linear regression over context_used samples → predicts minutes until 90% of context_window.
|
|
90
|
+
* Returns null if insufficient data or trend is flat/negative.
|
|
91
|
+
*/
|
|
92
|
+
export declare function predictSaturation(samples: Array<{
|
|
93
|
+
ts: number;
|
|
94
|
+
context_used: number;
|
|
95
|
+
}>, contextWindow: number): SaturationPrediction | null;
|
|
42
96
|
/**
|
|
43
97
|
* Genera el reporte completo de inteligencia para una sesión.
|
|
44
98
|
*/
|
|
45
99
|
export declare function analyzeSession(events: EventRow[], costUsd: number): IntelligenceReport;
|
|
100
|
+
export {};
|
package/dist/intelligence.js
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
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
20
|
const LOOP_THRESHOLD = 8; // calls para considerar loop
|
|
@@ -78,14 +84,170 @@ function calcEfficiencyScore(events, loops, costUsd) {
|
|
|
78
84
|
score -= 5;
|
|
79
85
|
return Math.max(0, Math.min(100, score));
|
|
80
86
|
}
|
|
87
|
+
// ─── Semantic metrics ─────────────────────────────────────────────────────────
|
|
88
|
+
const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep']);
|
|
89
|
+
const ERROR_PATTERN = /error:|ENOENT|EACCES|EPERM|permission denied|failed|No such file/i;
|
|
90
|
+
/**
|
|
91
|
+
* Counts exact duplicate tool calls: same tool_name + same tool_input.
|
|
92
|
+
* Only Done events with non-null tool_name are considered.
|
|
93
|
+
*/
|
|
94
|
+
function detectExactRetries(events) {
|
|
95
|
+
const seen = new Map();
|
|
96
|
+
for (const e of events) {
|
|
97
|
+
if (e.type !== 'Done' || !e.tool_name)
|
|
98
|
+
continue;
|
|
99
|
+
const key = `${e.tool_name}:${e.tool_input ?? ''}`;
|
|
100
|
+
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
101
|
+
}
|
|
102
|
+
let retries = 0;
|
|
103
|
+
for (const count of seen.values()) {
|
|
104
|
+
if (count > 1)
|
|
105
|
+
retries += count - 1;
|
|
106
|
+
}
|
|
107
|
+
return retries;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Fraction of Done calls whose tool_response matches an error pattern.
|
|
111
|
+
* Returns 0 if there are no Done calls with a response.
|
|
112
|
+
*/
|
|
113
|
+
function computeErrorRate(events) {
|
|
114
|
+
const done = events.filter(e => e.type === 'Done' && e.tool_response != null);
|
|
115
|
+
if (done.length === 0)
|
|
116
|
+
return 0;
|
|
117
|
+
const errored = done.filter(e => ERROR_PATTERN.test(e.tool_response));
|
|
118
|
+
return errored.length / done.length;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Diversity of file access: unique_files / total_file_calls.
|
|
122
|
+
* Returns 1.0 when there are no file tool calls (no penalty).
|
|
123
|
+
* Lower values indicate the agent is revisiting the same files repeatedly.
|
|
124
|
+
*/
|
|
125
|
+
function computeFileChurn(events) {
|
|
126
|
+
const fileCalls = events.filter(e => e.type === 'Done' && FILE_TOOLS.has(e.tool_name ?? ''));
|
|
127
|
+
if (fileCalls.length === 0)
|
|
128
|
+
return 1.0;
|
|
129
|
+
const uniquePaths = new Set();
|
|
130
|
+
for (const e of fileCalls) {
|
|
131
|
+
if (!e.tool_input)
|
|
132
|
+
continue;
|
|
133
|
+
try {
|
|
134
|
+
const inp = JSON.parse(e.tool_input);
|
|
135
|
+
const fp = inp.file_path ?? inp.path ?? inp.pattern;
|
|
136
|
+
if (typeof fp === 'string')
|
|
137
|
+
uniquePaths.add(fp);
|
|
138
|
+
}
|
|
139
|
+
catch { /* malformed input */ }
|
|
140
|
+
}
|
|
141
|
+
return uniquePaths.size / fileCalls.length;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Counts A→B→A triplets in the Done event sequence within a 5-minute window.
|
|
145
|
+
* Indicates the agent oscillates between two tools without making progress.
|
|
146
|
+
*/
|
|
147
|
+
function detectSeqCycles(events) {
|
|
148
|
+
const SEQ_WINDOW_MS = 5 * 60000;
|
|
149
|
+
const done = events.filter(e => e.type === 'Done' && e.tool_name);
|
|
150
|
+
let cycles = 0;
|
|
151
|
+
for (let i = 2; i < done.length; i++) {
|
|
152
|
+
if (done[i].tool_name === done[i - 2].tool_name &&
|
|
153
|
+
done[i].ts - done[i - 2].ts <= SEQ_WINDOW_MS) {
|
|
154
|
+
cycles++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return cycles;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Detects two kinds of semantic loops using assistant_turns data:
|
|
161
|
+
* - tool_sequence: same tool_calls signature repeated in ≥2 of the last 5 turns
|
|
162
|
+
* - error_persistence: ≥3 consecutive turns with error_count > 0
|
|
163
|
+
*/
|
|
164
|
+
function analyzeSemanticLoops(turns) {
|
|
165
|
+
const loops = [];
|
|
166
|
+
// ── error_persistence ────────────────────────────────────────────────────────
|
|
167
|
+
const ERROR_RUN_MIN = 3;
|
|
168
|
+
let runStart = -1;
|
|
169
|
+
let runLen = 0;
|
|
170
|
+
for (let i = 0; i < turns.length; i++) {
|
|
171
|
+
if (turns[i].error_count > 0) {
|
|
172
|
+
if (runLen === 0)
|
|
173
|
+
runStart = turns[i].turn_index;
|
|
174
|
+
runLen++;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
if (runLen >= ERROR_RUN_MIN) {
|
|
178
|
+
loops.push({ type: 'error_persistence', turn_start: runStart, count: runLen, detail: `${runLen} consecutive error turns` });
|
|
179
|
+
}
|
|
180
|
+
runLen = 0;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (runLen >= ERROR_RUN_MIN) {
|
|
184
|
+
loops.push({ type: 'error_persistence', turn_start: runStart, count: runLen, detail: `${runLen} consecutive error turns` });
|
|
185
|
+
}
|
|
186
|
+
// ── tool_sequence: same tool_calls fingerprint in ≥2 of last 5 turns ────────
|
|
187
|
+
const WINDOW = 5;
|
|
188
|
+
const fingerprint = (t) => (t.tool_calls ?? []).join(',');
|
|
189
|
+
for (let i = 1; i < turns.length; i++) {
|
|
190
|
+
const fp = fingerprint(turns[i]);
|
|
191
|
+
if (!fp)
|
|
192
|
+
continue;
|
|
193
|
+
const window = turns.slice(Math.max(0, i - WINDOW + 1), i);
|
|
194
|
+
const matches = window.filter(t => fingerprint(t) === fp);
|
|
195
|
+
if (matches.length >= 1) {
|
|
196
|
+
const alreadyLogged = loops.some(l => l.type === 'tool_sequence' && l.turn_start === turns[i - matches.length].turn_index);
|
|
197
|
+
if (!alreadyLogged) {
|
|
198
|
+
const label = (turns[i].tool_calls ?? []).join('→') || 'empty';
|
|
199
|
+
loops.push({
|
|
200
|
+
type: 'tool_sequence',
|
|
201
|
+
turn_start: turns[i - matches.length].turn_index,
|
|
202
|
+
count: matches.length + 1,
|
|
203
|
+
detail: `${label} ×${matches.length + 1}`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return loops;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Linear regression over context_used samples → predicts minutes until 90% of context_window.
|
|
212
|
+
* Returns null if insufficient data or trend is flat/negative.
|
|
213
|
+
*/
|
|
214
|
+
function predictSaturation(samples, contextWindow) {
|
|
215
|
+
if (samples.length < 3 || contextWindow <= 0)
|
|
216
|
+
return null;
|
|
217
|
+
const n = samples.length;
|
|
218
|
+
const t0 = samples[0].ts;
|
|
219
|
+
const xs = samples.map(s => (s.ts - t0) / 60000); // minutes from first sample
|
|
220
|
+
const ys = samples.map(s => s.context_used / contextWindow); // 0-1 fraction
|
|
221
|
+
const sumX = xs.reduce((a, b) => a + b, 0);
|
|
222
|
+
const sumY = ys.reduce((a, b) => a + b, 0);
|
|
223
|
+
const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0);
|
|
224
|
+
const sumX2 = xs.reduce((a, x) => a + x * x, 0);
|
|
225
|
+
const denom = n * sumX2 - sumX * sumX;
|
|
226
|
+
if (Math.abs(denom) < 1e-9)
|
|
227
|
+
return null;
|
|
228
|
+
const slope = (n * sumXY - sumX * sumY) / denom; // fraction per minute
|
|
229
|
+
const intercept = (sumY - slope * sumX) / n;
|
|
230
|
+
if (slope <= 0)
|
|
231
|
+
return null; // context shrinking or flat → no saturation risk
|
|
232
|
+
const target = 0.9;
|
|
233
|
+
const currentX = xs[n - 1];
|
|
234
|
+
const minutesLeft = (target - (intercept + slope * currentX)) / slope;
|
|
235
|
+
if (minutesLeft < 0)
|
|
236
|
+
return null; // already past target
|
|
237
|
+
return { minutesLeft: Math.round(minutesLeft), pctPerMin: Math.round(slope * 100 * 10) / 10 };
|
|
238
|
+
}
|
|
81
239
|
/**
|
|
82
240
|
* Genera el reporte completo de inteligencia para una sesión.
|
|
83
241
|
*/
|
|
84
242
|
function analyzeSession(events, costUsd) {
|
|
85
243
|
const loops = detectLoops(events);
|
|
86
244
|
const efficiencyScore = calcEfficiencyScore(events, loops, costUsd);
|
|
245
|
+
const exactRetries = detectExactRetries(events);
|
|
246
|
+
const errorRate = computeErrorRate(events);
|
|
247
|
+
const fileChurnScore = computeFileChurn(events);
|
|
248
|
+
const seqCycleCount = detectSeqCycles(events);
|
|
87
249
|
const summary = buildSummary(loops, efficiencyScore, costUsd, events);
|
|
88
|
-
return { loops, efficiencyScore, summary };
|
|
250
|
+
return { loops, efficiencyScore, summary, exactRetries, errorRate, fileChurnScore, seqCycleCount };
|
|
89
251
|
}
|
|
90
252
|
function buildSummary(loops, score, costUsd, events) {
|
|
91
253
|
const parts = [];
|
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.
|
package/dist/paths.js
CHANGED
|
@@ -26,6 +26,7 @@ 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;
|
|
@@ -147,6 +148,13 @@ function getHomeSlug() {
|
|
|
147
148
|
return encodeClaudePath(os_1.default.homedir());
|
|
148
149
|
}
|
|
149
150
|
// ─── OpenCode data directory ───────────────────────────────────────────────────
|
|
151
|
+
/**
|
|
152
|
+
* Returns the OpenCode config directory.
|
|
153
|
+
* Can be overridden via OPENCODE_CONFIG_DIR env var.
|
|
154
|
+
*/
|
|
155
|
+
function getOpencodeDir() {
|
|
156
|
+
return process.env.OPENCODE_CONFIG_DIR ?? path_1.default.join(os_1.default.homedir(), '.config', 'opencode');
|
|
157
|
+
}
|
|
150
158
|
/**
|
|
151
159
|
* Returns the OpenCode SQLite database path.
|
|
152
160
|
* Can be overridden via OPENCODE_DB env var.
|
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;
|
package/dist/routes/events.js
CHANGED
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
};
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
exports.processLatestForSession = exports.onCompactDetected = exports.onCostUpdate = exports.taggedSessionParents = exports.lastAgentByCwd = exports.eventsRouter = void 0;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
9
10
|
const express_1 = require("express");
|
|
10
11
|
const db_1 = require("../db");
|
|
@@ -19,11 +20,23 @@ const notifier_1 = require("../notifier");
|
|
|
19
20
|
const helpers_1 = require("./helpers");
|
|
20
21
|
const enricher_1 = require("../enricher");
|
|
21
22
|
Object.defineProperty(exports, "processLatestForSession", { enumerable: true, get: function () { return enricher_1.processLatestForSession; } });
|
|
23
|
+
const claude_code_1 = require("../watchers/claude-code");
|
|
24
|
+
// ─── Semantic extraction debounce: 3s after last new assistant turn ───────────
|
|
25
|
+
const semanticDebounce = new Map();
|
|
26
|
+
// ─── Predictive saturation: context samples + alert cooldown per session ──────
|
|
27
|
+
const contextSamples = new Map();
|
|
28
|
+
const saturationCooldown = new Map();
|
|
29
|
+
const SATURATION_WARN_MINUTES = 30;
|
|
30
|
+
const SATURATION_COOLDOWN_MS = 15 * 60000;
|
|
31
|
+
const CONTEXT_SAMPLES_MAX = 20;
|
|
22
32
|
// ─── Loop alert cooldown (toolName:sessionId → last alert ts) ─────────────────
|
|
23
33
|
const loopAlertCooldown = new Map();
|
|
24
34
|
const LOOP_ALERT_COOLDOWN_MS = 120000; // coincide con LOOP_COOLDOWN_MS en intelligence.ts
|
|
25
35
|
// ─── Session cost alert: sesiones que ya recibieron notificación ───────────────
|
|
26
36
|
const sessionCostAlertFired = new Set();
|
|
37
|
+
// ─── Memory leak bounds ────────────────────────────────────────────────────────
|
|
38
|
+
const AGENT_CWD_TTL_MS = 30 * 60000; // entries older than 30min can't be valid parents
|
|
39
|
+
const TAGGED_PARENTS_MAX = 500; // prevent unbounded growth over long daemon runs
|
|
27
40
|
exports.eventsRouter = (0, express_1.Router)();
|
|
28
41
|
// Skill activa por sesión — se setea tras Skill Done, se limpia en Stop.
|
|
29
42
|
// Permite taggear los eventos siguientes con skill_parent para agruparlos en la UI.
|
|
@@ -55,17 +68,52 @@ function shouldFireAlert(level, pct) {
|
|
|
55
68
|
alertCooldown.set(level, Date.now());
|
|
56
69
|
return true;
|
|
57
70
|
}
|
|
71
|
+
function extractTextPreview(transcriptPath) {
|
|
72
|
+
try {
|
|
73
|
+
const size = fs_1.default.statSync(transcriptPath).size;
|
|
74
|
+
const readSize = Math.min(size, 8192);
|
|
75
|
+
const buf = Buffer.alloc(readSize);
|
|
76
|
+
const fd = fs_1.default.openSync(transcriptPath, 'r');
|
|
77
|
+
fs_1.default.readSync(fd, buf, 0, readSize, Math.max(0, size - readSize));
|
|
78
|
+
fs_1.default.closeSync(fd);
|
|
79
|
+
const lines = buf.toString('utf8').split('\n').filter(l => l.trim());
|
|
80
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
81
|
+
try {
|
|
82
|
+
const obj = JSON.parse(lines[i]);
|
|
83
|
+
const data = obj.message ?? obj;
|
|
84
|
+
if (data.role !== 'assistant')
|
|
85
|
+
continue;
|
|
86
|
+
let text = '';
|
|
87
|
+
if (typeof data.content === 'string') {
|
|
88
|
+
text = data.content;
|
|
89
|
+
}
|
|
90
|
+
else if (Array.isArray(data.content)) {
|
|
91
|
+
text = data.content.find((c) => c.type === 'text')?.text ?? '';
|
|
92
|
+
}
|
|
93
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
94
|
+
if (!text)
|
|
95
|
+
continue;
|
|
96
|
+
return text.length > 120 ? text.slice(0, 120) + '…' : text;
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch { }
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
58
104
|
exports.eventsRouter.post('/event', (req, res) => {
|
|
59
105
|
const ip = req.ip ?? '127.0.0.1';
|
|
60
106
|
if ((0, rate_limiter_1.isRateLimited)(ip)) {
|
|
61
107
|
res.status(429).json({ error: 'Too many requests — wait 1 minute' });
|
|
62
108
|
return;
|
|
63
109
|
}
|
|
64
|
-
const { type, session_id, tool_name, tool_input, tool_response, ts, cwd, transcript_path, source } = req.body;
|
|
110
|
+
const { type, session_id, tool_name, tool_input, tool_response, ts, cwd, transcript_path, source: rawSource } = req.body;
|
|
65
111
|
if (!session_id || !type) {
|
|
66
112
|
res.status(400).json({ error: 'Missing session_id or type' });
|
|
67
113
|
return;
|
|
68
114
|
}
|
|
115
|
+
const KNOWN_SOURCES = new Set(['claude-code', 'opencode', 'codex', 'amp', 'droid', 'codebuff']);
|
|
116
|
+
const source = rawSource && KNOWN_SOURCES.has(rawSource) ? rawSource : 'claude-code';
|
|
69
117
|
const resolvedCwd = cwd
|
|
70
118
|
?? (transcript_path ? path_1.default.dirname(transcript_path) || undefined : undefined);
|
|
71
119
|
db_1.dbOps.upsertSession({ id: session_id, cwd: resolvedCwd, started_at: ts, last_event_at: ts, source });
|
|
@@ -94,13 +142,23 @@ exports.eventsRouter.post('/event', (req, res) => {
|
|
|
94
142
|
}
|
|
95
143
|
}
|
|
96
144
|
else {
|
|
145
|
+
const stopPreview = type === 'Stop'
|
|
146
|
+
? (() => {
|
|
147
|
+
const lam = req.body.last_assistant_message;
|
|
148
|
+
if (lam && typeof lam === 'string') {
|
|
149
|
+
const t = lam.replace(/\s+/g, ' ').trim();
|
|
150
|
+
return t.length > 120 ? t.slice(0, 120) + '…' : t;
|
|
151
|
+
}
|
|
152
|
+
return transcript_path ? extractTextPreview(transcript_path) : undefined;
|
|
153
|
+
})()
|
|
154
|
+
: undefined;
|
|
97
155
|
db_1.dbOps.insertEvent({
|
|
98
156
|
session_id, type,
|
|
99
157
|
tool_name: tool_name ?? undefined,
|
|
100
|
-
tool_input: tool_input ? JSON.stringify(tool_input) : undefined,
|
|
158
|
+
tool_input: tool_input ? JSON.stringify(tool_input) : (stopPreview ?? undefined),
|
|
101
159
|
ts, cwd: resolvedCwd, skill_parent: skillParent, source
|
|
102
160
|
});
|
|
103
|
-
(0, stream_1.broadcast)({ type: 'event', payload: { ...req.body, skill_parent: skillParent } });
|
|
161
|
+
(0, stream_1.broadcast)({ type: 'event', payload: { ...req.body, skill_parent: skillParent, ...(stopPreview ? { text_preview: stopPreview } : {}) } });
|
|
104
162
|
// Stop limpia el skill activo para esta sesión
|
|
105
163
|
if (type === 'Stop') {
|
|
106
164
|
activeSkillBySession.delete(session_id);
|
|
@@ -108,6 +166,11 @@ exports.eventsRouter.post('/event', (req, res) => {
|
|
|
108
166
|
}
|
|
109
167
|
// Registrar Agent PreToolUse para detección de sub-sesiones
|
|
110
168
|
if (type === 'PreToolUse' && tool_name === 'Agent' && resolvedCwd) {
|
|
169
|
+
const cwdCutoff = Date.now() - AGENT_CWD_TTL_MS;
|
|
170
|
+
for (const [cwd, info] of exports.lastAgentByCwd) {
|
|
171
|
+
if (info.pre_ts < cwdCutoff)
|
|
172
|
+
exports.lastAgentByCwd.delete(cwd);
|
|
173
|
+
}
|
|
111
174
|
exports.lastAgentByCwd.set(resolvedCwd, { pre_ts: ts, session_id });
|
|
112
175
|
}
|
|
113
176
|
}
|
|
@@ -121,6 +184,17 @@ exports.eventsRouter.post('/event', (req, res) => {
|
|
|
121
184
|
const projectCwd = (0, helpers_1.findProjectCwdForFile)(filePath);
|
|
122
185
|
if (projectCwd)
|
|
123
186
|
db_1.dbOps.updateSessionProject(session_id, projectCwd);
|
|
187
|
+
// Auto-declare intent for write tools when no active intent exists yet
|
|
188
|
+
if (type === 'PreToolUse' && ['Write', 'Edit'].includes(tool_name || '')) {
|
|
189
|
+
if (!db_1.dbOps.hasActiveIntent(source, filePath)) {
|
|
190
|
+
const conflicts = db_1.dbOps.getWriteConflicts([filePath], source);
|
|
191
|
+
if (conflicts.length === 0) {
|
|
192
|
+
const aid = db_1.dbOps.insertIntent(source, session_id, 'auto: ' + tool_name);
|
|
193
|
+
db_1.dbOps.insertIntentFile(aid, filePath, 'write');
|
|
194
|
+
(0, stream_1.broadcast)({ type: 'intent_declared', payload: { tool: source, files: [filePath], task: 'auto: ' + tool_name } });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
124
198
|
}
|
|
125
199
|
}
|
|
126
200
|
catch { /* ignorar errores de parsing */ }
|
|
@@ -197,12 +271,17 @@ exports.eventsRouter.post('/event', (req, res) => {
|
|
|
197
271
|
const onCostUpdate = (sessionId, cost, source) => {
|
|
198
272
|
// Ensure session row exists — sub-agent JSONLs arrive from the enricher without a
|
|
199
273
|
// prior hook event (Claude Code does not fire hooks for sub-agent sessions).
|
|
200
|
-
db_1.dbOps.upsertSession({ id: sessionId, cwd: undefined, started_at: cost.firstTs ?? Date.now(), last_event_at: Date.now(), source });
|
|
274
|
+
db_1.dbOps.upsertSession({ id: sessionId, cwd: undefined, started_at: cost.firstTs ?? Date.now(), last_event_at: cost.lastTs ?? cost.firstTs ?? Date.now(), source });
|
|
201
275
|
let sessionRow = db_1.dbOps.getSession(sessionId);
|
|
202
276
|
// Sub-agent detection: first time we see a session, check if its firstTs falls after
|
|
203
277
|
// a recent Agent PreToolUse from another session in the same CWD → tag as child.
|
|
204
278
|
if (!exports.taggedSessionParents.has(sessionId) && cost.firstTs) {
|
|
205
279
|
exports.taggedSessionParents.add(sessionId);
|
|
280
|
+
if (exports.taggedSessionParents.size > TAGGED_PARENTS_MAX) {
|
|
281
|
+
const arr = Array.from(exports.taggedSessionParents);
|
|
282
|
+
for (const id of arr.slice(0, arr.length >> 1))
|
|
283
|
+
exports.taggedSessionParents.delete(id);
|
|
284
|
+
}
|
|
206
285
|
const cwd = sessionRow?.cwd;
|
|
207
286
|
if (cwd) {
|
|
208
287
|
const agentInfo = exports.lastAgentByCwd.get(cwd);
|
|
@@ -213,16 +292,44 @@ const onCostUpdate = (sessionId, cost, source) => {
|
|
|
213
292
|
}
|
|
214
293
|
const events = db_1.dbOps.getSessionEvents(sessionId);
|
|
215
294
|
const report = (0, intelligence_1.analyzeSession)(events, cost.cost_usd);
|
|
216
|
-
db_1.dbOps.updateSessionCost(sessionId, cost, report.efficiencyScore, report.loops.length);
|
|
295
|
+
db_1.dbOps.updateSessionCost(sessionId, cost, report.efficiencyScore, report.loops.length, report.exactRetries, report.errorRate, report.fileChurnScore, report.seqCycleCount);
|
|
296
|
+
// ─── Predictive saturation ────────────────────────────────────────────────────
|
|
297
|
+
if (cost.context_used > 0 && cost.context_window > 0) {
|
|
298
|
+
const samples = contextSamples.get(sessionId) ?? [];
|
|
299
|
+
samples.push({ ts: Date.now(), context_used: cost.context_used });
|
|
300
|
+
if (samples.length > CONTEXT_SAMPLES_MAX)
|
|
301
|
+
samples.shift();
|
|
302
|
+
contextSamples.set(sessionId, samples);
|
|
303
|
+
const prediction = (0, intelligence_1.predictSaturation)(samples, cost.context_window);
|
|
304
|
+
if (prediction && prediction.minutesLeft <= SATURATION_WARN_MINUTES) {
|
|
305
|
+
const lastFired = saturationCooldown.get(sessionId) ?? 0;
|
|
306
|
+
if (Date.now() - lastFired >= SATURATION_COOLDOWN_MS) {
|
|
307
|
+
saturationCooldown.set(sessionId, Date.now());
|
|
308
|
+
(0, stream_1.broadcast)({
|
|
309
|
+
type: 'saturation_warning',
|
|
310
|
+
payload: {
|
|
311
|
+
session_id: sessionId,
|
|
312
|
+
minutes_left: prediction.minutesLeft,
|
|
313
|
+
pct_per_min: prediction.pctPerMin,
|
|
314
|
+
context_used: cost.context_used,
|
|
315
|
+
context_window: cost.context_window,
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
217
321
|
const startedAt = sessionRow?.started_at ?? Date.now();
|
|
218
322
|
const sessionDurationMinutes = (Date.now() - startedAt) / 60000;
|
|
219
323
|
const projectedHourlyUsd = sessionDurationMinutes > 0.5
|
|
220
324
|
? cost.cost_usd / sessionDurationMinutes * 60
|
|
221
325
|
: 0;
|
|
326
|
+
const sessionSource = sessionRow?.source ?? 'claude-code';
|
|
222
327
|
(0, stream_1.broadcast)({
|
|
223
328
|
type: 'cost_update',
|
|
224
329
|
payload: {
|
|
225
330
|
session_id: sessionId,
|
|
331
|
+
source: sessionSource,
|
|
332
|
+
started_at: startedAt,
|
|
226
333
|
cost_usd: cost.cost_usd,
|
|
227
334
|
input_tokens: cost.input_tokens,
|
|
228
335
|
output_tokens: cost.output_tokens,
|
|
@@ -268,6 +375,30 @@ const onCostUpdate = (sessionId, cost, source) => {
|
|
|
268
375
|
outputTokens: cost.lastEntry.outputTokens,
|
|
269
376
|
}
|
|
270
377
|
});
|
|
378
|
+
// Semantic extraction: debounced 3s so rapid updates don't cause duplicate parses
|
|
379
|
+
const existing = semanticDebounce.get(sessionId);
|
|
380
|
+
if (existing)
|
|
381
|
+
clearTimeout(existing);
|
|
382
|
+
semanticDebounce.set(sessionId, setTimeout(async () => {
|
|
383
|
+
semanticDebounce.delete(sessionId);
|
|
384
|
+
try {
|
|
385
|
+
const filePath = await (0, claude_code_1.findJSONLForSession)(sessionId);
|
|
386
|
+
if (!filePath)
|
|
387
|
+
return;
|
|
388
|
+
const semantic = await (0, claude_code_1.extractSemanticData)(filePath);
|
|
389
|
+
if (!semantic)
|
|
390
|
+
return;
|
|
391
|
+
db_1.dbOps.upsertAssistantTurns(sessionId, semantic.turns);
|
|
392
|
+
const semLoops = (0, intelligence_1.analyzeSemanticLoops)(semantic.turns);
|
|
393
|
+
db_1.dbOps.updateSessionSemantic(sessionId, semantic.avg_output_chars, semantic.error_block_count, semLoops.length);
|
|
394
|
+
if (semLoops.length > 0) {
|
|
395
|
+
(0, stream_1.broadcast)({ type: 'semantic_loop', payload: { session_id: sessionId, loops: semLoops } });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
console.warn('[events] Semantic extraction error:', err);
|
|
400
|
+
}
|
|
401
|
+
}, 3000));
|
|
271
402
|
}
|
|
272
403
|
};
|
|
273
404
|
exports.onCostUpdate = onCostUpdate;
|
package/dist/routes/history.js
CHANGED
|
@@ -22,8 +22,10 @@ exports.historyRouter.get('/history', (_req, res) => {
|
|
|
22
22
|
// Detectar modo desde los contadores precalculados en la query
|
|
23
23
|
const hasAgent = (s.agent_count ?? 0) > 0;
|
|
24
24
|
const hasSkill = (s.skill_count ?? 0) > 0;
|
|
25
|
-
const
|
|
26
|
-
|
|
25
|
+
const isSubAgent = s.parent_session_id != null;
|
|
26
|
+
const mode = isSubAgent ? 'sub-agente'
|
|
27
|
+
: hasAgent && hasSkill ? 'agentes+skills'
|
|
28
|
+
: hasAgent ? 'agentes' : hasSkill ? 'skills' : 'directo';
|
|
27
29
|
// Git info cacheada para este proyecto
|
|
28
30
|
const gitInfo = s.project_path ? (0, projects_cache_1.getCachedGitInfo)(s.project_path) : null;
|
|
29
31
|
byDate.get(date).push({
|
|
@@ -45,7 +47,9 @@ exports.historyRouter.get('/history', (_req, res) => {
|
|
|
45
47
|
return [];
|
|
46
48
|
} })() : [],
|
|
47
49
|
mode,
|
|
50
|
+
source: s.source ?? 'claude-code',
|
|
48
51
|
ai_summary: s.ai_summary ?? null,
|
|
52
|
+
is_sub_agent: isSubAgent,
|
|
49
53
|
git_branch: gitInfo?.branch ?? null,
|
|
50
54
|
git_dirty: gitInfo?.dirty ?? false,
|
|
51
55
|
git_ahead: gitInfo?.ahead ?? 0,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const intentsRouter: import("express-serve-static-core").Router;
|