@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.
Files changed (63) hide show
  1. package/README.md +36 -3
  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/config.d.ts +7 -0
  14. package/dist/config.js +36 -0
  15. package/dist/daemon.js +113 -9
  16. package/dist/db.d.ts +87 -2
  17. package/dist/db.js +325 -65
  18. package/dist/doctor.js +21 -3
  19. package/dist/enricher.d.ts +3 -2
  20. package/dist/enricher.js +10 -5
  21. package/dist/export.d.ts +2 -1
  22. package/dist/export.js +41 -6
  23. package/dist/index.js +406 -20
  24. package/dist/insights.d.ts +1 -0
  25. package/dist/insights.js +26 -0
  26. package/dist/install.js +28 -1
  27. package/dist/intelligence.d.ts +66 -4
  28. package/dist/intelligence.js +205 -17
  29. package/dist/logger.d.ts +6 -0
  30. package/dist/logger.js +49 -0
  31. package/dist/notifier.d.ts +15 -0
  32. package/dist/notifier.js +26 -0
  33. package/dist/paths.d.ts +23 -0
  34. package/dist/paths.js +42 -0
  35. package/dist/pricing.d.ts +2 -0
  36. package/dist/pricing.js +12 -1
  37. package/dist/routes/events.js +136 -5
  38. package/dist/routes/helpers.d.ts +5 -0
  39. package/dist/routes/helpers.js +21 -1
  40. package/dist/routes/history.js +6 -2
  41. package/dist/routes/intents.d.ts +1 -0
  42. package/dist/routes/intents.js +155 -0
  43. package/dist/routes/misc.js +150 -4
  44. package/dist/routes/opencode-reader.js +39 -3
  45. package/dist/routes/projects.js +19 -1
  46. package/dist/routes/replay.d.ts +1 -0
  47. package/dist/routes/replay.js +29 -0
  48. package/dist/routes/reports.js +7 -0
  49. package/dist/routes/top.js +8 -1
  50. package/dist/service.js +11 -0
  51. package/dist/watchers/adapter.d.ts +1 -0
  52. package/dist/watchers/claude-code.d.ts +16 -1
  53. package/dist/watchers/claude-code.js +201 -76
  54. package/dist/watchers/opencode.d.ts +1 -0
  55. package/dist/watchers/opencode.js +152 -14
  56. package/hooks/event.js +44 -26
  57. package/package.json +1 -1
  58. package/dashboard/dist/assets/AnalyticsView-5bUM3UHp.js +0 -8
  59. package/dashboard/dist/assets/HistoryView-C-AsEqos.js +0 -1
  60. package/dashboard/dist/assets/ProjectsView-D9bZBdY2.js +0 -6
  61. package/dashboard/dist/assets/SystemView-DIYDCCF3.js +0 -1
  62. package/dashboard/dist/assets/TopView-DhdLpsiA.js +0 -1
  63. package/dashboard/dist/assets/index-DgbWvj42.js +0 -84
