@yancyyu/openhermit 1.6.27 → 1.6.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/bin/hermit.mjs +2 -2
- package/dist-renderer/assets/ProjectEditorOverlay-CQm6jUR1.js +52 -0
- package/dist-renderer/assets/{TeamGraphOverlay-DVq8rt6_.js → TeamGraphOverlay-h0WDfifv.js} +1 -1
- package/dist-renderer/assets/{_basePickBy-ZbF0pKvS.js → _basePickBy-CgG_tjgX.js} +1 -1
- package/dist-renderer/assets/{_baseUniq-BBLBOeXc.js → _baseUniq-DwPTU9lP.js} +1 -1
- package/dist-renderer/assets/{arc-wGaEgkCf.js → arc-7nIrGRzY.js} +1 -1
- package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-BpMkdC35.js → architectureDiagram-VXUJARFQ-BYhA6Ev2.js} +1 -1
- package/dist-renderer/assets/{blockDiagram-VD42YOAC-C8Z1xhG4.js → blockDiagram-VD42YOAC-BVpZUGDg.js} +1 -1
- package/dist-renderer/assets/{c4Diagram-YG6GDRKO-CJmlw9LA.js → c4Diagram-YG6GDRKO-DsdreMQ9.js} +1 -1
- package/dist-renderer/assets/channel-C0SqeFU7.js +1 -0
- package/dist-renderer/assets/{chunk-4BX2VUAB-CHPHiRPP.js → chunk-4BX2VUAB-CcoAs7Jd.js} +1 -1
- package/dist-renderer/assets/{chunk-55IACEB6-DyVohOQb.js → chunk-55IACEB6-CGGAOoXd.js} +1 -1
- package/dist-renderer/assets/{chunk-B4BG7PRW-p5bffh_R.js → chunk-B4BG7PRW-FhpTEPvD.js} +1 -1
- package/dist-renderer/assets/{chunk-DI55MBZ5-BnfGPSUu.js → chunk-DI55MBZ5-DoYySbm1.js} +1 -1
- package/dist-renderer/assets/{chunk-FMBD7UC4-B6SCKseX.js → chunk-FMBD7UC4-e9l2tGHG.js} +1 -1
- package/dist-renderer/assets/{chunk-QN33PNHL-L12RvLBR.js → chunk-QN33PNHL-DeiXVTCy.js} +1 -1
- package/dist-renderer/assets/{chunk-QZHKN3VN-DeH1Kxge.js → chunk-QZHKN3VN-DC2UJLJM.js} +1 -1
- package/dist-renderer/assets/{chunk-TZMSLE5B-BWnjzSlI.js → chunk-TZMSLE5B-BHFD9eZI.js} +1 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-DWew1HpM.js +1 -0
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-DWew1HpM.js +1 -0
- package/dist-renderer/assets/clone-Dm-k63Yr.js +1 -0
- package/dist-renderer/assets/{cose-bilkent-S5V4N54A-BtzoT5fu.js → cose-bilkent-S5V4N54A-BdybQraU.js} +1 -1
- package/dist-renderer/assets/{dagre-6UL2VRFP-CBBvuoUD.js → dagre-6UL2VRFP-DdF3pwM3.js} +1 -1
- package/dist-renderer/assets/{diagram-PSM6KHXK-Be9BAKws.js → diagram-PSM6KHXK-B9Ldd3nh.js} +1 -1
- package/dist-renderer/assets/{diagram-QEK2KX5R-BDS4PI_i.js → diagram-QEK2KX5R-XEqkrbpu.js} +1 -1
- package/dist-renderer/assets/{diagram-S2PKOQOG-2Rameaq7.js → diagram-S2PKOQOG-CipwtY59.js} +1 -1
- package/dist-renderer/assets/{erDiagram-Q2GNP2WA-CSIzCEZD.js → erDiagram-Q2GNP2WA-BB-2ISGo.js} +1 -1
- package/dist-renderer/assets/{flowDiagram-NV44I4VS-ForEIVM5.js → flowDiagram-NV44I4VS-B8XmJ0u2.js} +1 -1
- package/dist-renderer/assets/{ganttDiagram-JELNMOA3-BJrli_xr.js → ganttDiagram-JELNMOA3-D-8XglBb.js} +1 -1
- package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-C_4GuLno.js → gitGraphDiagram-V2S2FVAM-DL4ChakD.js} +1 -1
- package/dist-renderer/assets/{graph-B1EAT_gw.js → graph-BiFNoBjP.js} +1 -1
- package/dist-renderer/assets/{index-eKRmS5kI.js → index-6m1ZAymG.js} +1 -1
- package/dist-renderer/assets/index-BhellmRb.css +1 -0
- package/dist-renderer/assets/{index-DYdseEwc.js → index-BowUl0Jb.js} +518 -514
- package/dist-renderer/assets/{index-DR602dwJ.js → index-Dp3kJTEe.js} +1 -1
- package/dist-renderer/assets/{index-Dwr5wu5x.js → index-TOpt_T7A.js} +1 -1
- package/dist-renderer/assets/{index-DOA_jbYb.js → index-qNBNjW4K.js} +1 -1
- package/dist-renderer/assets/{index-k4tnOFC5.js → index-vAykq1H1.js} +1 -1
- package/dist-renderer/assets/{infoDiagram-HS3SLOUP-DjI0uaMz.js → infoDiagram-HS3SLOUP-DRIBfHDi.js} +1 -1
- package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-jQ6Thae-.js → journeyDiagram-XKPGCS4Q-BOMiigU4.js} +1 -1
- package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-CKw6InbL.js → kanban-definition-3W4ZIXB7-DDxeyjod.js} +1 -1
- package/dist-renderer/assets/{layout-Dad20y3V.js → layout-DNANbrI4.js} +1 -1
- package/dist-renderer/assets/{linear-vMgo_2Cv.js → linear-DxEJi1yT.js} +1 -1
- package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-DYp6YoHL.js → mindmap-definition-VGOIOE7T-nBfGriW8.js} +1 -1
- package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BytBecG9.js → pieDiagram-ADFJNKIX-Din5j6sV.js} +1 -1
- package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-RUaspLsc.js → quadrantDiagram-AYHSOK5B-DMVK2BEQ.js} +1 -1
- package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-rR2B1Use.js → requirementDiagram-UZGBJVZJ-6SC94Gg_.js} +1 -1
- package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-BJi5qYhq.js → sankeyDiagram-TZEHDZUN-CD2gghhu.js} +1 -1
- package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-BM-wggUb.js → sequenceDiagram-WL72ISMW-BnhkN7nZ.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-BqmcVjnj.js → stateDiagram-FKZM4ZOC-Bn8XdYX-.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-By3JDVbB.js → stateDiagram-v2-4FDKWEC3-1b6sI1_g.js} +1 -1
- package/dist-renderer/assets/{timeline-definition-IT6M3QCI-szH0GUyk.js → timeline-definition-IT6M3QCI-CNs3RPoa.js} +1 -1
- package/dist-renderer/assets/treemap-GDKQZRPO-DU_yr827.js +162 -0
- package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-dwDpvw0w.js → xychartDiagram-PRI3JC2R-B8o5J2f3.js} +1 -1
- package/dist-renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/server.ts +800 -163
- package/src/main/services/session-intelligence/SessionUsageParser.ts +446 -0
- package/src/main/services/session-intelligence/UsageTelemetryService.ts +252 -0
- package/src/main/services/teams-mvp/CollaborationBoardService.ts +310 -0
- package/src/main/services/teams-mvp/TaskDispatchService.ts +880 -95
- package/src/main/services/teams-mvp/TeamProvisioningService.ts +58 -19
- package/src/main/services/teams-mvp/TeamWorkspaceService.ts +25 -2
- package/src/main/services/teams-mvp/index.ts +3 -0
- package/src/renderer/App.tsx +5 -0
- package/src/renderer/api/httpClient.ts +67 -0
- package/src/renderer/components/dashboard/DashboardView.tsx +6 -105
- package/src/renderer/components/layout/PaneContent.tsx +2 -0
- package/src/renderer/components/layout/SortableTab.tsx +1 -0
- package/src/renderer/components/layout/TabBarActions.tsx +12 -12
- package/src/renderer/components/schedules/SchedulesView.tsx +54 -22
- package/src/renderer/components/settings/SettingsTabs.tsx +2 -2
- package/src/renderer/components/settings/sections/AdvancedSection.tsx +1 -1
- package/src/renderer/components/settings/sections/TaskBusSection.tsx +511 -81
- package/src/renderer/components/tasks/TasksView.tsx +343 -0
- package/src/renderer/components/team/TeamDetailView.tsx +20 -98
- package/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +1 -1
- package/src/renderer/components/team/editor/EditorContextMenu.tsx +8 -23
- package/src/renderer/components/team/editor/EditorFileTree.tsx +0 -4
- package/src/renderer/components/team/editor/EditorSelectionMenu.tsx +1 -8
- package/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +0 -10
- package/src/renderer/components/team/kanban/KanbanBoard.tsx +5 -1
- package/src/renderer/components/team/members/MemberDetailDialog.tsx +8 -33
- package/src/renderer/components/team/messages/MessageComposer.tsx +39 -3
- package/src/renderer/components/team/messages/MessagesPanel.tsx +72 -2
- package/src/renderer/components/team/messages/StatusBlock.tsx +2 -24
- package/src/renderer/components/team/schedule/ScheduleEmptyState.tsx +1 -1
- package/src/renderer/components/ui/MentionableTextarea.tsx +0 -1
- package/src/renderer/store/slices/scheduleSlice.ts +21 -0
- package/src/renderer/store/slices/teamSlice.ts +59 -23
- package/src/renderer/types/tabs.ts +1 -0
- package/src/shared/types/api.ts +29 -0
- package/src/shared/types/team.ts +109 -1
- package/dist-renderer/assets/ProjectEditorOverlay-BBwYdXPv.js +0 -57
- package/dist-renderer/assets/channel-DJUrwVrK.js +0 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-blc3DrH7.js +0 -1
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-blc3DrH7.js +0 -1
- package/dist-renderer/assets/clone-BftjWakJ.js +0 -1
- package/dist-renderer/assets/index-CWpFqEvz.css +0 -1
- package/dist-renderer/assets/treemap-GDKQZRPO-BCMlh-Ex.js +0 -162
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UsageTelemetryService - scans Claude Code JSONL sessions and uploads
|
|
3
|
+
* metadata-only usage metrics to Redis.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type Redis from 'ioredis';
|
|
7
|
+
import { scanSessions } from './SessionUsageParser';
|
|
8
|
+
import type { TaskBusConfig } from '@shared/types/team';
|
|
9
|
+
import type { ParseResult } from './SessionUsageParser';
|
|
10
|
+
|
|
11
|
+
const KEY_DAILY = (slug: string, date: string) => `hermit:usage:${slug}:daily:${date}`;
|
|
12
|
+
const KEY_SUMMARY = (slug: string) => `hermit:usage:${slug}:summary`;
|
|
13
|
+
const KEY_LAST_SCAN = (slug: string) => `hermit:usage:${slug}:lastScan`;
|
|
14
|
+
const KEY_HOURLY = (slug: string) => `hermit:usage:${slug}:hourly`;
|
|
15
|
+
const KEY_EVENTS7D = (slug: string) => `hermit:usage:${slug}:events7d`;
|
|
16
|
+
const KEY_WORK_SECONDS = (slug: string) => `hermit:usage:${slug}:workSeconds`;
|
|
17
|
+
const KEY_PROJECTS = (slug: string) => `hermit:usage:${slug}:projects`;
|
|
18
|
+
|
|
19
|
+
let scanInterval: ReturnType<typeof setInterval> | null = null;
|
|
20
|
+
let lastLocalScan: TelemetryStatusResult | null = null;
|
|
21
|
+
|
|
22
|
+
function redisConfig(cfg: TaskBusConfig) {
|
|
23
|
+
return {
|
|
24
|
+
host: cfg.redis.host,
|
|
25
|
+
port: cfg.redis.port,
|
|
26
|
+
password: cfg.redis.password,
|
|
27
|
+
db: cfg.redis.db,
|
|
28
|
+
lazyConnect: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function getRedis(cfg: TaskBusConfig): Promise<Redis | null> {
|
|
33
|
+
let Redis: typeof import('ioredis').default;
|
|
34
|
+
try {
|
|
35
|
+
const mod = await import('ioredis');
|
|
36
|
+
Redis = mod.default;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const r = new Redis(redisConfig(cfg));
|
|
42
|
+
try {
|
|
43
|
+
await r.connect();
|
|
44
|
+
await r.ping();
|
|
45
|
+
return r;
|
|
46
|
+
} catch {
|
|
47
|
+
try {
|
|
48
|
+
r.disconnect();
|
|
49
|
+
} catch {
|
|
50
|
+
/* ignore */
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function uploadMetrics(client: Redis, slug: string, result: ParseResult): Promise<void> {
|
|
57
|
+
const { aggregate } = result;
|
|
58
|
+
const pipe = client.pipeline();
|
|
59
|
+
|
|
60
|
+
// Per-day metrics (90 day TTL)
|
|
61
|
+
for (const [day, m] of Object.entries(aggregate.daily)) {
|
|
62
|
+
pipe.hset(KEY_DAILY(slug, day), {
|
|
63
|
+
sessions: m.sessions,
|
|
64
|
+
messages: m.messages,
|
|
65
|
+
tokens_in: m.tokensIn,
|
|
66
|
+
tokens_out: m.tokensOut,
|
|
67
|
+
cache_read: m.cacheRead,
|
|
68
|
+
cache_creation: m.cacheCreation,
|
|
69
|
+
work_seconds: m.workSeconds,
|
|
70
|
+
});
|
|
71
|
+
pipe.expire(KEY_DAILY(slug, day), 90 * 86400);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Summary
|
|
75
|
+
pipe.hset(KEY_SUMMARY(slug), {
|
|
76
|
+
sessions: aggregate.sessions,
|
|
77
|
+
messages: aggregate.messages,
|
|
78
|
+
tokens_in: aggregate.tokens.input,
|
|
79
|
+
tokens_out: aggregate.tokens.output,
|
|
80
|
+
cache_read: aggregate.tokens.cacheRead,
|
|
81
|
+
cache_creation: aggregate.tokens.cacheCreation,
|
|
82
|
+
active_days: aggregate.activeDays,
|
|
83
|
+
last_scan: new Date().toISOString(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Hourly distribution
|
|
87
|
+
pipe.set(KEY_HOURLY(slug), JSON.stringify(aggregate.hourly));
|
|
88
|
+
|
|
89
|
+
// 7-day events for rolling window
|
|
90
|
+
pipe.set(KEY_EVENTS7D(slug), JSON.stringify(aggregate.events7d));
|
|
91
|
+
|
|
92
|
+
// Work seconds by day
|
|
93
|
+
pipe.set(KEY_WORK_SECONDS(slug), JSON.stringify(aggregate.workSecondsByDay));
|
|
94
|
+
|
|
95
|
+
// Projects ranking
|
|
96
|
+
pipe.set(KEY_PROJECTS(slug), JSON.stringify(aggregate.projects));
|
|
97
|
+
|
|
98
|
+
// Last scan time
|
|
99
|
+
pipe.set(KEY_LAST_SCAN(slug), new Date().toISOString());
|
|
100
|
+
|
|
101
|
+
await pipe.exec();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function doScan(cfg: TaskBusConfig): Promise<ParseResult | null> {
|
|
105
|
+
if (!cfg.telemetry?.enabled) return null;
|
|
106
|
+
|
|
107
|
+
const result = await scanSessions();
|
|
108
|
+
lastLocalScan = statusFromParseResult(result, false);
|
|
109
|
+
|
|
110
|
+
if (!cfg.telemetry.uploadEnabled) {
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const client = await getRedis(cfg);
|
|
115
|
+
if (!client) return result;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await uploadMetrics(client, 'global', result);
|
|
119
|
+
lastLocalScan = statusFromParseResult(result, true);
|
|
120
|
+
return result;
|
|
121
|
+
} finally {
|
|
122
|
+
try {
|
|
123
|
+
client.disconnect();
|
|
124
|
+
} catch {
|
|
125
|
+
/* ignore */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function startTelemetry(cfg: TaskBusConfig): Promise<void> {
|
|
131
|
+
await stopTelemetry();
|
|
132
|
+
if (!cfg.telemetry?.enabled) return;
|
|
133
|
+
|
|
134
|
+
// Immediate first scan
|
|
135
|
+
await doScan(cfg);
|
|
136
|
+
|
|
137
|
+
// Periodic scan every 10 minutes
|
|
138
|
+
scanInterval = setInterval(
|
|
139
|
+
async () => {
|
|
140
|
+
await doScan(cfg);
|
|
141
|
+
},
|
|
142
|
+
10 * 60 * 1000
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function stopTelemetry(): Promise<void> {
|
|
147
|
+
if (scanInterval) {
|
|
148
|
+
clearInterval(scanInterval);
|
|
149
|
+
scanInterval = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function triggerScan(cfg: TaskBusConfig): Promise<ParseResult | null> {
|
|
154
|
+
return doScan(cfg);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function isTelemetryRunning(): boolean {
|
|
158
|
+
return scanInterval !== null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface TelemetryStatusResult {
|
|
162
|
+
connected: boolean;
|
|
163
|
+
lastScan: string | null;
|
|
164
|
+
sessions: number;
|
|
165
|
+
messages: number;
|
|
166
|
+
tokensIn: number;
|
|
167
|
+
tokensOut: number;
|
|
168
|
+
cacheRead: number;
|
|
169
|
+
cacheCreation: number;
|
|
170
|
+
activeDays: number;
|
|
171
|
+
hourly: number[];
|
|
172
|
+
projects: Array<{
|
|
173
|
+
cwd: string;
|
|
174
|
+
sessions: number;
|
|
175
|
+
messages: number;
|
|
176
|
+
tokensIn: number;
|
|
177
|
+
tokensOut: number;
|
|
178
|
+
}>;
|
|
179
|
+
workSecondsByDay: Record<string, number>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function statusFromParseResult(result: ParseResult, connected: boolean): TelemetryStatusResult {
|
|
183
|
+
const { aggregate } = result;
|
|
184
|
+
return {
|
|
185
|
+
connected,
|
|
186
|
+
lastScan: new Date().toISOString(),
|
|
187
|
+
sessions: aggregate.sessions,
|
|
188
|
+
messages: aggregate.messages,
|
|
189
|
+
tokensIn: aggregate.tokens.input,
|
|
190
|
+
tokensOut: aggregate.tokens.output,
|
|
191
|
+
cacheRead: aggregate.tokens.cacheRead,
|
|
192
|
+
cacheCreation: aggregate.tokens.cacheCreation,
|
|
193
|
+
activeDays: aggregate.activeDays,
|
|
194
|
+
hourly: aggregate.hourly,
|
|
195
|
+
projects: aggregate.projects,
|
|
196
|
+
workSecondsByDay: aggregate.workSecondsByDay,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function getTelemetryStatus(
|
|
201
|
+
redisCfg?: TaskBusConfig['redis']
|
|
202
|
+
): Promise<TelemetryStatusResult | null> {
|
|
203
|
+
if (!redisCfg) return lastLocalScan;
|
|
204
|
+
|
|
205
|
+
let Redis: typeof import('ioredis').default;
|
|
206
|
+
try {
|
|
207
|
+
const mod = await import('ioredis');
|
|
208
|
+
Redis = mod.default;
|
|
209
|
+
} catch {
|
|
210
|
+
return lastLocalScan;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const cfg = { redis: redisCfg };
|
|
214
|
+
const client = new Redis(redisConfig(cfg as TaskBusConfig));
|
|
215
|
+
try {
|
|
216
|
+
await client.connect();
|
|
217
|
+
await client.ping();
|
|
218
|
+
} catch {
|
|
219
|
+
return lastLocalScan;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const [lastScan, summary, hourlyRaw, projectsRaw, workSecondsRaw] = await Promise.all([
|
|
224
|
+
client.get(KEY_LAST_SCAN('global')),
|
|
225
|
+
client.hgetall(KEY_SUMMARY('global')),
|
|
226
|
+
client.get(KEY_HOURLY('global')),
|
|
227
|
+
client.get(KEY_PROJECTS('global')),
|
|
228
|
+
client.get(KEY_WORK_SECONDS('global')),
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
connected: true,
|
|
233
|
+
lastScan: lastScan ?? null,
|
|
234
|
+
sessions: Number(summary.sessions ?? 0),
|
|
235
|
+
messages: Number(summary.messages ?? 0),
|
|
236
|
+
tokensIn: Number(summary.tokens_in ?? 0),
|
|
237
|
+
tokensOut: Number(summary.tokens_out ?? 0),
|
|
238
|
+
cacheRead: Number(summary.cache_read ?? 0),
|
|
239
|
+
cacheCreation: Number(summary.cache_creation ?? 0),
|
|
240
|
+
activeDays: Number(summary.active_days ?? 0),
|
|
241
|
+
hourly: hourlyRaw ? JSON.parse(hourlyRaw) : new Array(24).fill(0),
|
|
242
|
+
projects: projectsRaw ? JSON.parse(projectsRaw) : [],
|
|
243
|
+
workSecondsByDay: workSecondsRaw ? JSON.parse(workSecondsRaw) : {},
|
|
244
|
+
};
|
|
245
|
+
} finally {
|
|
246
|
+
try {
|
|
247
|
+
client.disconnect();
|
|
248
|
+
} catch {
|
|
249
|
+
/* ignore */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|