@yancyyu/openhermit 1.6.27 → 1.6.28

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 (69) hide show
  1. package/README.md +7 -1
  2. package/bin/hermit.mjs +2 -2
  3. package/dist-renderer/assets/{ProjectEditorOverlay-BBwYdXPv.js → ProjectEditorOverlay-A4DZTvSy.js} +1 -1
  4. package/dist-renderer/assets/{TeamGraphOverlay-DVq8rt6_.js → TeamGraphOverlay-Ba5njic5.js} +1 -1
  5. package/dist-renderer/assets/{_basePickBy-ZbF0pKvS.js → _basePickBy-BvnK-OC1.js} +1 -1
  6. package/dist-renderer/assets/{_baseUniq-BBLBOeXc.js → _baseUniq-DmFYXx9G.js} +1 -1
  7. package/dist-renderer/assets/{arc-wGaEgkCf.js → arc-DX4ZQFY4.js} +1 -1
  8. package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-BpMkdC35.js → architectureDiagram-VXUJARFQ-DfYr3vEN.js} +1 -1
  9. package/dist-renderer/assets/{blockDiagram-VD42YOAC-C8Z1xhG4.js → blockDiagram-VD42YOAC-DuXdVeWn.js} +1 -1
  10. package/dist-renderer/assets/{c4Diagram-YG6GDRKO-CJmlw9LA.js → c4Diagram-YG6GDRKO-Bw2nixXe.js} +1 -1
  11. package/dist-renderer/assets/channel-Pre42N5O.js +1 -0
  12. package/dist-renderer/assets/{chunk-4BX2VUAB-CHPHiRPP.js → chunk-4BX2VUAB-DLiNGQoE.js} +1 -1
  13. package/dist-renderer/assets/{chunk-55IACEB6-DyVohOQb.js → chunk-55IACEB6-B1L_8VIF.js} +1 -1
  14. package/dist-renderer/assets/{chunk-B4BG7PRW-p5bffh_R.js → chunk-B4BG7PRW-DaZMWKGk.js} +1 -1
  15. package/dist-renderer/assets/{chunk-DI55MBZ5-BnfGPSUu.js → chunk-DI55MBZ5-ku-dflJG.js} +1 -1
  16. package/dist-renderer/assets/{chunk-FMBD7UC4-B6SCKseX.js → chunk-FMBD7UC4-DV-mF1dP.js} +1 -1
  17. package/dist-renderer/assets/{chunk-QN33PNHL-L12RvLBR.js → chunk-QN33PNHL-ByGcDFQ0.js} +1 -1
  18. package/dist-renderer/assets/{chunk-QZHKN3VN-DeH1Kxge.js → chunk-QZHKN3VN-7dv-Min8.js} +1 -1
  19. package/dist-renderer/assets/{chunk-TZMSLE5B-BWnjzSlI.js → chunk-TZMSLE5B-WdXL5fTu.js} +1 -1
  20. package/dist-renderer/assets/classDiagram-2ON5EDUG-CdJsTJsj.js +1 -0
  21. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-CdJsTJsj.js +1 -0
  22. package/dist-renderer/assets/clone-BjQBiNfj.js +1 -0
  23. package/dist-renderer/assets/{cose-bilkent-S5V4N54A-BtzoT5fu.js → cose-bilkent-S5V4N54A-CNcsvqPl.js} +1 -1
  24. package/dist-renderer/assets/{dagre-6UL2VRFP-CBBvuoUD.js → dagre-6UL2VRFP-DBNx4qqx.js} +1 -1
  25. package/dist-renderer/assets/{diagram-PSM6KHXK-Be9BAKws.js → diagram-PSM6KHXK-BfVlT6sT.js} +1 -1
  26. package/dist-renderer/assets/{diagram-QEK2KX5R-BDS4PI_i.js → diagram-QEK2KX5R-HvVjs0K6.js} +1 -1
  27. package/dist-renderer/assets/{diagram-S2PKOQOG-2Rameaq7.js → diagram-S2PKOQOG-DYb_KnWS.js} +1 -1
  28. package/dist-renderer/assets/{erDiagram-Q2GNP2WA-CSIzCEZD.js → erDiagram-Q2GNP2WA-Ba-IgI5G.js} +1 -1
  29. package/dist-renderer/assets/{flowDiagram-NV44I4VS-ForEIVM5.js → flowDiagram-NV44I4VS-2iDN8Kpj.js} +1 -1
  30. package/dist-renderer/assets/{ganttDiagram-JELNMOA3-BJrli_xr.js → ganttDiagram-JELNMOA3-Byjf8Fa3.js} +1 -1
  31. package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-C_4GuLno.js → gitGraphDiagram-V2S2FVAM-DbKvfZ_j.js} +1 -1
  32. package/dist-renderer/assets/{graph-B1EAT_gw.js → graph-Enirf-f8.js} +1 -1
  33. package/dist-renderer/assets/{index-eKRmS5kI.js → index-AjxP_rE_.js} +1 -1
  34. package/dist-renderer/assets/index-BIOJremZ.css +1 -0
  35. package/dist-renderer/assets/{index-Dwr5wu5x.js → index-COZPUWJW.js} +1 -1
  36. package/dist-renderer/assets/{index-k4tnOFC5.js → index-ChR1D6ZF.js} +1 -1
  37. package/dist-renderer/assets/{index-DR602dwJ.js → index-CtlzGepK.js} +1 -1
  38. package/dist-renderer/assets/{index-DYdseEwc.js → index-DY1zqsb6.js} +511 -511
  39. package/dist-renderer/assets/{index-DOA_jbYb.js → index-DdhqolqE.js} +1 -1
  40. package/dist-renderer/assets/{infoDiagram-HS3SLOUP-DjI0uaMz.js → infoDiagram-HS3SLOUP-D6uicwz1.js} +1 -1
  41. package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-jQ6Thae-.js → journeyDiagram-XKPGCS4Q-DqwZsXlQ.js} +1 -1
  42. package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-CKw6InbL.js → kanban-definition-3W4ZIXB7-fCDVhVUm.js} +1 -1
  43. package/dist-renderer/assets/{layout-Dad20y3V.js → layout-CPFgj98r.js} +1 -1
  44. package/dist-renderer/assets/{linear-vMgo_2Cv.js → linear-CYiQ7Y3M.js} +1 -1
  45. package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-DYp6YoHL.js → mindmap-definition-VGOIOE7T-D31dS2KE.js} +1 -1
  46. package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BytBecG9.js → pieDiagram-ADFJNKIX-BOsCJfds.js} +1 -1
  47. package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-RUaspLsc.js → quadrantDiagram-AYHSOK5B-CYTVQCfr.js} +1 -1
  48. package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-rR2B1Use.js → requirementDiagram-UZGBJVZJ-CODCFpkt.js} +1 -1
  49. package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-BJi5qYhq.js → sankeyDiagram-TZEHDZUN-Z4ce9ZtZ.js} +1 -1
  50. package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-BM-wggUb.js → sequenceDiagram-WL72ISMW-CmS9TxhW.js} +1 -1
  51. package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-BqmcVjnj.js → stateDiagram-FKZM4ZOC-o9k-ns3q.js} +1 -1
  52. package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-By3JDVbB.js → stateDiagram-v2-4FDKWEC3-CxHMyEt1.js} +1 -1
  53. package/dist-renderer/assets/{timeline-definition-IT6M3QCI-szH0GUyk.js → timeline-definition-IT6M3QCI-B6T3zrde.js} +1 -1
  54. package/dist-renderer/assets/{treemap-GDKQZRPO-BCMlh-Ex.js → treemap-GDKQZRPO-CVd5GNDw.js} +1 -1
  55. package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-dwDpvw0w.js → xychartDiagram-PRI3JC2R-CleBrdqc.js} +1 -1
  56. package/dist-renderer/index.html +2 -2
  57. package/package.json +1 -1
  58. package/src/main/server.ts +120 -3
  59. package/src/main/services/session-intelligence/SessionUsageParser.ts +446 -0
  60. package/src/main/services/session-intelligence/UsageTelemetryService.ts +237 -0
  61. package/src/renderer/components/dashboard/DashboardView.tsx +6 -105
  62. package/src/renderer/components/settings/SettingsTabs.tsx +2 -2
  63. package/src/renderer/components/settings/sections/TaskBusSection.tsx +463 -83
  64. package/src/shared/types/team.ts +5 -0
  65. package/dist-renderer/assets/channel-DJUrwVrK.js +0 -1
  66. package/dist-renderer/assets/classDiagram-2ON5EDUG-blc3DrH7.js +0 -1
  67. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-blc3DrH7.js +0 -1
  68. package/dist-renderer/assets/clone-BftjWakJ.js +0 -1
  69. package/dist-renderer/assets/index-CWpFqEvz.css +0 -1