@@ -35,13 +35,49 @@ exports.opencodeReaderRouter.get('/api/opencode/session/:id', (req, res) => {
35
35
  try {
36
36
  const { id: sessionId } = req.params;
37
37
  const db = openDb();
38
- // Get all messages for this session ordered by time
38
+ // ── Find all related session IDs (OpenCode creates one session per prompt) ──
39
+ // Look up directory for this session
40
+ const sessionRow = db.prepare(`
41
+ SELECT directory, time_created FROM session WHERE id = ?
42
+ `).get(sessionId);
43
+ let sessionIds = [sessionId];
44
+ if (sessionRow?.directory) {
45
+ // Find sessions with same directory, ordered by creation time
46
+ const sameDir = db.prepare(`
47
+ SELECT id, time_created FROM session
48
+ WHERE directory = ? AND time_archived IS NULL
49
+ ORDER BY time_created ASC
50
+ `).all(sessionRow.directory);
51
+ if (sameDir.length > 1) {
52
+ // Locate our session in the sorted list
53
+ let idx = sameDir.findIndex(s => s.id === sessionId);
54
+ if (idx !== -1) {
55
+ // Expand forward: consecutive sessions with gap < 60s
56
+ let end = idx;
57
+ for (let i = idx + 1; i < sameDir.length; i++) {
58
+ if (sameDir[i].time_created - sameDir[i - 1].time_created > 300000)
59
+ break;
60
+ end = i;
61
+ }
62
+ // Expand backward
63
+ let begin = idx;
64
+ for (let i = idx - 1; i >= 0; i--) {
65
+ if (sameDir[begin].time_created - sameDir[i].time_created > 300000)
66
+ break;
67
+ begin = i;
68
+ }
69
+ sessionIds = sameDir.slice(begin, end + 1).map(s => s.id);
70
+ }
71
+ }
72
+ }
73
+ // ── Query messages from ALL sessions in the group ──
74
+ const placeholders = sessionIds.map(() => '?').join(',');
39
75
  const messages = db.prepare(`
40
76
  SELECT id, time_created, time_updated, data
41
77
  FROM message
42
- WHERE session_id = ?
78
+ WHERE session_id IN (${placeholders})
43
79
  ORDER BY time_created ASC
44
- `).all(sessionId);
80
+ `).all(...sessionIds);
45
81
  const events = [];
46
82
  const prompts = [];
47
83
  let totalParts = 0;
@@ -13,6 +13,7 @@ const db_1 = require("../db");
13
13
  const projects_cache_1 = require("../cache/projects-cache");
14
14
  const pattern_analyzer_1 = require("../pattern-analyzer");
15
15
  const helpers_1 = require("./helpers");
16
+ const config_1 = require("../config");
16
17
  exports.projectsRouter = (0, express_1.Router)();
17
18
  /** Infiere el proyecto activo mirando los eventos de archivo de una sesión */
18
19
  function inferProjectCwd(events) {
@@ -125,14 +126,31 @@ exports.projectsRouter.get('/projects', (_req, res) => {
125
126
  jsonl_source: useJSONL,
126
127
  });
127
128
  }
129
+ // CLI hours per project (source breakdown from DB)
130
+ const cliHoursRows = db_1.dbOps.getProjectCliHours();
131
+ const cliHoursMap = new Map();
132
+ for (const row of cliHoursRows) {
133
+ if (!cliHoursMap.has(row.project_path))
134
+ cliHoursMap.set(row.project_path, {});
135
+ cliHoursMap.get(row.project_path)[row.source] = row.total_ms / 3600000;
136
+ }
128
137
  // Attach pattern insights per project (only if DB has enough data)
138
+ const cfg = (0, config_1.readConfig)();
129
139
  const projects = [...projectMap.values()].map(p => {
130
140
  const toolCounts = db_1.dbOps.getProjectToolCounts(p.path);
131
141
  const sessionStats = db_1.dbOps.getProjectSessionStats(p.path);
132
142
  const insights = (sessionStats && sessionStats.session_count >= 2)
133
143
  ? (0, pattern_analyzer_1.analyzePatterns)(toolCounts, sessionStats)
134
144
  : [];
135
- return { ...p, insights };
145
+ const cli_hours = cliHoursMap.get(p.path);
146
+ const aliasName = cfg.projectAliases[p.path];
147
+ return {
148
+ ...p,
149
+ name: aliasName ?? p.name,
150
+ alias: aliasName ?? null,
151
+ insights,
152
+ ...(cli_hours ? { cli_hours } : {}),
153
+ };
136
154
  })
137
155
  .sort((a, b) => (b.last_active ?? 0) - (a.last_active ?? 0));
138
156
  res.json({ projects, active_project: activeProject });
@@ -0,0 +1 @@
1
+ export declare const replayRouter: import("express-serve-static-core").Router;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.replayRouter = void 0;
4
+ const express_1 = require("express");
5
+ const db_1 = require("../db");
6
+ exports.replayRouter = (0, express_1.Router)();
7
+ const CONTEXT_WINDOW = 200000;
8
+ exports.replayRouter.get('/session/:id/replay', (req, res) => {
9
+ const { id } = req.params;
10
+ const turns = db_1.dbOps.getAssistantTurns(id);
11
+ res.json({ turns, context_window: CONTEXT_WINDOW });
12
+ });
13
+ function buildTree(sessionId, depth = 0) {
14
+ if (depth > 5)
15
+ return { id: sessionId, started_at: 0, children: [] };
16
+ const session = db_1.dbOps.getSession(sessionId);
17
+ const children = db_1.dbOps.getChildSessions(sessionId);
18
+ return {
19
+ id: sessionId,
20
+ dominant_model: session?.dominant_model ?? undefined,
21
+ total_cost_usd: session?.total_cost_usd ?? undefined,
22
+ started_at: session?.started_at ?? 0,
23
+ children: children.map(c => buildTree(c.id, depth + 1)),
24
+ };
25
+ }
26
+ exports.replayRouter.get('/session/:id/agent-tree', (req, res) => {
27
+ const { id } = req.params;
28
+ res.json(buildTree(id));
29
+ });
@@ -61,6 +61,13 @@ exports.reportsRouter.get('/api/analytics', (req, res) => {
61
61
  },
62
62
  });
63
63
  });
64
+ // ─── GET /api/analytics/coach — eventos combinados para el coach multi-source ─
65
+ exports.reportsRouter.get('/api/analytics/coach', (req, res) => {
66
+ const raw = req.query.session_ids;
67
+ const sessionIds = raw ? raw.split(',').map(s => s.trim()).filter(Boolean) : [];
68
+ const events = db_1.dbOps.getCoachEvents(sessionIds);
69
+ res.json({ events });
70
+ });
64
71
  // ─── POST /api/weekly-reports/generate-now — generar informe inmediatamente ───
65
72
  exports.reportsRouter.post('/api/weekly-reports/generate-now', (_req, res) => {
66
73
  const cfg = (0, config_1.readConfig)();
@@ -8,18 +8,25 @@ exports.topRouter.get('/api/top', (req, res) => {
8
8
  const by = req.query.by ?? 'cost';
9
9
  const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
10
10
  const days = Math.min(parseInt(req.query.days, 10) || 30, 365);
11
+ const source = req.query.source ?? 'all';
11
12
  if (!['cost', 'count', 'duration'].includes(by)) {
12
13
  res.status(400).json({ error: 'Invalid "by" parameter. Use: cost, count, duration' });
13
14
  return;
14
15
  }
15
- const tools = db_1.dbOps.getTopTools(days, by, limit);
16
+ if (!['all', 'claude-code', 'opencode'].includes(source)) {
17
+ res.status(400).json({ error: 'Invalid "source" parameter. Use: all, claude-code, opencode' });
18
+ return;
19
+ }
20
+ const tools = db_1.dbOps.getTopTools(days, by, limit, source);
16
21
  const totalCost = tools.reduce((s, t) => s + t.total_cost_usd, 0);
17
22
  const totalCount = tools.filter(t => t.tool_name !== 'Other').reduce((s, t) => s + t.count, 0);
18
23
  res.json({
19
24
  by,
20
25
  days,
26
+ source,
21
27
  tools: tools.map(t => ({
22
28
  tool: t.tool_name,
29
+ source: t.source,
23
30
  count: t.count,
24
31
  totalDurationMs: t.total_duration_ms,
25
32
  estimatedCostUsd: t.total_cost_usd,
package/dist/service.js CHANGED
@@ -8,6 +8,14 @@ exports.uninstallService = uninstallService;
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const child_process_1 = require("child_process");
11
+ function buildEnvPath() {
12
+ const current = process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin';
13
+ const nvmDir = process.env.NVM_DIR;
14
+ if (!nvmDir)
15
+ return current;
16
+ const nvmBin = path_1.default.join(nvmDir, 'versions', 'node', `v${process.versions.node}`, 'bin');
17
+ return current.includes(nvmBin) ? current : `${nvmBin}:${current}`;
18
+ }
11
19
  const PLIST_LABEL = 'com.statforge.claudestat';
12
20
  const PLIST_PATH = path_1.default.join(process.env.HOME ?? '~', 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
13
21
  const SYSTEMD_DIR = path_1.default.join(process.env.HOME ?? '~', '.config', 'systemd', 'user');
@@ -37,6 +45,8 @@ function makePlist() {
37
45
  <dict>
38
46
  <key>CLAUDESTAT_DAEMON</key>
39
47
  <string>1</string>
48
+ <key>PATH</key>
49
+ <string>${buildEnvPath()}</string>
40
50
  </dict>
41
51
  <key>StandardOutPath</key>
42
52
  <string>/tmp/claudestat-daemon.log</string>
@@ -56,6 +66,7 @@ ExecStart=${serviceCommand()} start
56
66
  Restart=on-failure
57
67
  RestartSec=5
58
68
  Environment=CLAUDESTAT_DAEMON=1
69
+ Environment=PATH=${buildEnvPath()}
59
70
 
60
71
  [Install]
61
72
  WantedBy=default.target`;
@@ -33,6 +33,7 @@ export interface WatcherAdapter {
33
33
  export interface PollSession {
34
34
  sessionId: string;
35
35
  cost: CostUpdate;
36
+ cwd?: string;
36
37
  }
37
38
  /** Adapters que no usan archivos JSONL sino una DB o API propia */
38
39
  export interface PollableAdapter extends WatcherAdapter {
@@ -6,8 +6,23 @@
6
6
  */
7
7
  import { type WatcherAdapter } from './adapter';
8
8
  import type { BlockCostEntry } from '../db';
9
- export declare function getContextWindow(model: string): number;
10
9
  export declare const claudeCodeAdapter: WatcherAdapter;
10
+ export interface AssistantTurn {
11
+ turn_index: number;
12
+ ts?: number;
13
+ text_preview: string;
14
+ tool_calls: string[];
15
+ error_count: number;
16
+ output_chars: number;
17
+ context_used: number;
18
+ }
19
+ export interface SemanticData {
20
+ turns: AssistantTurn[];
21
+ avg_output_chars: number;
22
+ error_block_count: number;
23
+ }
24
+ export declare function findJSONLForSession(sessionId: string): Promise<string | null>;
25
+ export declare function extractSemanticData(filePath: string): Promise<SemanticData | null>;
11
26
  export declare function getAllBlockCostsForSession(sessionId: string): Promise<BlockCostEntry[]>;
12
27
  export interface SessionPrompt {
13
28
  index: number;
@@ -10,7 +10,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.claudeCodeAdapter = void 0;
13
- exports.getContextWindow = getContextWindow;
13
+ exports.findJSONLForSession = findJSONLForSession;
14
+ exports.extractSemanticData = extractSemanticData;
14
15
  exports.getAllBlockCostsForSession = getAllBlockCostsForSession;
15
16
  exports.getSessionPrompts = getSessionPrompts;
16
17
  const path_1 = __importDefault(require("path"));
@@ -20,14 +21,6 @@ const adapter_1 = require("./adapter");
20
21
  const paths_1 = require("../paths");
21
22
  const pricing_1 = require("../pricing");
22
23
  function projectsDir() { return path_1.default.join((0, paths_1.getClaudeDir)(), 'projects'); }
23
- const KNOWN_CONTEXT_WINDOWS = {
24
- 'claude-opus-4-6': 200000,
25
- 'claude-sonnet-4-6': 200000,
26
- 'claude-haiku-4-5': 200000,
27
- };
28
- function getContextWindow(model) {
29
- return KNOWN_CONTEXT_WINDOWS[model] ?? 200000;
30
- }
31
24
  const fileOffsets = new Map();
32
25
  const FILE_OFFSET_TTL = 30 * 60000;
33
26
  function cleanupStaleOffsets() {
@@ -38,80 +31,106 @@ function cleanupStaleOffsets() {
38
31
  }
39
32
  }
40
33
  async function processJSONL(filePath) {
41
- let fileContent;
34
+ let fd;
42
35
  try {
43
- fileContent = await promises_1.default.readFile(filePath, 'utf8');
44
- }
45
- catch {
46
- return null;
47
- }
48
- const currentSize = Buffer.byteLength(fileContent, 'utf8');
49
- const knownEntry = fileOffsets.get(filePath);
50
- const knownOffset = knownEntry?.offset ?? 0;
51
- if (currentSize < knownOffset)
52
- fileOffsets.set(filePath, { offset: 0, lastAccess: Date.now() });
53
- const totals = {
54
- input_tokens: 0, output_tokens: 0,
55
- cache_read: 0, cache_creation: 0, cost_usd: 0,
56
- context_used: 0, context_window: 200000
57
- };
58
- let lastInputUsd = 0;
59
- let lastOutputUsd = 0;
60
- let lastInputTokens = 0;
61
- let lastOutputTokens = 0;
62
- let lastModel;
63
- let firstTs;
64
- for (const raw of fileContent.split('\n')) {
65
- const line = raw.trim();
66
- if (!line)
67
- continue;
68
- try {
69
- const obj = JSON.parse(line);
70
- if (obj.type !== 'assistant')
71
- continue;
72
- const msg = obj.message;
73
- if (!msg?.usage)
36
+ fd = await promises_1.default.open(filePath, 'r');
37
+ const currentSize = (await fd.stat()).size;
38
+ // File was truncated (e.g., /compact) — drop cached state and re-read from start
39
+ let state = fileOffsets.get(filePath);
40
+ if (state && currentSize < state.offset) {
41
+ fileOffsets.delete(filePath);
42
+ state = undefined;
43
+ }
44
+ const fromByte = state?.offset ?? 0;
45
+ // Nothing new return cached totals without lastEntry to avoid duplicate SSE
46
+ if (currentSize === fromByte && state)
47
+ return { ...state.totals, lastEntry: undefined };
48
+ // Read only the new bytes since last processed offset
49
+ const buf = Buffer.alloc(currentSize - fromByte);
50
+ await fd.read(buf, 0, buf.length, fromByte);
51
+ const newContent = buf.toString('utf8');
52
+ // Accumulate on top of previous totals (or start from zero on first read)
53
+ const prevTotals = state?.totals;
54
+ const totals = {
55
+ input_tokens: prevTotals?.input_tokens ?? 0,
56
+ output_tokens: prevTotals?.output_tokens ?? 0,
57
+ cache_read: prevTotals?.cache_read ?? 0,
58
+ cache_creation: prevTotals?.cache_creation ?? 0,
59
+ cost_usd: prevTotals?.cost_usd ?? 0,
60
+ context_used: prevTotals?.context_used ?? 0,
61
+ context_window: prevTotals?.context_window ?? 200000,
62
+ firstTs: prevTotals?.firstTs,
63
+ lastModel: prevTotals?.lastModel,
64
+ };
65
+ let lastInputUsd = 0;
66
+ let lastOutputUsd = 0;
67
+ let lastInputTokens = 0;
68
+ let lastOutputTokens = 0;
69
+ let hasNewAssistant = false;
70
+ for (const raw of newContent.split('\n')) {
71
+ const line = raw.trim();
72
+ if (!line)
74
73
  continue;
75
- const usage = msg.usage;
76
- const model = msg.model ?? 'claude-sonnet-4-6';
77
- if (firstTs === undefined && obj.timestamp) {
78
- try {
79
- firstTs = new Date(obj.timestamp).getTime();
74
+ try {
75
+ const obj = JSON.parse(line);
76
+ if (obj.type !== 'assistant')
77
+ continue;
78
+ const msg = obj.message;
79
+ if (!msg?.usage)
80
+ continue;
81
+ const usage = msg.usage;
82
+ const model = msg.model ?? 'claude-sonnet-4-6';
83
+ hasNewAssistant = true;
84
+ if (obj.timestamp) {
85
+ try {
86
+ const ts = new Date(obj.timestamp).getTime();
87
+ if (totals.firstTs === undefined)
88
+ totals.firstTs = ts;
89
+ totals.lastTs = ts;
90
+ }
91
+ catch { /* ignore */ }
80
92
  }
81
- catch { /* ignore */ }
93
+ totals.input_tokens += usage.input_tokens ?? 0;
94
+ totals.output_tokens += usage.output_tokens ?? 0;
95
+ totals.cache_read += usage.cache_read_input_tokens ?? 0;
96
+ totals.cache_creation += usage.cache_creation_input_tokens ?? 0;
97
+ totals.cost_usd += (0, pricing_1.calcCost)(model, usage);
98
+ totals.context_used = (usage.input_tokens ?? 0)
99
+ + (usage.cache_read_input_tokens ?? 0)
100
+ + (usage.cache_creation_input_tokens ?? 0);
101
+ totals.context_window = (0, pricing_1.getContextWindow)(model);
102
+ const price = pricing_1.PRICING[model] ?? pricing_1.DEFAULT_PRICING;
103
+ const M = 1000000;
104
+ const inp = usage.input_tokens ?? 0;
105
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
106
+ const cacheCreate = usage.cache_creation_input_tokens ?? 0;
107
+ const out = usage.output_tokens ?? 0;
108
+ lastInputUsd = (inp * price.input + cacheRead * price.cacheRead + cacheCreate * price.cacheCreate) / M;
109
+ lastOutputUsd = out * price.output / M;
110
+ lastInputTokens = inp + cacheRead + cacheCreate;
111
+ lastOutputTokens = out;
112
+ totals.lastModel = model;
82
113
  }
83
- totals.input_tokens += usage.input_tokens ?? 0;
84
- totals.output_tokens += usage.output_tokens ?? 0;
85
- totals.cache_read += usage.cache_read_input_tokens ?? 0;
86
- totals.cache_creation += usage.cache_creation_input_tokens ?? 0;
87
- totals.cost_usd += (0, pricing_1.calcCost)(model, usage);
88
- totals.context_used = (usage.input_tokens ?? 0)
89
- + (usage.cache_read_input_tokens ?? 0)
90
- + (usage.cache_creation_input_tokens ?? 0);
91
- totals.context_window = getContextWindow(model);
92
- const price = pricing_1.PRICING[model] ?? pricing_1.DEFAULT_PRICING;
93
- const M = 1000000;
94
- lastInputUsd = ((usage.input_tokens ?? 0) * price.input +
95
- (usage.cache_read_input_tokens ?? 0) * price.cacheRead +
96
- (usage.cache_creation_input_tokens ?? 0) * price.cacheCreate) / M;
97
- lastOutputUsd = ((usage.output_tokens ?? 0) * price.output) / M;
98
- lastInputTokens = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
99
- lastOutputTokens = usage.output_tokens ?? 0;
100
- lastModel = model ?? lastModel;
114
+ catch { /* skip malformed lines */ }
115
+ }
116
+ // lastEntry only when there are new API calls — drives the block_cost SSE event
117
+ if (hasNewAssistant && lastInputUsd + lastOutputUsd > 0) {
118
+ const totalUsd = lastInputUsd + lastOutputUsd;
119
+ totals.lastEntry = {
120
+ inputUsd: lastInputUsd, outputUsd: lastOutputUsd,
121
+ totalUsd,
122
+ inputTokens: lastInputTokens, outputTokens: lastOutputTokens,
123
+ };
101
124
  }
102
- catch { /* skip malformed lines */ }
125
+ fileOffsets.set(filePath, { offset: currentSize, lastAccess: Date.now(), totals });
126
+ return totals;
103
127
  }
104
- if (lastInputUsd + lastOutputUsd > 0) {
105
- totals.lastEntry = {
106
- inputUsd: lastInputUsd, outputUsd: lastOutputUsd,
107
- totalUsd: lastInputUsd + lastOutputUsd,
108
- inputTokens: lastInputTokens, outputTokens: lastOutputTokens,
109
- };
128
+ catch {
129
+ return null;
130
+ }
131
+ finally {
132
+ await fd?.close();
110
133
  }
111
- totals.lastModel = lastModel;
112
- totals.firstTs = firstTs;
113
- fileOffsets.set(filePath, { offset: currentSize, lastAccess: Date.now() });
114
- return totals;
115
134
  }
116
135
  exports.claudeCodeAdapter = {
117
136
  name: 'claude-code',
@@ -152,6 +171,112 @@ exports.claudeCodeAdapter = {
152
171
  };
153
172
  setInterval(cleanupStaleOffsets, 5 * 60000).unref();
154
173
  (0, adapter_1.registerAdapter)(exports.claudeCodeAdapter);
174
+ async function findJSONLForSession(sessionId) {
175
+ try {
176
+ if (!fs_1.default.existsSync(projectsDir()))
177
+ return null;
178
+ const dirs = await promises_1.default.readdir(projectsDir());
179
+ for (const dir of dirs) {
180
+ const dirPath = path_1.default.join(projectsDir(), dir);
181
+ try {
182
+ const stat = await promises_1.default.stat(dirPath);
183
+ if (!stat.isDirectory())
184
+ continue;
185
+ }
186
+ catch {
187
+ continue;
188
+ }
189
+ const filePath = path_1.default.join(dirPath, `${sessionId}.jsonl`);
190
+ try {
191
+ await promises_1.default.access(filePath);
192
+ return filePath;
193
+ }
194
+ catch {
195
+ continue;
196
+ }
197
+ }
198
+ }
199
+ catch { /* ignore */ }
200
+ return null;
201
+ }
202
+ async function extractSemanticData(filePath) {
203
+ try {
204
+ const content = await promises_1.default.readFile(filePath, 'utf8');
205
+ const turns = [];
206
+ let pendingTurn = null;
207
+ let totalErrorBlocks = 0;
208
+ let turnIndex = 0;
209
+ for (const raw of content.split('\n')) {
210
+ const line = raw.trim();
211
+ if (!line)
212
+ continue;
213
+ try {
214
+ const obj = JSON.parse(line);
215
+ if (obj.type === 'assistant') {
216
+ if (pendingTurn)
217
+ turns.push(pendingTurn);
218
+ const msgContent = obj.message?.content;
219
+ if (!Array.isArray(msgContent)) {
220
+ pendingTurn = null;
221
+ continue;
222
+ }
223
+ let outputChars = 0;
224
+ const textParts = [];
225
+ const toolCalls = [];
226
+ for (const block of msgContent) {
227
+ if (block?.type === 'text' && typeof block.text === 'string') {
228
+ outputChars += block.text.length;
229
+ textParts.push(block.text);
230
+ }
231
+ else if (block?.type === 'tool_use' && typeof block.name === 'string') {
232
+ toolCalls.push(block.name);
233
+ }
234
+ }
235
+ let ts;
236
+ if (obj.timestamp) {
237
+ try {
238
+ ts = new Date(obj.timestamp).getTime();
239
+ }
240
+ catch { /* ignore */ }
241
+ }
242
+ const usage = obj.message?.usage;
243
+ const contextUsed = usage
244
+ ? ((usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0))
245
+ : 0;
246
+ pendingTurn = {
247
+ turn_index: turnIndex++,
248
+ ts,
249
+ text_preview: textParts.join('\n').slice(0, 500),
250
+ tool_calls: toolCalls,
251
+ error_count: 0,
252
+ output_chars: outputChars,
253
+ context_used: contextUsed,
254
+ };
255
+ }
256
+ else if ((obj.type === 'human' || obj.type === 'user') && pendingTurn) {
257
+ const msgContent = obj.message?.content;
258
+ if (!Array.isArray(msgContent))
259
+ continue;
260
+ for (const block of msgContent) {
261
+ if (block?.type === 'tool_result' && block.is_error === true) {
262
+ pendingTurn.error_count++;
263
+ totalErrorBlocks++;
264
+ }
265
+ }
266
+ }
267
+ }
268
+ catch { /* skip malformed lines */ }
269
+ }
270
+ if (pendingTurn)
271
+ turns.push(pendingTurn);
272
+ const totalOutputChars = turns.reduce((sum, t) => sum + t.output_chars, 0);
273
+ const avg_output_chars = turns.length > 0 ? Math.round(totalOutputChars / turns.length) : 0;
274
+ return { turns, avg_output_chars, error_block_count: totalErrorBlocks };
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ }
155
280
  // ─── Session-level utilities (used by routes/stream and routes/misc) ───────────
156
281
  const blockCostCache = new Map();
157
282
  const costCacheLocks = new Map();
@@ -7,3 +7,4 @@
7
7
  */
8
8
  import { type PollableAdapter } from './adapter';
9
9
  export declare const opencodeAdapter: PollableAdapter;
10
+ export declare function isSessionArchived(sessionId: string): boolean;