@yancyyu/openhermit 1.6.26 → 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 (74) hide show
  1. package/README.md +132 -80
  2. package/bin/hermit.mjs +2 -2
  3. package/dist-renderer/assets/{ProjectEditorOverlay-Byepdwo2.js → ProjectEditorOverlay-A4DZTvSy.js} +1 -1
  4. package/dist-renderer/assets/{TeamGraphOverlay-vvWu-2c9.js → TeamGraphOverlay-Ba5njic5.js} +1 -1
  5. package/dist-renderer/assets/{_basePickBy-DfsmMgXN.js → _basePickBy-BvnK-OC1.js} +1 -1
  6. package/dist-renderer/assets/{_baseUniq-Bve-IKz5.js → _baseUniq-DmFYXx9G.js} +1 -1
  7. package/dist-renderer/assets/{arc-4cbkhagw.js → arc-DX4ZQFY4.js} +1 -1
  8. package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-CC9i0bMK.js → architectureDiagram-VXUJARFQ-DfYr3vEN.js} +1 -1
  9. package/dist-renderer/assets/{blockDiagram-VD42YOAC-BjFruJ65.js → blockDiagram-VD42YOAC-DuXdVeWn.js} +1 -1
  10. package/dist-renderer/assets/{c4Diagram-YG6GDRKO-CrYzsQC1.js → c4Diagram-YG6GDRKO-Bw2nixXe.js} +1 -1
  11. package/dist-renderer/assets/channel-Pre42N5O.js +1 -0
  12. package/dist-renderer/assets/{chunk-4BX2VUAB-Bb9MCt7J.js → chunk-4BX2VUAB-DLiNGQoE.js} +1 -1
  13. package/dist-renderer/assets/{chunk-55IACEB6-BpOVOXVa.js → chunk-55IACEB6-B1L_8VIF.js} +1 -1
  14. package/dist-renderer/assets/{chunk-B4BG7PRW-GtEiO-7n.js → chunk-B4BG7PRW-DaZMWKGk.js} +1 -1
  15. package/dist-renderer/assets/{chunk-DI55MBZ5-BRlzcOEj.js → chunk-DI55MBZ5-ku-dflJG.js} +1 -1
  16. package/dist-renderer/assets/{chunk-FMBD7UC4-DcvMVOZx.js → chunk-FMBD7UC4-DV-mF1dP.js} +1 -1
  17. package/dist-renderer/assets/{chunk-QN33PNHL-B9pkjVpd.js → chunk-QN33PNHL-ByGcDFQ0.js} +1 -1
  18. package/dist-renderer/assets/{chunk-QZHKN3VN-DzHPSm01.js → chunk-QZHKN3VN-7dv-Min8.js} +1 -1
  19. package/dist-renderer/assets/{chunk-TZMSLE5B-BU9c0Hcn.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-BqOg0x3V.js → cose-bilkent-S5V4N54A-CNcsvqPl.js} +1 -1
  24. package/dist-renderer/assets/{dagre-6UL2VRFP-C9JTWefj.js → dagre-6UL2VRFP-DBNx4qqx.js} +1 -1
  25. package/dist-renderer/assets/{diagram-PSM6KHXK-ljleG6ui.js → diagram-PSM6KHXK-BfVlT6sT.js} +1 -1
  26. package/dist-renderer/assets/{diagram-QEK2KX5R-BbV-WSTr.js → diagram-QEK2KX5R-HvVjs0K6.js} +1 -1
  27. package/dist-renderer/assets/{diagram-S2PKOQOG-CKi3DFby.js → diagram-S2PKOQOG-DYb_KnWS.js} +1 -1
  28. package/dist-renderer/assets/{erDiagram-Q2GNP2WA-D3HE7b-j.js → erDiagram-Q2GNP2WA-Ba-IgI5G.js} +1 -1
  29. package/dist-renderer/assets/{flowDiagram-NV44I4VS-C2yLRmM0.js → flowDiagram-NV44I4VS-2iDN8Kpj.js} +1 -1
  30. package/dist-renderer/assets/{ganttDiagram-JELNMOA3-XEV4KtUf.js → ganttDiagram-JELNMOA3-Byjf8Fa3.js} +1 -1
  31. package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-ufaCCg7c.js → gitGraphDiagram-V2S2FVAM-DbKvfZ_j.js} +1 -1
  32. package/dist-renderer/assets/{graph-BzPvdBp0.js → graph-Enirf-f8.js} +1 -1
  33. package/dist-renderer/assets/{index-BprOls_t.js → index-AjxP_rE_.js} +1 -1
  34. package/dist-renderer/assets/index-BIOJremZ.css +1 -0
  35. package/dist-renderer/assets/{index-DHq6dXy7.js → index-COZPUWJW.js} +1 -1
  36. package/dist-renderer/assets/{index-Cr91T9ef.js → index-ChR1D6ZF.js} +1 -1
  37. package/dist-renderer/assets/{index-DUIDxnaf.js → index-CtlzGepK.js} +1 -1
  38. package/dist-renderer/assets/{index-A5CMVuXA.js → index-DY1zqsb6.js} +538 -538
  39. package/dist-renderer/assets/{index-yNYjzR2R.js → index-DdhqolqE.js} +1 -1
  40. package/dist-renderer/assets/{infoDiagram-HS3SLOUP-DKP5zgHc.js → infoDiagram-HS3SLOUP-D6uicwz1.js} +1 -1
  41. package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-Omd7tmzE.js → journeyDiagram-XKPGCS4Q-DqwZsXlQ.js} +1 -1
  42. package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-D7yw9yIY.js → kanban-definition-3W4ZIXB7-fCDVhVUm.js} +1 -1
  43. package/dist-renderer/assets/{layout-DZxAqFuM.js → layout-CPFgj98r.js} +1 -1
  44. package/dist-renderer/assets/{linear-BXWJygRB.js → linear-CYiQ7Y3M.js} +1 -1
  45. package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-BfJ09SBb.js → mindmap-definition-VGOIOE7T-D31dS2KE.js} +1 -1
  46. package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BYaLQhXj.js → pieDiagram-ADFJNKIX-BOsCJfds.js} +1 -1
  47. package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-DeA0B1fw.js → quadrantDiagram-AYHSOK5B-CYTVQCfr.js} +1 -1
  48. package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-DnFWn7-v.js → requirementDiagram-UZGBJVZJ-CODCFpkt.js} +1 -1
  49. package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-L9bek20k.js → sankeyDiagram-TZEHDZUN-Z4ce9ZtZ.js} +1 -1
  50. package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-BBmcJUXb.js → sequenceDiagram-WL72ISMW-CmS9TxhW.js} +1 -1
  51. package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-DrwPQvTq.js → stateDiagram-FKZM4ZOC-o9k-ns3q.js} +1 -1
  52. package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-BOUQrTH6.js → stateDiagram-v2-4FDKWEC3-CxHMyEt1.js} +1 -1
  53. package/dist-renderer/assets/{timeline-definition-IT6M3QCI-Dldh9vsj.js → timeline-definition-IT6M3QCI-B6T3zrde.js} +1 -1
  54. package/dist-renderer/assets/{treemap-GDKQZRPO-BsGSs8-P.js → treemap-GDKQZRPO-CVd5GNDw.js} +1 -1
  55. package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-BsR_bj-d.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 +137 -8
  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/api/httpClient.ts +1 -1
  62. package/src/renderer/components/dashboard/DashboardView.tsx +6 -105
  63. package/src/renderer/components/settings/SettingsTabs.tsx +2 -2
  64. package/src/renderer/components/settings/sections/TaskBusSection.tsx +463 -83
  65. package/src/renderer/components/team/TeamDetailView.tsx +40 -20
  66. package/src/renderer/components/team/TeamListView.tsx +35 -21
  67. package/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +10 -7
  68. package/src/renderer/components/team/dialogs/EditTeamDialog.tsx +9 -6
  69. package/src/shared/types/team.ts +5 -0
  70. package/dist-renderer/assets/channel-BMMyVRy4.js +0 -1
  71. package/dist-renderer/assets/classDiagram-2ON5EDUG-Dz1VG1T3.js +0 -1
  72. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-Dz1VG1T3.js +0 -1
  73. package/dist-renderer/assets/clone-COsIIGZQ.js +0 -1
  74. 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'));