@@ -49,6 +49,12 @@ import { TeamProvisioningService } from './services/teams-mvp';
49
49
  import { TaskDispatchService } from './services/teams-mvp/TaskDispatchService';
50
50
  import type { TaskBusConfig } from '@shared/types/team';
51
51
  import { UpdateService } from './services/UpdateService';
52
+ import {
53
+ startTelemetry,
54
+ stopTelemetry,
55
+ triggerScan,
56
+ getTelemetryStatus,
57
+ } from './services/session-intelligence/UsageTelemetryService';
52
58
 
53
59
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
54
60
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '../../package.json'), 'utf-8'));
@@ -3938,18 +3944,29 @@ app.get<{ Params: { name: string } }>('/api/cross-team/outbox/:name', async (req
3938
3944
  return { pending };
3939
3945
  });
3940
3946
 
3941
- // Task bus settings
3947
+ // GET /api/settings/task-bus → full config including telemetry
3942
3948
  app.get('/api/settings/task-bus', async () => {
3943
3949
  const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
3944
3950
  try {
3945
3951
  const raw = await fs.readFile(configPath, 'utf-8');
3946
3952
  const settings = JSON.parse(raw);
3947
- return settings.taskBus ?? { enabled: false, redis: { host: '127.0.0.1', port: 6379 } };
3953
+ return (
3954
+ settings.taskBus ?? {
3955
+ enabled: false,
3956
+ redis: { host: '127.0.0.1', port: 6379 },
3957
+ telemetry: { enabled: false, platform: 'claudecode' },
3958
+ }
3959
+ );
3948
3960
  } catch {
3949
- return { enabled: false, redis: { host: '127.0.0.1', port: 6379 } };
3961
+ return {
3962
+ enabled: false,
3963
+ redis: { host: '127.0.0.1', port: 6379 },
3964
+ telemetry: { enabled: false, platform: 'claudecode' },
3965
+ };
3950
3966
  }
3951
3967
  });
