@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.
- package/README.md +132 -80
- package/bin/hermit.mjs +2 -2
- package/dist-renderer/assets/{ProjectEditorOverlay-Byepdwo2.js → ProjectEditorOverlay-A4DZTvSy.js} +1 -1
- package/dist-renderer/assets/{TeamGraphOverlay-vvWu-2c9.js → TeamGraphOverlay-Ba5njic5.js} +1 -1
- package/dist-renderer/assets/{_basePickBy-DfsmMgXN.js → _basePickBy-BvnK-OC1.js} +1 -1
- package/dist-renderer/assets/{_baseUniq-Bve-IKz5.js → _baseUniq-DmFYXx9G.js} +1 -1
- package/dist-renderer/assets/{arc-4cbkhagw.js → arc-DX4ZQFY4.js} +1 -1
- package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-CC9i0bMK.js → architectureDiagram-VXUJARFQ-DfYr3vEN.js} +1 -1
- package/dist-renderer/assets/{blockDiagram-VD42YOAC-BjFruJ65.js → blockDiagram-VD42YOAC-DuXdVeWn.js} +1 -1
- package/dist-renderer/assets/{c4Diagram-YG6GDRKO-CrYzsQC1.js → c4Diagram-YG6GDRKO-Bw2nixXe.js} +1 -1
- package/dist-renderer/assets/channel-Pre42N5O.js +1 -0
- package/dist-renderer/assets/{chunk-4BX2VUAB-Bb9MCt7J.js → chunk-4BX2VUAB-DLiNGQoE.js} +1 -1
- package/dist-renderer/assets/{chunk-55IACEB6-BpOVOXVa.js → chunk-55IACEB6-B1L_8VIF.js} +1 -1
- package/dist-renderer/assets/{chunk-B4BG7PRW-GtEiO-7n.js → chunk-B4BG7PRW-DaZMWKGk.js} +1 -1
- package/dist-renderer/assets/{chunk-DI55MBZ5-BRlzcOEj.js → chunk-DI55MBZ5-ku-dflJG.js} +1 -1
- package/dist-renderer/assets/{chunk-FMBD7UC4-DcvMVOZx.js → chunk-FMBD7UC4-DV-mF1dP.js} +1 -1
- package/dist-renderer/assets/{chunk-QN33PNHL-B9pkjVpd.js → chunk-QN33PNHL-ByGcDFQ0.js} +1 -1
- package/dist-renderer/assets/{chunk-QZHKN3VN-DzHPSm01.js → chunk-QZHKN3VN-7dv-Min8.js} +1 -1
- package/dist-renderer/assets/{chunk-TZMSLE5B-BU9c0Hcn.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-BqOg0x3V.js → cose-bilkent-S5V4N54A-CNcsvqPl.js} +1 -1
- package/dist-renderer/assets/{dagre-6UL2VRFP-C9JTWefj.js → dagre-6UL2VRFP-DBNx4qqx.js} +1 -1
- package/dist-renderer/assets/{diagram-PSM6KHXK-ljleG6ui.js → diagram-PSM6KHXK-BfVlT6sT.js} +1 -1
- package/dist-renderer/assets/{diagram-QEK2KX5R-BbV-WSTr.js → diagram-QEK2KX5R-HvVjs0K6.js} +1 -1
- package/dist-renderer/assets/{diagram-S2PKOQOG-CKi3DFby.js → diagram-S2PKOQOG-DYb_KnWS.js} +1 -1
- package/dist-renderer/assets/{erDiagram-Q2GNP2WA-D3HE7b-j.js → erDiagram-Q2GNP2WA-Ba-IgI5G.js} +1 -1
- package/dist-renderer/assets/{flowDiagram-NV44I4VS-C2yLRmM0.js → flowDiagram-NV44I4VS-2iDN8Kpj.js} +1 -1
- package/dist-renderer/assets/{ganttDiagram-JELNMOA3-XEV4KtUf.js → ganttDiagram-JELNMOA3-Byjf8Fa3.js} +1 -1
- package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-ufaCCg7c.js → gitGraphDiagram-V2S2FVAM-DbKvfZ_j.js} +1 -1
- package/dist-renderer/assets/{graph-BzPvdBp0.js → graph-Enirf-f8.js} +1 -1
- package/dist-renderer/assets/{index-BprOls_t.js → index-AjxP_rE_.js} +1 -1
- package/dist-renderer/assets/index-BIOJremZ.css +1 -0
- package/dist-renderer/assets/{index-DHq6dXy7.js → index-COZPUWJW.js} +1 -1
- package/dist-renderer/assets/{index-Cr91T9ef.js → index-ChR1D6ZF.js} +1 -1
- package/dist-renderer/assets/{index-DUIDxnaf.js → index-CtlzGepK.js} +1 -1
- package/dist-renderer/assets/{index-A5CMVuXA.js → index-DY1zqsb6.js} +538 -538
- package/dist-renderer/assets/{index-yNYjzR2R.js → index-DdhqolqE.js} +1 -1
- package/dist-renderer/assets/{infoDiagram-HS3SLOUP-DKP5zgHc.js → infoDiagram-HS3SLOUP-D6uicwz1.js} +1 -1
- package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-Omd7tmzE.js → journeyDiagram-XKPGCS4Q-DqwZsXlQ.js} +1 -1
- package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-D7yw9yIY.js → kanban-definition-3W4ZIXB7-fCDVhVUm.js} +1 -1
- package/dist-renderer/assets/{layout-DZxAqFuM.js → layout-CPFgj98r.js} +1 -1
- package/dist-renderer/assets/{linear-BXWJygRB.js → linear-CYiQ7Y3M.js} +1 -1
- package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-BfJ09SBb.js → mindmap-definition-VGOIOE7T-D31dS2KE.js} +1 -1
- package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BYaLQhXj.js → pieDiagram-ADFJNKIX-BOsCJfds.js} +1 -1
- package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-DeA0B1fw.js → quadrantDiagram-AYHSOK5B-CYTVQCfr.js} +1 -1
- package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-DnFWn7-v.js → requirementDiagram-UZGBJVZJ-CODCFpkt.js} +1 -1
- package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-L9bek20k.js → sankeyDiagram-TZEHDZUN-Z4ce9ZtZ.js} +1 -1
- package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-BBmcJUXb.js → sequenceDiagram-WL72ISMW-CmS9TxhW.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-DrwPQvTq.js → stateDiagram-FKZM4ZOC-o9k-ns3q.js} +1 -1
- package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-BOUQrTH6.js → stateDiagram-v2-4FDKWEC3-CxHMyEt1.js} +1 -1
- package/dist-renderer/assets/{timeline-definition-IT6M3QCI-Dldh9vsj.js → timeline-definition-IT6M3QCI-B6T3zrde.js} +1 -1
- package/dist-renderer/assets/{treemap-GDKQZRPO-BsGSs8-P.js → treemap-GDKQZRPO-CVd5GNDw.js} +1 -1
- package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-BsR_bj-d.js → xychartDiagram-PRI3JC2R-CleBrdqc.js} +1 -1
- package/dist-renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/server.ts +137 -8
- package/src/main/services/session-intelligence/SessionUsageParser.ts +446 -0
- package/src/main/services/session-intelligence/UsageTelemetryService.ts +237 -0
- package/src/renderer/api/httpClient.ts +1 -1
- 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/renderer/components/team/TeamDetailView.tsx +40 -20
- package/src/renderer/components/team/TeamListView.tsx +35 -21
- package/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +10 -7
- package/src/renderer/components/team/dialogs/EditTeamDialog.tsx +9 -6
- package/src/shared/types/team.ts +5 -0
- package/dist-renderer/assets/channel-BMMyVRy4.js +0 -1
- package/dist-renderer/assets/classDiagram-2ON5EDUG-Dz1VG1T3.js +0 -1
- package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-Dz1VG1T3.js +0 -1
- package/dist-renderer/assets/clone-COsIIGZQ.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'));
|
|
@@ -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
|
-
|
|
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(
|
|
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: '
|
|
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
|
-
//
|
|
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
|
|
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 {
|
|
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
|
+
}
|