@@ -682,7 +688,17 @@ app.patch<{ Body: Record<string, unknown> }>('/api/cc-settings', async (request)
682
688
  app.post('/api/cc-restart', async () => {
683
689
  try {
684
690
  await cc.restart();
685
- return { ok: true };
691
+ // Wait for cc-connect to come back (restart only signals, process respawns async)
692
+ for (let i = 0; i < 30; i++) {
693
+ await new Promise((r) => setTimeout(r, 1000));
694
+ try {
695
+ await cc.listProjects();
696
+ return { ok: true };
697
+ } catch {
698
+ /* not back yet */
699
+ }
700
+ }
701
+ return reply500(new Error('cc-connect did not come back within 30s'));
686
702
  } catch (err) {
687
703
  return reply500(err);
688
704
  }
@@ -764,7 +780,9 @@ app.get('/api/teams', async () => {
764
780
  };
765
781
  })
766
782
  );
767
- return summaries.filter((team) => team.pendingDelete !== true);
783
+ return summaries.filter(
784
+ (team) => team.pendingDelete !== true && team.teamName !== 'my-project'
785
+ );
768
786
  } catch {
769
787
  return [];
770
788
  }
@@ -807,7 +825,7 @@ app.post('/api/teams/create', async (request, reply) => {
807
825
  }
808
826
 
809
827
  // Bind provider refs if specified
810
- const providerRefs = Array.isArray(body.providerRefs) ? body.providerRefs as string[] : [];
828
+ const providerRefs = Array.isArray(body.providerRefs) ? (body.providerRefs as string[]) : [];
811
829
  if (providerRefs.length > 0) {
812
830
  try {
813
831
  await cc.setProviderRefs(name, providerRefs);
@@ -1050,8 +1068,8 @@ app.delete<{ Params: { name: string }; Querystring: { deleteFiles?: string } }>(
1050
1068
  '/api/teams/:name',
1051
1069
  async (request, reply) => {
1052
1070
  const teamName = request.params.name;
1053
- if (teamName === 'default') {
1054
- return reply.code(403).send({ error: 'default 团队不可删除' });
1071
+ if (teamName === 'default' || teamName === 'my-project') {
1072
+ return reply.code(403).send({ error: '该团队不可删除' });
1055
1073
  }
1056
1074
  try {
1057
1075
  let restartRequired = false;
@@ -3926,18 +3944,29 @@ app.get<{ Params: { name: string } }>('/api/cross-team/outbox/:name', async (req
3926
3944
  return { pending };
3927
3945
  });
3928
3946
 
3929
- // Task bus settings
3947
+ // GET /api/settings/task-bus → full config including telemetry
3930
3948
  app.get('/api/settings/task-bus', async () => {
3931
3949
  const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
3932
3950
  try {
3933
3951
  const raw = await fs.readFile(configPath, 'utf-8');
3934
3952
  const settings = JSON.parse(raw);
3935
- 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
+ );
3936
3960
  } catch {
3937
- 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
+ };
3938
3966
  }
3939
3967
  });
3940
3968
 
3969
+ // PUT /api/settings/task-bus → save config + start/stop telemetry
3941
3970
  app.put<{ Body: TaskBusConfig }>('/api/settings/task-bus', async (request) => {
3942
3971
  const config = request.body;
3943
3972
  const configPath = path.join(os.homedir(), '.hermit', 'settings.json');
@@ -3952,6 +3981,13 @@ app.put<{ Body: TaskBusConfig }>('/api/settings/task-bus', async (request) => {
3952
3981
  await fs.mkdir(path.dirname(configPath), { recursive: true });
3953
3982
  await fs.writeFile(configPath, JSON.stringify(settings, null, 2));
3954
3983
 
3984
+ // Sync telemetry service
3985
+ if (config.telemetry?.enabled) {
3986
+ await startTelemetry(config);
3987
+ } else {
3988
+ await stopTelemetry();
3989
+ }
3990
+
3955
3991
  // Auto-inject CLAUDE.md instructions when enabling
3956
3992
  if (config?.enabled) {
3957
3993
  try {
@@ -4004,6 +4040,99 @@ app.put<{ Body: TaskBusConfig }>('/api/settings/task-bus', async (request) => {
4004
4040
  return { ok: true, connected: false, message: 'Task bus disabled' };
4005
4041
  });
4006
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
+
4007
4136
  app.get<{ Params: { name: string; memberName: string } }>(
4008
4137
  '/api/teams/:name/review/agent-changes/:memberName',
4009
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
+ }