3952
3968
 
3969
+ // PUT /api/settings/task-bus → save config + start/stop telemetry
3953
3970
  app.put<{ Body: TaskBusConfig }>('/api/settings/task-bus', async (request) => {
3954
3971
  const config = request.body;
3955
3972
  const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
@@ -3964,6 +3981,13 @@ app.put<{ Body: TaskBusConfig }>('/api/settings/task-bus', async (request) => {
3964
3981
  await fs.mkdir(path.dirname(configPath), { recursive: true });
3965
3982
  await fs.writeFile(configPath, JSON.stringify(settings, null, 2));
3966
3983
 
3984
+ // Sync telemetry service
3985
+ if (config.telemetry?.enabled) {
3986
+ await startTelemetry(config);
3987
+ } else {
3988
+ await stopTelemetry();
3989
+ }
3990
+
3967
3991
  // Auto-inject CLAUDE.md instructions when enabling
3968
3992
  if (config?.enabled) {
3969
3993
  try {
@@ -4016,6 +4040,99 @@ app.put<{ Body: TaskBusConfig }>('/api/settings/task-bus', async (request) => {
4016
4040
  return { ok: true, connected: false, message: 'Task bus disabled' };
4017
4041
  });
4018
4042
 
4043
+ // POST /api/telemetry/scan → trigger manual scan
4044
+ app.post('/api/telemetry/scan', async (request, reply) => {
4045
+ try {
4046
+ const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
4047
+ let settings: Record<string, unknown> = {};
4048
+ try {
4049
+ const raw = await fs.readFile(configPath, 'utf-8');
4050
+ settings = JSON.parse(raw);
4051
+ } catch {
4052
+ // no settings
4053
+ }
4054
+ const taskBus = (settings.taskBus ?? {}) as TaskBusConfig;
4055
+ if (!taskBus.telemetry?.enabled) {
4056
+ return reply.code(400).send({ error: 'Telemetry is not enabled' });
4057
+ }
4058
+ const result = await triggerScan(taskBus);
4059
+ if (!result) {
4060
+ return reply.code(503).send({ error: 'Redis not available' });
4061
+ }
4062
+ return {
4063
+ ok: true,
4064
+ ...result.aggregate,
4065
+ sessions: result.sessions.length,
4066
+ lastScan: new Date().toISOString(),
4067
+ };
4068
+ } catch (err) {
4069
+ return reply.code(500).send({ error: String(err) });
4070
+ }
4071
+ });
4072
+
4073
+ // GET /api/telemetry/status → current telemetry status (full stats)
4074
+ app.get('/api/telemetry/status', async (request, reply) => {
4075
+ try {
4076
+ const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
4077
+ let settings: Record<string, unknown> = {};
4078
+ try {
4079
+ const raw = await fs.readFile(configPath, 'utf-8');
4080
+ settings = JSON.parse(raw);
4081
+ } catch {
4082
+ // no settings
4083
+ }
4084
+ const taskBus = (settings.taskBus ?? {}) as TaskBusConfig;
4085
+ if (!taskBus.redis) {
4086
+ return {
4087
+ connected: false,
4088
+ lastScan: null,
4089
+ sessions: 0,
4090
+ messages: 0,
4091
+ tokensIn: 0,
4092
+ tokensOut: 0,
4093
+ cacheRead: 0,
4094
+ cacheCreation: 0,
4095
+ activeDays: 0,
4096
+ hourly: [],
4097
+ projects: [],
4098
+ workSecondsByDay: {},
4099
+ };
4100
+ }
4101
+ const status = await getTelemetryStatus(taskBus.redis);
4102
+ return (
4103
+ status ?? {
4104
+ connected: false,
4105
+ lastScan: null,
4106
+ sessions: 0,
4107
+ messages: 0,
4108
+ tokensIn: 0,
4109
+ tokensOut: 0,
4110
+ cacheRead: 0,
4111
+ cacheCreation: 0,
4112
+ activeDays: 0,
4113
+ hourly: [],
4114
+ projects: [],
4115
+ workSecondsByDay: {},
4116
+ }
4117
+ );
4118
+ } catch {
4119
+ return {
4120
+ connected: false,
4121
+ lastScan: null,
4122
+ sessions: 0,
4123
+ messages: 0,
4124
+ tokensIn: 0,
4125
+ tokensOut: 0,
4126
+ cacheRead: 0,
4127
+ cacheCreation: 0,
4128
+ activeDays: 0,
4129
+ hourly: [],
4130
+ projects: [],
4131
+ workSecondsByDay: {},
4132
+ };
4133
+ }
4134
+ });
4135
+
4019
4136
  app.get<{ Params: { name: string; memberName: string } }>(
4020
4137
  '/api/teams/:name/review/agent-changes/:memberName',
4021
4138
  async (request) => ({
@@ -0,0 +1,446 @@
1
+ /**
2
+ * SessionUsageParser - reads Claude Code JSONL session files and extracts
3
+ * metadata-only usage metrics (tokens, message counts, tool calls).
4
+ *
5
+ * Modeled after CCPal's parse_jsonl() / build_index() from
6
+ * https://github.com/lujinian1982/ccpal
7
+ */
8
+
9
+ import { createReadStream } from 'node:fs';
10
+ import { readdir, stat } from 'node:fs/promises';
11
+ import { createInterface } from 'node:readline';
12
+ import * as path from 'node:path';
13
+ import * as os from 'node:os';
14
+
15
+ export interface SessionEntry {
16
+ relPath: string;
17
+ projectPath: string;
18
+ title: string;
19
+ messageCount: number;
20
+ toolCalls: Record<string, number>;
21
+ tokens: {
22
+ input: number;
23
+ output: number;
24
+ cacheRead: number;
25
+ cacheCreation: number;
26
+ };
27
+ startTime: string;
28
+ endTime: string;
29
+ fileSize: number;
30
+ mtime: number;
31
+ isWorktree: boolean;
32
+ }
33
+
34
+ export interface UsageAggregate {
35
+ sessions: number;
36
+ messages: number;
37
+ tokens: {
38
+ input: number;
39
+ output: number;
40
+ cacheRead: number;
41
+ cacheCreation: number;
42
+ };
43
+ activeDays: number;
44
+ daily: Record<string, DailyMetrics>;
45
+ hourly: number[];
46
+ projects: ProjectMetricsEntry[];
47
+ events7d: EventEntry[];
48
+ workSecondsByDay: Record<string, number>;
49
+ }
50
+
51
+ export interface DailyMetrics {
52
+ sessions: number;
53
+ messages: number;
54
+ tokensIn: number;
55
+ tokensOut: number;
56
+ cacheRead: number;
57
+ cacheCreation: number;
58
+ workSeconds: number;
59
+ }
60
+
61
+ export interface ProjectMetricsEntry {
62
+ cwd: string;
63
+ sessions: number;
64
+ messages: number;
65
+ tokensIn: number;
66
+ tokensOut: number;
67
+ }
68
+
69
+ export interface EventEntry {
70
+ ts: number;
71
+ tokensIn: number;
72
+ tokensOut: number;
73
+ cacheRead: number;
74
+ cacheCreation: number;
75
+ }
76
+
77
+ export interface ParseResult {
78
+ sessions: SessionEntry[];
79
+ aggregate: UsageAggregate;
80
+ }
81
+
82
+ const PROJECTS_ROOT = path.join(os.homedir(), '.claude', 'projects');
83
+ const SEG_GAP_MS = 10 * 60 * 1000; // 10 minutes gap threshold
84
+ const RECENT_DAYS = 7;
85
+
86
+ function extractFirstUserText(content: unknown): string {
87
+ if (typeof content === 'string') {
88
+ return content.slice(0, 200).trim();
89
+ }
90
+ if (!Array.isArray(content)) return '';
91
+ for (const block of content) {
92
+ if (typeof block !== 'object' || block === null) continue;
93
+ const b = block as Record<string, unknown>;
94
+ if (b.type === 'text' && typeof b.text === 'string') {
95
+ return b.text.slice(0, 200).trim();
96
+ }
97
+ }
98
+ return '';
99
+ }
100
+
101
+ function smartTitle(text: string, maxLen = 90): string {
102
+ if (!text) return '';
103
+ let t = text;
104
+ if (t.startsWith('@') && t.includes(' ')) {
105
+ t = t.slice(t.indexOf(' ') + 1).trim();
106
+ }
107
+ let cut = t.length;
108
+ for (const sep of ['\n', '。', '?', '!', ';']) {
109
+ const i = t.indexOf(sep);
110
+ if (5 < i && i < maxLen && i < cut) cut = i;
111
+ }
112
+ return t.slice(0, cut < t.length ? cut : maxLen).trim();
113
+ }
114
+
115
+ function extractToolCalls(content: unknown, counts: Record<string, number>): void {
116
+ if (!Array.isArray(content)) return;
117
+ for (const block of content) {
118
+ if (typeof block !== 'object' || block === null) continue;
119
+ const b = block as Record<string, unknown>;
120
+ if (b.type === 'tool_use' && typeof b.name === 'string') {
121
+ counts[b.name] = (counts[b.name] ?? 0) + 1;
122
+ }
123
+ }
124
+ }
125
+
126
+ function normalizeCwd(cwd: string): { normalized: string; isWorktree: boolean } {
127
+ if (!cwd) return { normalized: cwd, isWorktree: false };
128
+ const parts = cwd.split('/');
129
+ const idx = parts.indexOf('.claude');
130
+ const isWorktree = idx >= 0 && idx + 1 < parts.length && parts[idx + 1] === 'worktrees';
131
+ const normalized = isWorktree ? parts.slice(0, idx).join('/') : cwd;
132
+ return { normalized, isWorktree };
133
+ }
134
+
135
+ interface ParsedSession {
136
+ title: string;
137
+ projectPath: string;
138
+ isWorktree: boolean;
139
+ messageCount: number;
140
+ toolCalls: Record<string, number>;
141
+ tokens: { input: number; output: number; cacheRead: number; cacheCreation: number };
142
+ startTime: string;
143
+ endTime: string;
144
+ dailyTokens: Record<string, DailyMetrics>;
145
+ hourly: number[];
146
+ events: EventEntry[];
147
+ }
148
+
149
+ async function parseJsonl(filePath: string): Promise<ParsedSession | null> {
150
+ let messageCount = 0;
151
+ let title = '';
152
+ let rawCwd = '';
153
+ let isWorktree = false;
154
+ const toolCalls: Record<string, number> = {};
155
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
156
+ let startTime = '';
157
+ let endTime = '';
158
+ const dailyTokens: Record<string, DailyMetrics> = {};
159
+ const hourly: number[] = new Array(24).fill(0);
160
+ const events: EventEntry[] = [];
161
+
162
+ const rl = createInterface({ input: createReadStream(filePath, 'utf-8'), crlfDelay: Infinity });
163
+
164
+ for await (const rawLine of rl) {
165
+ const line = rawLine.trim();
166
+ if (!line) continue;
167
+
168
+ let obj: Record<string, unknown>;
169
+ try {
170
+ obj = JSON.parse(line) as Record<string, unknown>;
171
+ } catch {
172
+ continue;
173
+ }
174
+
175
+ if (!rawCwd && typeof obj.cwd === 'string') {
176
+ rawCwd = obj.cwd;
177
+ const { normalized, isWorktree: iw } = normalizeCwd(rawCwd);
178
+ isWorktree = iw;
179
+ }
180
+
181
+ const msg = obj.message as Record<string, unknown> | undefined;
182
+ let role: string | undefined;
183
+ let content: unknown;
184
+ let usage: Record<string, unknown> | undefined;
185
+ let ts: string | undefined;
186
+
187
+ if (msg && typeof msg === 'object') {
188
+ role = msg.role as string | undefined;
189
+ content = msg.content;
190
+ usage = msg.usage as Record<string, unknown> | undefined;
191
+ ts = (obj.timestamp ?? msg.timestamp) as string | undefined;
192
+ } else if (obj.type === 'user' || obj.type === 'assistant') {
193
+ role = obj.type as string;
194
+ content = obj.content;
195
+ ts = obj.timestamp as string | undefined;
196
+ }
197
+
198
+ if (!role || !ts) continue;
199
+
200
+ messageCount++;
201
+
202
+ if (role === 'user' && !title && content) {
203
+ title = smartTitle(extractFirstUserText(content));
204
+ }
205
+
206
+ if (role === 'assistant' && content) {
207
+ extractToolCalls(content, toolCalls);
208
+ }
209
+
210
+ if (role === 'assistant' && usage && typeof usage === 'object') {
211
+ const inp = Number(usage.input_tokens ?? 0) || 0;
212
+ const out = Number(usage.output_tokens ?? 0) || 0;
213
+ const cread = Number(usage.cache_read_input_tokens ?? 0) || 0;
214
+ const ccreate = Number(usage.cache_creation_input_tokens ?? 0) || 0;
215
+
216
+ tokens.input += inp;
217
+ tokens.output += out;
218
+ tokens.cacheRead += cread;
219
+ tokens.cacheCreation += ccreate;
220
+
221
+ // Daily aggregation
222
+ const day = ts.slice(0, 10);
223
+ if (day.length === 10) {
224
+ const d = (dailyTokens[day] ??= {
225
+ sessions: 0,
226
+ messages: 0,
227
+ tokensIn: 0,
228
+ tokensOut: 0,
229
+ cacheRead: 0,
230
+ cacheCreation: 0,
231
+ workSeconds: 0,
232
+ });
233
+ d.messages++;
234
+ d.tokensIn += inp;
235
+ d.tokensOut += out;
236
+ d.cacheRead += cread;
237
+ d.cacheCreation += ccreate;
238
+ }
239
+
240
+ // Hourly distribution
241
+ const hour = Number(ts.slice(11, 13));
242
+ if (hour >= 0 && hour < 24) {
243
+ hourly[hour]++;
244
+ }
245
+
246
+ // Events for work seconds calculation
247
+ const tsUnix = Date.parse(ts) / 1000;
248
+ if (!isNaN(tsUnix)) {
249
+ events.push({
250
+ ts: tsUnix,
251
+ tokensIn: inp,
252
+ tokensOut: out,
253
+ cacheRead: cread,
254
+ cacheCreation: ccreate,
255
+ });
256
+ }
257
+ }
258
+
259
+ if (!startTime) startTime = ts;
260
+ endTime = ts;
261
+ }
262
+
263
+ if (messageCount === 0) return null;
264
+
265
+ return {
266
+ title,
267
+ projectPath: normalizeCwd(rawCwd).normalized,
268
+ isWorktree,
269
+ messageCount,
270
+ toolCalls,
271
+ tokens,
272
+ startTime,
273
+ endTime,
274
+ dailyTokens,
275
+ hourly,
276
+ events,
277
+ };
278
+ }
279
+
280
+ async function* walkJsonl(dir: string): AsyncGenerator<string> {
281
+ let entries;
282
+ try {
283
+ entries = await readdir(dir, { withFileTypes: true });
284
+ } catch {
285
+ return;
286
+ }
287
+ for (const entry of entries) {
288
+ const full = path.join(dir, entry.name);
289
+ if (entry.isDirectory()) {
290
+ yield* walkJsonl(full);
291
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
292
+ yield full;
293
+ }
294
+ }
295
+ }
296
+
297
+ function calcWorkSeconds(events: EventEntry[]): Record<string, number> {
298
+ // Group events by day
299
+ const byDay: Record<string, number[]> = {};
300
+ for (const ev of events) {
301
+ const day = new Date(ev.ts * 1000).toISOString().slice(0, 10);
302
+ if (!byDay[day]) byDay[day] = [];
303
+ byDay[day].push(ev.ts);
304
+ }
305
+
306
+ const workSeconds: Record<string, number> = {};
307
+ for (const [day, tsList] of Object.entries(byDay)) {
308
+ if (tsList.length === 0) continue;
309
+ tsList.sort((a, b) => a - b);
310
+
311
+ let total = 0;
312
+ let segStart = tsList[0];
313
+ let last = tsList[0];
314
+
315
+ for (const t of tsList.slice(1)) {
316
+ if (t - last > SEG_GAP_MS / 1000) {
317
+ total += last - segStart;
318
+ segStart = t;
319
+ }
320
+ last = t;
321
+ }
322
+ total += last - segStart;
323
+
324
+ // Minimum 60 seconds if there are events but total is 0
325
+ if (total === 0 && tsList.length > 0) {
326
+ total = 60;
327
+ }
328
+
329
+ workSeconds[day] = Math.round(total);
330
+ }
331
+
332
+ return workSeconds;
333
+ }
334
+
335
+ export async function scanSessions(): Promise<ParseResult> {
336
+ const sessions: SessionEntry[] = [];
337
+ const aggregate: UsageAggregate = {
338
+ sessions: 0,
339
+ messages: 0,
340
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 },
341
+ activeDays: 0,
342
+ daily: {},
343
+ hourly: new Array(24).fill(0),
344
+ projects: [],
345
+ events7d: [],
346
+ workSecondsByDay: {},
347
+ };
348
+
349
+ const activeDaySet = new Set<string>();
350
+ const allEvents: EventEntry[] = [];
351
+ const projectMap: Record<string, ProjectMetricsEntry> = {};
352
+
353
+ for await (const filePath of walkJsonl(PROJECTS_ROOT)) {
354
+ let fileStat;
355
+ try {
356
+ fileStat = await stat(filePath);
357
+ } catch {
358
+ continue;
359
+ }
360
+
361
+ const parsed = await parseJsonl(filePath);
362
+ if (!parsed) continue;
363
+
364
+ const relPath = path.relative(PROJECTS_ROOT, filePath);
365
+ sessions.push({
366
+ relPath,
367
+ projectPath: parsed.projectPath,
368
+ title: parsed.title,
369
+ messageCount: parsed.messageCount,
370
+ toolCalls: parsed.toolCalls,
371
+ tokens: parsed.tokens,
372
+ startTime: parsed.startTime,
373
+ endTime: parsed.endTime,
374
+ fileSize: fileStat.size,
375
+ mtime: fileStat.mtimeMs,
376
+ isWorktree: parsed.isWorktree,
377
+ });
378
+
379
+ aggregate.sessions++;
380
+ aggregate.messages += parsed.messageCount;
381
+ aggregate.tokens.input += parsed.tokens.input;
382
+ aggregate.tokens.output += parsed.tokens.output;
383
+ aggregate.tokens.cacheRead += parsed.tokens.cacheRead;
384
+ aggregate.tokens.cacheCreation += parsed.tokens.cacheCreation;
385
+
386
+ // Hourly
387
+ for (let h = 0; h < 24; h++) {
388
+ aggregate.hourly[h] += parsed.hourly[h];
389
+ }
390
+
391
+ // Events
392
+ allEvents.push(...parsed.events);
393
+
394
+ // Daily
395
+ for (const [day, m] of Object.entries(parsed.dailyTokens)) {
396
+ activeDaySet.add(day);
397
+ const d = (aggregate.daily[day] ??= {
398
+ sessions: 0,
399
+ messages: 0,
400
+ tokensIn: 0,
401
+ tokensOut: 0,
402
+ cacheRead: 0,
403
+ cacheCreation: 0,
404
+ workSeconds: 0,
405
+ });
406
+ d.sessions++;
407
+ d.messages += m.messages;
408
+ d.tokensIn += m.tokensIn;
409
+ d.tokensOut += m.tokensOut;
410
+ d.cacheRead += m.cacheRead;
411
+ d.cacheCreation += m.cacheCreation;
412
+ }
413
+
414
+ // Projects
415
+ const proj = parsed.projectPath || '(untracked)';
416
+ if (!projectMap[proj]) {
417
+ projectMap[proj] = { cwd: proj, sessions: 0, messages: 0, tokensIn: 0, tokensOut: 0 };
418
+ }
419
+ const p = projectMap[proj];
420
+ p.sessions++;
421
+ p.messages += parsed.messageCount;
422
+ p.tokensIn += parsed.tokens.input;
423
+ p.tokensOut += parsed.tokens.output;
424
+ }
425
+
426
+ // Work seconds per day
427
+ aggregate.workSecondsByDay = calcWorkSeconds(allEvents);
428
+ for (const [day, secs] of Object.entries(aggregate.workSecondsByDay)) {
429
+ if (aggregate.daily[day]) {
430
+ aggregate.daily[day].workSeconds = secs;
431
+ }
432
+ }
433
+
434
+ // 7-day rolling window events
435
+ const cutoff = Date.now() / 1000 - RECENT_DAYS * 86400;
436
+ aggregate.events7d = allEvents.filter((e) => e.ts >= cutoff).sort((a, b) => a.ts - b.ts);
437
+
438
+ aggregate.activeDays = activeDaySet.size;
439
+
440
+ // Projects sorted by messages descending
441
+ aggregate.projects = Object.values(projectMap).sort((a, b) => b.messages - a.messages);
442
+
443
+ sessions.sort((a, b) => b.endTime.localeCompare(a.endTime));
444
+
445
+ return { sessions, aggregate };
446
+ }