@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.
- package/README.md +7 -1
- package/bin/hermit.mjs +2 -2
- package/dist-renderer/assets/{ProjectEditorOverlay-BBwYdXPv.js → ProjectEditorOverlay-A4DZTvSy.js} +1 -1
- package/dist-renderer/assets/{TeamGraphOverlay-DVq8rt6_.js → TeamGraphOverlay-Ba5njic5.js} +1 -1
- package/dist-renderer/assets/{_basePickBy-ZbF0pKvS.js → _basePickBy-BvnK-OC1.js} +1 -1
- package/dist-renderer/assets/{_baseUniq-BBLBOeXc.js → _baseUniq-DmFYXx9G.js} +1 -1
- package/dist-renderer/assets/{arc-wGaEgkCf.js → arc-DX4ZQFY4.js} +1 -1
- package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-BpMkdC35.js → architectureDiagram-VXUJARFQ-DfYr3vEN.js} +1 -1
- package/dist-renderer/assets/{blockDiagram-VD42YOAC-C8Z1xhG4.js → blockDiagram-VD42YOAC-DuXdVeWn.js} +1 -1
- package/dist-renderer/assets/{c4Diagram-YG6GDRKO-CJmlw9LA.js → c4Diagram-YG6GDRKO-Bw2nixXe.js} +1 -1
- package/dist-renderer/assets/channel-Pre42N5O.js +1 -0
- package/dist-renderer/assets/{chunk-4BX2VUAB-CHPHiRPP.js → chunk-4BX2VUAB-DLiNGQoE.js} +1 -1
- package/dist-renderer/assets/{chunk-55IACEB6-DyVohOQb.js → chunk-55IACEB6-B1L_8VIF.js} +1 -1
- package/dist-renderer/assets/{chunk-B4BG7PRW-p5bffh_R.js → chunk-B4BG7PRW-DaZMWKGk.js} +1 -1
- package/dist-renderer/assets/{chunk-DI55MBZ5-BnfGPSUu.js → chunk-DI55MBZ5-ku-dflJG.js} +1 -1
- package/dist-renderer/assets/{chunk-FMBD7UC4-B6SCKseX.js → chunk-FMBD7UC4-DV-mF1dP.js} +1 -1
- package/dist-renderer/assets/{chunk-QN33PNHL-L12RvLBR.js → chunk-QN33PNHL-ByGcDFQ0.js} +1 -1
- package/dist-renderer/assets/{chunk-QZHKN3VN-DeH1Kxge.js → chunk-QZHKN3VN-7dv-Min8.js} +1 -1
- package/dist-renderer/assets/{chunk-TZMSLE5B-BWnjzSlI.js → chunk-TZMSLE5B-WdXL5fTu.js} +1 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-CdJsTJsj.js +1 -0
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-CdJsTJsj.js +1 -0
- package/dist-renderer/assets/clone-BjQBiNfj.js +1 -0
- package/dist-renderer/assets/{cose-bilkent-S5V4N54A-BtzoT5fu.js → cose-bilkent-S5V4N54A-CNcsvqPl.js} +1 -1
- package/dist-renderer/assets/{dagre-6UL2VRFP-CBBvuoUD.js → dagre-6UL2VRFP-DBNx4qqx.js} +1 -1
- package/dist-renderer/assets/{diagram-PSM6KHXK-Be9BAKws.js → diagram-PSM6KHXK-BfVlT6sT.js} +1 -1
- package/dist-renderer/assets/{diagram-QEK2KX5R-BDS4PI_i.js → diagram-QEK2KX5R-HvVjs0K6.js} +1 -1
- package/dist-renderer/assets/{diagram-S2PKOQOG-2Rameaq7.js → diagram-S2PKOQOG-DYb_KnWS.js} +1 -1
- package/dist-renderer/assets/{erDiagram-Q2GNP2WA-CSIzCEZD.js → erDiagram-Q2GNP2WA-Ba-IgI5G.js} +1 -1
- package/dist-renderer/assets/{flowDiagram-NV44I4VS-ForEIVM5.js → flowDiagram-NV44I4VS-2iDN8Kpj.js} +1 -1
- package/dist-renderer/assets/{ganttDiagram-JELNMOA3-BJrli_xr.js → ganttDiagram-JELNMOA3-Byjf8Fa3.js} +1 -1
- package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-C_4GuLno.js → gitGraphDiagram-V2S2FVAM-DbKvfZ_j.js} +1 -1
- package/dist-renderer/assets/{graph-B1EAT_gw.js → graph-Enirf-f8.js} +1 -1
- package/dist-renderer/assets/{index-eKRmS5kI.js → index-AjxP_rE_.js} +1 -1
- package/dist-renderer/assets/index-BIOJremZ.css +1 -0
- package/dist-renderer/assets/{index-Dwr5wu5x.js → index-COZPUWJW.js} +1 -1
- package/dist-renderer/assets/{index-k4tnOFC5.js → index-ChR1D6ZF.js} +1 -1
- package/dist-renderer/assets/{index-DR602dwJ.js → index-CtlzGepK.js} +1 -1
- package/dist-renderer/assets/{index-DYdseEwc.js → index-DY1zqsb6.js} +511 -511
- package/dist-renderer/assets/{index-DOA_jbYb.js → index-DdhqolqE.js} +1 -1
- package/dist-renderer/assets/{infoDiagram-HS3SLOUP-DjI0uaMz.js → infoDiagram-HS3SLOUP-D6uicwz1.js} +1 -1
- package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-jQ6Thae-.js → journeyDiagram-XKPGCS4Q-DqwZsXlQ.js} +1 -1
- package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-CKw6InbL.js → kanban-definition-3W4ZIXB7-fCDVhVUm.js} +1 -1
- package/dist-renderer/assets/{layout-Dad20y3V.js → layout-CPFgj98r.js} +1 -1
- package/dist-renderer/assets/{linear-vMgo_2Cv.js → linear-CYiQ7Y3M.js} +1 -1
- package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-DYp6YoHL.js → mindmap-definition-VGOIOE7T-D31dS2KE.js} +1 -1
- package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BytBecG9.js → pieDiagram-ADFJNKIX-BOsCJfds.js} +1 -1
- package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-RUaspLsc.js → quadrantDiagram-AYHSOK5B-CYTVQCfr.js} +1 -1
- package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-rR2B1Use.js → requirementDiagram-UZGBJVZJ-CODCFpkt.js} +1 -1
- package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-BJi5qYhq.js → sankeyDiagram-TZEHDZUN-Z4ce9ZtZ.js} +1 -1
- package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-BM-wggUb.js → sequenceDiagram-WL72ISMW-CmS9TxhW.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-BqmcVjnj.js → stateDiagram-FKZM4ZOC-o9k-ns3q.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-By3JDVbB.js → stateDiagram-v2-4FDKWEC3-CxHMyEt1.js} +1 -1
- package/dist-renderer/assets/{timeline-definition-IT6M3QCI-szH0GUyk.js → timeline-definition-IT6M3QCI-B6T3zrde.js} +1 -1
- package/dist-renderer/assets/{treemap-GDKQZRPO-BCMlh-Ex.js → treemap-GDKQZRPO-CVd5GNDw.js} +1 -1
- package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-dwDpvw0w.js → xychartDiagram-PRI3JC2R-CleBrdqc.js} +1 -1
- package/dist-renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/server.ts +120 -3
- package/src/main/services/session-intelligence/SessionUsageParser.ts +446 -0
- package/src/main/services/session-intelligence/UsageTelemetryService.ts +237 -0
- package/src/renderer/components/dashboard/DashboardView.tsx +6 -105
- package/src/renderer/components/settings/SettingsTabs.tsx +2 -2
- package/src/renderer/components/settings/sections/TaskBusSection.tsx +463 -83
- package/src/shared/types/team.ts +5 -0
- 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/src/main/server.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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 {
|
|
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
|
+
}
|