@statforge/claudestat 1.6.0 → 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.
Files changed (49) hide show
  1. package/README.md +3 -1
  2. package/dashboard/dist/assets/AnalyticsView-DDGLDoCN.js +7 -0
  3. package/dashboard/dist/assets/HistoryView-DkPfrNrv.js +1 -0
  4. package/dashboard/dist/assets/LineChart-BOWYkkEW.js +2 -0
  5. package/dashboard/dist/assets/ProjectsView-VRoRiEL4.js +6 -0
  6. package/dashboard/dist/assets/SystemView-B2zbIxhY.js +1 -0
  7. package/dashboard/dist/assets/TopView-C2qdsy0Y.js +1 -0
  8. package/dashboard/dist/assets/index-CMhe3KaT.js +84 -0
  9. package/dashboard/dist/assets/shared-BbBtsdh1.js +1 -0
  10. package/dashboard/dist/assets/{vendor-lucide-Cym0q5l_.js → vendor-lucide-ClCW-axQ.js} +79 -64
  11. package/dashboard/dist/assets/{vendor-react-B_Jzs0gY.js → vendor-react-gHSHIE2L.js} +1 -1
  12. package/dashboard/dist/index.html +3 -3
  13. package/dist/daemon.js +58 -2
  14. package/dist/db.d.ts +76 -2
  15. package/dist/db.js +295 -65
  16. package/dist/doctor.js +1 -1
  17. package/dist/enricher.d.ts +3 -2
  18. package/dist/enricher.js +12 -5
  19. package/dist/index.js +12 -1
  20. package/dist/intelligence.d.ts +55 -0
  21. package/dist/intelligence.js +163 -1
  22. package/dist/paths.d.ts +10 -0
  23. package/dist/paths.js +17 -0
  24. package/dist/pricing.d.ts +2 -0
  25. package/dist/pricing.js +12 -1
  26. package/dist/routes/events.js +136 -5
  27. package/dist/routes/history.js +6 -2
  28. package/dist/routes/intents.d.ts +1 -0
  29. package/dist/routes/intents.js +155 -0
  30. package/dist/routes/misc.js +132 -4
  31. package/dist/routes/opencode-reader.js +42 -8
  32. package/dist/routes/projects.js +10 -1
  33. package/dist/routes/replay.d.ts +1 -0
  34. package/dist/routes/replay.js +29 -0
  35. package/dist/routes/reports.js +7 -0
  36. package/dist/routes/stream.js +1 -1
  37. package/dist/routes/top.js +8 -1
  38. package/dist/watchers/adapter.d.ts +1 -0
  39. package/dist/watchers/claude-code.d.ts +16 -1
  40. package/dist/watchers/claude-code.js +201 -76
  41. package/dist/watchers/opencode.d.ts +1 -0
  42. package/dist/watchers/opencode.js +161 -23
  43. package/package.json +1 -1
  44. package/dashboard/dist/assets/AnalyticsView-5bUM3UHp.js +0 -8
  45. package/dashboard/dist/assets/HistoryView-C-AsEqos.js +0 -1
  46. package/dashboard/dist/assets/ProjectsView-D9bZBdY2.js +0 -6
  47. package/dashboard/dist/assets/SystemView-DIYDCCF3.js +0 -1
  48. package/dashboard/dist/assets/TopView-DhdLpsiA.js +0 -1
  49. package/dashboard/dist/assets/index-DgbWvj42.js +0 -84
@@ -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 {};
@@ -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,16 @@ 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;
80
+ /**
81
+ * Returns the OpenCode SQLite database path.
82
+ * Can be overridden via OPENCODE_DB env var.
83
+ */
84
+ export declare function getOpencodeDb(): string;
75
85
  /**
76
86
  * Returns the appropriate command to find an executable in PATH.
77
87
  * Unix: which <name>
package/dist/paths.js CHANGED
@@ -26,6 +26,8 @@ exports.encodeClaudePath = encodeClaudePath;
26
26
  exports.decodeClaudePath = decodeClaudePath;
27
27
  exports.homeSlugRegex = homeSlugRegex;
28
28
  exports.getHomeSlug = getHomeSlug;
29
+ exports.getOpencodeDir = getOpencodeDir;
30
+ exports.getOpencodeDb = getOpencodeDb;
29
31
  exports.whichCmd = whichCmd;
30
32
  exports.whichAllCmd = whichAllCmd;
31
33
  exports.portCheckCmd = portCheckCmd;
@@ -145,6 +147,21 @@ function homeSlugRegex() {
145
147
  function getHomeSlug() {
146
148
  return encodeClaudePath(os_1.default.homedir());
147
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
+ }
158
+ /**
159
+ * Returns the OpenCode SQLite database path.
160
+ * Can be overridden via OPENCODE_DB env var.
161
+ */
162
+ function getOpencodeDb() {
163
+ return process.env.OPENCODE_DB ?? path_1.default.join(os_1.default.homedir(), '.local', 'share', 'opencode', 'opencode.db');
164
+ }
148
165
  // ─── Platform utilities ────────────────────────────────────────────────────────
149
166
  /**
150
167
  * Returns the appropriate command to find an executable in PATH.
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;
@@ -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;
@@ -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 mode = hasAgent && hasSkill ? 'agentes+skills'
26
- : hasAgent ? 'agentes' : hasSkill ? 'skills' : 'directo';
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;