vibeusage 0.3.4 → 0.3.5
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 +20 -8
- package/package.json +1 -1
- package/src/commands/init.js +1 -1
- package/src/commands/status.js +16 -0
- package/src/commands/sync.js +81 -0
- package/src/commands/uninstall.js +10 -0
- package/src/lib/diagnostics.js +28 -0
- package/src/lib/hermes-config.js +172 -0
- package/src/lib/hermes-usage-ledger.js +123 -0
- package/src/lib/integrations/context.js +6 -0
- package/src/lib/integrations/hermes.js +96 -0
- package/src/lib/integrations/index.js +2 -0
- package/src/templates/hermes-vibeusage-plugin/__init__.py +75 -0
- package/src/templates/hermes-vibeusage-plugin/plugin.yaml +9 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# VibeUsage
|
|
6
6
|
|
|
7
7
|
**Track token usage across AI coding CLIs.**
|
|
8
|
-
Local parsing, minimal data collection, and a shareable dashboard for Codex CLI, Claude Code, Gemini CLI, OpenCode, OpenClaw, and more.
|
|
8
|
+
Local parsing, minimal data collection, and a shareable dashboard for Codex CLI, Claude Code, Gemini CLI, OpenCode, Hermes, OpenClaw, and more.
|
|
9
9
|
|
|
10
10
|
[](https://www.npmjs.com/package/vibeusage)
|
|
11
11
|
[](LICENSE)
|
|
@@ -32,7 +32,7 @@ Give the install guide to ChatGPT, Claude, Codex, or your preferred agent — it
|
|
|
32
32
|
|
|
33
33
|
VibeUsage is a **token usage tracker for AI agent CLIs**. It installs lightweight local hooks/plugins, reads usage from local logs or local databases, aggregates usage into time buckets on your machine, and syncs only the data needed to power a dashboard, cost breakdowns, project usage views, public profiles, and leaderboards.
|
|
34
34
|
|
|
35
|
-
It is currently **macOS-first**, with support focused on real developer workflows around **Codex CLI, Every Code, Claude Code, Gemini CLI, OpenCode, and OpenClaw**.
|
|
35
|
+
It is currently **macOS-first**, with support focused on real developer workflows around **Codex CLI, Every Code, Claude Code, Gemini CLI, OpenCode, Hermes, and OpenClaw**.
|
|
36
36
|
|
|
37
37
|
## Why VibeUsage
|
|
38
38
|
|
|
@@ -87,8 +87,19 @@ This is useful when you want to copy an install command from the dashboard or le
|
|
|
87
87
|
| **Claude Code** | Auto-detected | `Stop` + `SessionEnd` hooks | local hook output |
|
|
88
88
|
| **Gemini CLI** | Auto-detected | `SessionEnd` hook | `~/.gemini/tmp/**/chats/session-*.json` |
|
|
89
89
|
| **OpenCode** | Auto-detected | plugin + local parsing | `~/.local/share/opencode/opencode.db` (legacy message files are fallback only) |
|
|
90
|
+
| **Hermes** | Auto-detected when installed | plugin + local parsing | `~/.vibeusage/tracker/hermes.usage.jsonl` |
|
|
90
91
|
| **OpenClaw** | Auto-detected when installed | session plugin | local sanitized usage ledger |
|
|
91
92
|
|
|
93
|
+
### Hermes note
|
|
94
|
+
|
|
95
|
+
Hermes uses a dedicated plugin-ledger path:
|
|
96
|
+
|
|
97
|
+
**`vibeusage init` installs Hermes plugin → Hermes lifecycle hooks append `~/.vibeusage/tracker/hermes.usage.jsonl` → `vibeusage sync` parses only that ledger**
|
|
98
|
+
|
|
99
|
+
- no prompt / response content upload
|
|
100
|
+
- no fallback parsing of `~/.hermes/state.db`, `~/.hermes/sessions/`, or trajectory files
|
|
101
|
+
- plugin hooks collect locally only; upload still happens in `vibeusage sync`
|
|
102
|
+
|
|
92
103
|
### OpenClaw note
|
|
93
104
|
|
|
94
105
|
OpenClaw uses a dedicated privacy-preserving path:
|
|
@@ -140,12 +151,13 @@ graph LR
|
|
|
140
151
|
C[Claude Code] --> G
|
|
141
152
|
D[Gemini CLI] --> G
|
|
142
153
|
E[OpenCode] --> G
|
|
143
|
-
F[
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
I --> J[
|
|
147
|
-
|
|
148
|
-
|
|
154
|
+
F[Hermes] --> G
|
|
155
|
+
H[OpenClaw] --> G
|
|
156
|
+
G --> I[Local aggregation into 30-min UTC buckets]
|
|
157
|
+
I --> J[VibeUsage backend]
|
|
158
|
+
J --> K[Dashboard]
|
|
159
|
+
J --> L[Project usage]
|
|
160
|
+
J --> M[Public profile / leaderboard]
|
|
149
161
|
```
|
|
150
162
|
|
|
151
163
|
At a high level:
|
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -190,7 +190,7 @@ function renderWelcome() {
|
|
|
190
190
|
DIVIDER,
|
|
191
191
|
"",
|
|
192
192
|
"This tool will:",
|
|
193
|
-
" - Analyze your local AI CLI configurations (Codex, Every Code, Claude, Gemini, Opencode, OpenClaw)",
|
|
193
|
+
" - Analyze your local AI CLI configurations (Codex, Every Code, Claude, Gemini, Opencode, Hermes, OpenClaw)",
|
|
194
194
|
" - Set up lightweight hooks to track your flow state",
|
|
195
195
|
" - Link your device to your VibeScore account",
|
|
196
196
|
"",
|
package/src/commands/status.js
CHANGED
|
@@ -6,6 +6,7 @@ const { readJson } = require("../lib/fs");
|
|
|
6
6
|
const { collectLocalSubscriptions } = require("../lib/subscriptions");
|
|
7
7
|
const { normalizeState: normalizeUploadState } = require("../lib/upload-throttle");
|
|
8
8
|
const { collectTrackerDiagnostics } = require("../lib/diagnostics");
|
|
9
|
+
const { readLastHermesUsageEvent, resolveHermesUsageLedgerPaths } = require("../lib/hermes-usage-ledger");
|
|
9
10
|
const { createIntegrationContext, listIntegrations, probeIntegrations } = require("../lib/integrations");
|
|
10
11
|
const { resolveTrackerPaths } = require("../lib/tracker-paths");
|
|
11
12
|
|
|
@@ -20,6 +21,7 @@ async function cmdStatus(argv = []) {
|
|
|
20
21
|
const home = os.homedir();
|
|
21
22
|
const { trackerDir } = await resolveTrackerPaths({ home });
|
|
22
23
|
const configPath = path.join(trackerDir, "config.json");
|
|
24
|
+
const { ledgerPath: hermesLedgerPath } = resolveHermesUsageLedgerPaths({ trackerDir });
|
|
23
25
|
const queuePath = path.join(trackerDir, "queue.jsonl");
|
|
24
26
|
const queueStatePath = path.join(trackerDir, "queue.state.json");
|
|
25
27
|
const cursorsPath = path.join(trackerDir, "cursors.json");
|
|
@@ -80,8 +82,10 @@ async function cmdStatus(argv = []) {
|
|
|
80
82
|
const claudeProbe = probeByName.get("claude");
|
|
81
83
|
const geminiProbe = probeByName.get("gemini");
|
|
82
84
|
const opencodeProbe = probeByName.get("opencode");
|
|
85
|
+
const hermesProbe = probeByName.get("hermes");
|
|
83
86
|
const openclawSessionProbe = probeByName.get("openclaw-session");
|
|
84
87
|
const opencodeDbPresent = Boolean((await safeStat(opencodeDbPath))?.isFile?.());
|
|
88
|
+
const hermesLedgerPresent = Boolean((await safeStat(hermesLedgerPath))?.isFile?.());
|
|
85
89
|
const opencodeSqliteState =
|
|
86
90
|
cursors?.opencodeSqlite && typeof cursors.opencodeSqlite === "object"
|
|
87
91
|
? cursors.opencodeSqlite
|
|
@@ -90,6 +94,15 @@ async function cmdStatus(argv = []) {
|
|
|
90
94
|
typeof opencodeSqliteState.lastStatus === "string" && opencodeSqliteState.lastStatus.trim()
|
|
91
95
|
? opencodeSqliteState.lastStatus.trim()
|
|
92
96
|
: "never_checked";
|
|
97
|
+
const hermesLedgerState =
|
|
98
|
+
cursors?.hermesLedger && typeof cursors.hermesLedger === "object" ? cursors.hermesLedger : {};
|
|
99
|
+
const hermesLastLedgerEvent = await readLastHermesUsageEvent({ trackerDir });
|
|
100
|
+
const hermesLastEventAt =
|
|
101
|
+
typeof hermesLastLedgerEvent?.emitted_at === "string"
|
|
102
|
+
? hermesLastLedgerEvent.emitted_at
|
|
103
|
+
: typeof hermesLedgerState.lastEventAt === "string"
|
|
104
|
+
? hermesLedgerState.lastEventAt
|
|
105
|
+
: "never";
|
|
93
106
|
|
|
94
107
|
process.stdout.write(
|
|
95
108
|
[
|
|
@@ -111,6 +124,9 @@ async function cmdStatus(argv = []) {
|
|
|
111
124
|
`- Claude plugin: ${renderIntegrationStatus(descriptors.get("claude"), claudeProbe)}`,
|
|
112
125
|
`- Gemini hooks: ${renderIntegrationStatus(descriptors.get("gemini"), geminiProbe)}`,
|
|
113
126
|
`- Opencode plugin: ${renderIntegrationStatus(descriptors.get("opencode"), opencodeProbe)}`,
|
|
127
|
+
`- Hermes plugin: ${renderIntegrationStatus(descriptors.get("hermes"), hermesProbe)}`,
|
|
128
|
+
`- Hermes ledger: ${hermesLedgerPresent ? "present" : "missing"}`,
|
|
129
|
+
`- Hermes last ledger event: ${hermesLastEventAt}`,
|
|
114
130
|
`- OpenCode SQLite DB: ${opencodeDbPresent ? "present" : "missing"}`,
|
|
115
131
|
`- OpenCode SQLite reader: ${opencodeSqliteReader}`,
|
|
116
132
|
`- OpenClaw session plugin: ${renderIntegrationStatus(
|
package/src/commands/sync.js
CHANGED
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
} = require("../lib/rollout");
|
|
23
23
|
const { drainQueueToCloud } = require("../lib/uploader");
|
|
24
24
|
const { readOpenclawUsageLedger } = require("../lib/openclaw-usage-ledger");
|
|
25
|
+
const { readHermesUsageLedger } = require("../lib/hermes-usage-ledger");
|
|
25
26
|
const { collectLocalSubscriptions } = require("../lib/subscriptions");
|
|
26
27
|
const { createProgress, renderBar, formatNumber, formatBytes } = require("../lib/progress");
|
|
27
28
|
const { syncHeartbeat } = require("../lib/vibeusage-api");
|
|
@@ -114,6 +115,12 @@ async function cmdSync(argv) {
|
|
|
114
115
|
},
|
|
115
116
|
});
|
|
116
117
|
|
|
118
|
+
const hermesResult = await parseHermesUsageLedger({
|
|
119
|
+
trackerDir,
|
|
120
|
+
cursors,
|
|
121
|
+
queuePath,
|
|
122
|
+
});
|
|
123
|
+
|
|
117
124
|
const openclawResult = opts.fromOpenclaw
|
|
118
125
|
? await parseOpenclawSanitizedLedger({
|
|
119
126
|
trackerDir,
|
|
@@ -369,12 +376,14 @@ async function cmdSync(argv) {
|
|
|
369
376
|
if (!opts.auto) {
|
|
370
377
|
const totalParsed =
|
|
371
378
|
parseResult.filesProcessed +
|
|
379
|
+
hermesResult.filesProcessed +
|
|
372
380
|
openclawResult.filesProcessed +
|
|
373
381
|
claudeResult.filesProcessed +
|
|
374
382
|
geminiResult.filesProcessed +
|
|
375
383
|
opencodeResult.filesProcessed;
|
|
376
384
|
const totalBuckets =
|
|
377
385
|
parseResult.bucketsQueued +
|
|
386
|
+
hermesResult.bucketsQueued +
|
|
378
387
|
openclawResult.bucketsQueued +
|
|
379
388
|
claudeResult.bucketsQueued +
|
|
380
389
|
geminiResult.bucketsQueued +
|
|
@@ -436,6 +445,78 @@ function parseArgs(argv) {
|
|
|
436
445
|
|
|
437
446
|
module.exports = { cmdSync };
|
|
438
447
|
|
|
448
|
+
async function parseHermesUsageLedger({ trackerDir, cursors, queuePath }) {
|
|
449
|
+
const ledgerCursor =
|
|
450
|
+
cursors?.hermesLedger && typeof cursors.hermesLedger === "object" ? cursors.hermesLedger : {};
|
|
451
|
+
const offset = Math.max(0, Number(ledgerCursor.offset || 0));
|
|
452
|
+
const { events, endOffset } = await readHermesUsageLedger({ trackerDir, offset });
|
|
453
|
+
|
|
454
|
+
const hourlyState = normalizeHourlyState(cursors?.hourly);
|
|
455
|
+
const touchedBuckets = new Set();
|
|
456
|
+
let eventsAggregated = 0;
|
|
457
|
+
|
|
458
|
+
for (const event of events) {
|
|
459
|
+
if (!event || typeof event !== "object") continue;
|
|
460
|
+
if (event.type !== "usage") continue;
|
|
461
|
+
const bucketStart = toUtcHalfHourStart(event.emitted_at);
|
|
462
|
+
if (!bucketStart) continue;
|
|
463
|
+
|
|
464
|
+
const model =
|
|
465
|
+
typeof event.model === "string" && event.model.trim() ? event.model.trim() : "unknown";
|
|
466
|
+
const source = "hermes";
|
|
467
|
+
const delta = {
|
|
468
|
+
input_tokens: Math.max(0, Number(event.input_tokens || 0)),
|
|
469
|
+
cached_input_tokens: Math.max(
|
|
470
|
+
0,
|
|
471
|
+
Number(event.cache_read_tokens || 0) + Number(event.cache_write_tokens || 0),
|
|
472
|
+
),
|
|
473
|
+
output_tokens: Math.max(0, Number(event.output_tokens || 0)),
|
|
474
|
+
reasoning_output_tokens: Math.max(0, Number(event.reasoning_tokens || 0)),
|
|
475
|
+
total_tokens: Math.max(0, Number(event.total_tokens || 0)),
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
if (
|
|
479
|
+
delta.input_tokens === 0 &&
|
|
480
|
+
delta.cached_input_tokens === 0 &&
|
|
481
|
+
delta.output_tokens === 0 &&
|
|
482
|
+
delta.reasoning_output_tokens === 0 &&
|
|
483
|
+
delta.total_tokens === 0
|
|
484
|
+
) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
|
|
489
|
+
addTotals(bucket.totals, delta);
|
|
490
|
+
touchedBuckets.add(bucketKey(source, model, bucketStart));
|
|
491
|
+
eventsAggregated += 1;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
|
|
495
|
+
const lastUsageEvent = [...events].reverse().find((event) => {
|
|
496
|
+
if (!event || event.type !== "usage") return false;
|
|
497
|
+
return Boolean(toUtcHalfHourStart(event.emitted_at));
|
|
498
|
+
});
|
|
499
|
+
hourlyState.updatedAt = new Date().toISOString();
|
|
500
|
+
cursors.hourly = hourlyState;
|
|
501
|
+
cursors.hermesLedger = {
|
|
502
|
+
version: 1,
|
|
503
|
+
offset: endOffset,
|
|
504
|
+
updatedAt: new Date().toISOString(),
|
|
505
|
+
lastEventAt:
|
|
506
|
+
typeof lastUsageEvent?.emitted_at === "string"
|
|
507
|
+
? lastUsageEvent.emitted_at
|
|
508
|
+
: typeof ledgerCursor.lastEventAt === "string"
|
|
509
|
+
? ledgerCursor.lastEventAt
|
|
510
|
+
: null,
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
filesProcessed: endOffset > offset ? 1 : 0,
|
|
515
|
+
eventsAggregated,
|
|
516
|
+
bucketsQueued,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
439
520
|
async function parseOpenclawSanitizedLedger({ trackerDir, cursors, queuePath }) {
|
|
440
521
|
const ledgerCursor =
|
|
441
522
|
cursors?.openclawLedger && typeof cursors.openclawLedger === "object"
|
|
@@ -21,6 +21,7 @@ async function cmdUninstall(argv) {
|
|
|
21
21
|
const claudeConfigExists = await isDir(integrationContext.claude.configDir);
|
|
22
22
|
const geminiConfigExists = await isDir(integrationContext.gemini.configDir);
|
|
23
23
|
const opencodeConfigExists = await isDir(integrationContext.opencode.configDir);
|
|
24
|
+
const hermesConfigExists = await isDir(integrationContext.hermes.hermesHome);
|
|
24
25
|
const integrationResults = await uninstallIntegrations(integrationContext);
|
|
25
26
|
const resultByName = new Map(integrationResults.map((result) => [result.name, result]));
|
|
26
27
|
|
|
@@ -82,6 +83,15 @@ async function cmdUninstall(argv) {
|
|
|
82
83
|
noChangeText: "- Opencode plugin: no change",
|
|
83
84
|
skippedText: "- Opencode plugin: skipped (unexpected content)",
|
|
84
85
|
}),
|
|
86
|
+
renderHookLine({
|
|
87
|
+
exists: hermesConfigExists,
|
|
88
|
+
result: resultByName.get("hermes"),
|
|
89
|
+
missingText: `- Hermes plugin: skipped (${integrationContext.hermes.hermesHome} not found)`,
|
|
90
|
+
removedText: (result) =>
|
|
91
|
+
`- Hermes plugin removed: ${result.detail || integrationContext.hermes.pluginDir}`,
|
|
92
|
+
noChangeText: "- Hermes plugin: no change",
|
|
93
|
+
skippedText: "- Hermes plugin: skipped (unexpected content)",
|
|
94
|
+
}),
|
|
85
95
|
renderHookLine({
|
|
86
96
|
exists: true,
|
|
87
97
|
result: resultByName.get("openclaw-session"),
|
package/src/lib/diagnostics.js
CHANGED
|
@@ -3,6 +3,7 @@ const path = require("node:path");
|
|
|
3
3
|
const fs = require("node:fs/promises");
|
|
4
4
|
|
|
5
5
|
const { readJson } = require("./fs");
|
|
6
|
+
const { readLastHermesUsageEvent, resolveHermesUsageLedgerPaths } = require("./hermes-usage-ledger");
|
|
6
7
|
const { normalizeState: normalizeUploadState } = require("./upload-throttle");
|
|
7
8
|
const { createIntegrationContext, probeIntegrations } = require("./integrations");
|
|
8
9
|
|
|
@@ -25,6 +26,7 @@ async function collectTrackerDiagnostics({
|
|
|
25
26
|
});
|
|
26
27
|
const trackerDir = integrationContext.trackerPaths.trackerDir;
|
|
27
28
|
const configPath = path.join(trackerDir, "config.json");
|
|
29
|
+
const { ledgerPath: hermesLedgerPath } = resolveHermesUsageLedgerPaths({ trackerDir });
|
|
28
30
|
const queuePath = path.join(trackerDir, "queue.jsonl");
|
|
29
31
|
const queueStatePath = path.join(trackerDir, "queue.state.json");
|
|
30
32
|
const cursorsPath = path.join(trackerDir, "cursors.json");
|
|
@@ -45,7 +47,10 @@ async function collectTrackerDiagnostics({
|
|
|
45
47
|
const probeByName = new Map(probes.map((probe) => [probe.name, probe]));
|
|
46
48
|
const opencodeSqliteCursor =
|
|
47
49
|
cursors?.opencodeSqlite && typeof cursors.opencodeSqlite === "object" ? cursors.opencodeSqlite : {};
|
|
50
|
+
const hermesLedgerCursor =
|
|
51
|
+
cursors?.hermesLedger && typeof cursors.hermesLedger === "object" ? cursors.hermesLedger : {};
|
|
48
52
|
const opencodeDbStat = await safeStat(opencodeDbPath);
|
|
53
|
+
const hermesLedgerStat = await safeStat(hermesLedgerPath);
|
|
49
54
|
|
|
50
55
|
const queueSize = await safeStatSize(queuePath);
|
|
51
56
|
const offsetBytes = Number(queueState.offset || 0);
|
|
@@ -60,6 +65,7 @@ async function collectTrackerDiagnostics({
|
|
|
60
65
|
const claudeProbe = probeByName.get("claude");
|
|
61
66
|
const geminiProbe = probeByName.get("gemini");
|
|
62
67
|
const opencodeProbe = probeByName.get("opencode");
|
|
68
|
+
const hermesProbe = probeByName.get("hermes");
|
|
63
69
|
const openclawSessionProbe = probeByName.get("openclaw-session");
|
|
64
70
|
|
|
65
71
|
const codexNotify = Array.isArray(codexProbe?.currentNotify)
|
|
@@ -73,6 +79,7 @@ async function collectTrackerDiagnostics({
|
|
|
73
79
|
? new Date(uploadThrottle.lastSuccessMs).toISOString()
|
|
74
80
|
: null;
|
|
75
81
|
const autoRetryAt = parseEpochMsToIso(autoRetry?.retryAtMs);
|
|
82
|
+
const hermesLastLedgerEvent = await readLastHermesUsageEvent({ trackerDir });
|
|
76
83
|
|
|
77
84
|
return {
|
|
78
85
|
ok: true,
|
|
@@ -92,6 +99,9 @@ async function collectTrackerDiagnostics({
|
|
|
92
99
|
claude_config: redactValue(integrationContext.claude.settingsPath, home),
|
|
93
100
|
gemini_config: redactValue(integrationContext.gemini.settingsPath, home),
|
|
94
101
|
opencode_config: redactValue(integrationContext.opencode.configDir, home),
|
|
102
|
+
hermes_home: redactValue(integrationContext.hermes.hermesHome, home),
|
|
103
|
+
hermes_plugin_dir: redactValue(integrationContext.hermes.pluginDir, home),
|
|
104
|
+
hermes_ledger: redactValue(hermesLedgerPath, home),
|
|
95
105
|
},
|
|
96
106
|
config: {
|
|
97
107
|
base_url: typeof config?.baseUrl === "string" ? config.baseUrl : null,
|
|
@@ -131,6 +141,22 @@ async function collectTrackerDiagnostics({
|
|
|
131
141
|
? opencodeSqliteCursor.lastErrorCode
|
|
132
142
|
: null,
|
|
133
143
|
},
|
|
144
|
+
hermes: {
|
|
145
|
+
ledger_present: Boolean(hermesLedgerStat?.isFile?.()),
|
|
146
|
+
ledger_size_bytes: hermesLedgerStat?.isFile?.() ? Number(hermesLedgerStat.size || 0) : 0,
|
|
147
|
+
ledger_offset:
|
|
148
|
+
Number.isFinite(Number(hermesLedgerCursor.offset)) ? Math.max(0, Number(hermesLedgerCursor.offset)) : 0,
|
|
149
|
+
ledger_updated_at:
|
|
150
|
+
typeof hermesLedgerCursor.updatedAt === "string" ? hermesLedgerCursor.updatedAt : null,
|
|
151
|
+
last_event_at:
|
|
152
|
+
typeof hermesLastLedgerEvent?.emitted_at === "string"
|
|
153
|
+
? hermesLastLedgerEvent.emitted_at
|
|
154
|
+
: typeof hermesLedgerCursor.lastEventAt === "string"
|
|
155
|
+
? hermesLedgerCursor.lastEventAt
|
|
156
|
+
: null,
|
|
157
|
+
last_event_type:
|
|
158
|
+
typeof hermesLastLedgerEvent?.type === "string" ? hermesLastLedgerEvent.type : null,
|
|
159
|
+
},
|
|
134
160
|
notify: {
|
|
135
161
|
last_notify: lastNotify,
|
|
136
162
|
last_openclaw_triggered_sync: lastOpenclawSync,
|
|
@@ -147,6 +173,8 @@ async function collectTrackerDiagnostics({
|
|
|
147
173
|
gemini_hook_configured: Boolean(geminiProbe?.configured),
|
|
148
174
|
opencode_plugin_status: opencodeProbe?.status || "unknown",
|
|
149
175
|
opencode_plugin_configured: Boolean(opencodeProbe?.configured),
|
|
176
|
+
hermes_plugin_status: hermesProbe?.status || "unknown",
|
|
177
|
+
hermes_plugin_configured: Boolean(hermesProbe?.configured),
|
|
150
178
|
openclaw_session_plugin_status: openclawSessionProbe?.status || "unknown",
|
|
151
179
|
openclaw_session_plugin_configured: Boolean(openclawSessionProbe?.configured),
|
|
152
180
|
openclaw_session_plugin_linked: Boolean(openclawSessionProbe?.linked),
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const os = require("node:os");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
const fssync = require("node:fs");
|
|
5
|
+
|
|
6
|
+
const { ensureDir, writeFileAtomic } = require("./fs");
|
|
7
|
+
|
|
8
|
+
const HERMES_PLUGIN_ID = "vibeusage";
|
|
9
|
+
const HERMES_PLUGIN_MARKER = "VIBEUSAGE_HERMES_PLUGIN";
|
|
10
|
+
const HERMES_PLUGIN_TEMPLATE_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
function resolveHermesHome({ home = os.homedir(), env = process.env } = {}) {
|
|
13
|
+
const explicit = typeof env.HERMES_HOME === "string" ? env.HERMES_HOME.trim() : "";
|
|
14
|
+
if (explicit) return path.resolve(explicit);
|
|
15
|
+
return path.join(home, ".hermes");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveHermesPluginPaths({ home = os.homedir(), env = process.env, trackerDir } = {}) {
|
|
19
|
+
if (!trackerDir) throw new Error("trackerDir is required");
|
|
20
|
+
|
|
21
|
+
const hermesHome = resolveHermesHome({ home, env });
|
|
22
|
+
const pluginDir = path.join(hermesHome, "plugins", HERMES_PLUGIN_ID);
|
|
23
|
+
return {
|
|
24
|
+
hermesHome,
|
|
25
|
+
pluginId: HERMES_PLUGIN_ID,
|
|
26
|
+
pluginDir,
|
|
27
|
+
pluginYamlPath: path.join(pluginDir, "plugin.yaml"),
|
|
28
|
+
pluginInitPath: path.join(pluginDir, "__init__.py"),
|
|
29
|
+
ledgerPath: path.join(trackerDir, "hermes.usage.jsonl"),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function probeHermesPlugin({ home = os.homedir(), env = process.env, trackerDir } = {}) {
|
|
34
|
+
const paths = resolveHermesPluginPaths({ home, env, trackerDir });
|
|
35
|
+
const expectedYaml = buildHermesPluginYaml();
|
|
36
|
+
const expectedInit = buildHermesPluginInit({ ledgerPath: paths.ledgerPath });
|
|
37
|
+
const hermesHomeExists = await pathExists(paths.hermesHome);
|
|
38
|
+
|
|
39
|
+
if (!hermesHomeExists) {
|
|
40
|
+
return {
|
|
41
|
+
configured: false,
|
|
42
|
+
status: "not_installed",
|
|
43
|
+
detail: "Hermes home not found",
|
|
44
|
+
initPreviewStatus: "updated",
|
|
45
|
+
initPreviewDetail: "Will install plugin",
|
|
46
|
+
...paths,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pluginDirExists = await pathExists(paths.pluginDir);
|
|
51
|
+
if (!pluginDirExists) {
|
|
52
|
+
return {
|
|
53
|
+
configured: false,
|
|
54
|
+
status: "not_installed",
|
|
55
|
+
detail: "Plugin not installed",
|
|
56
|
+
initPreviewStatus: "updated",
|
|
57
|
+
initPreviewDetail: "Will install plugin",
|
|
58
|
+
...paths,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const yamlState = await readTextStrict(paths.pluginYamlPath);
|
|
63
|
+
const initState = await readTextStrict(paths.pluginInitPath);
|
|
64
|
+
if (yamlState.status === "error" || initState.status === "error") {
|
|
65
|
+
return {
|
|
66
|
+
configured: false,
|
|
67
|
+
status: "unreadable",
|
|
68
|
+
detail: yamlState.error || initState.error || "Plugin unreadable",
|
|
69
|
+
...paths,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (yamlState.status === "missing" || initState.status === "missing") {
|
|
73
|
+
return {
|
|
74
|
+
configured: false,
|
|
75
|
+
status: "drifted",
|
|
76
|
+
detail: "Run vibeusage init to reconcile plugin",
|
|
77
|
+
...paths,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const configured = yamlState.value === expectedYaml && initState.value === expectedInit;
|
|
82
|
+
return {
|
|
83
|
+
configured,
|
|
84
|
+
status: configured ? "ready" : "drifted",
|
|
85
|
+
detail: configured ? "Plugin installed" : "Run vibeusage init to reconcile plugin",
|
|
86
|
+
...paths,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function installHermesPlugin({ home = os.homedir(), env = process.env, trackerDir } = {}) {
|
|
91
|
+
const paths = resolveHermesPluginPaths({ home, env, trackerDir });
|
|
92
|
+
const nextYaml = buildHermesPluginYaml();
|
|
93
|
+
const nextInit = buildHermesPluginInit({ ledgerPath: paths.ledgerPath });
|
|
94
|
+
const currentYaml = await fs.readFile(paths.pluginYamlPath, "utf8").catch(() => null);
|
|
95
|
+
const currentInit = await fs.readFile(paths.pluginInitPath, "utf8").catch(() => null);
|
|
96
|
+
const changed = currentYaml !== nextYaml || currentInit !== nextInit;
|
|
97
|
+
|
|
98
|
+
await ensureDir(paths.pluginDir);
|
|
99
|
+
await writeFileAtomic(paths.pluginYamlPath, nextYaml);
|
|
100
|
+
await writeFileAtomic(paths.pluginInitPath, nextInit);
|
|
101
|
+
return { configured: true, changed, ...paths };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function removeHermesPlugin({ home = os.homedir(), env = process.env, trackerDir } = {}) {
|
|
105
|
+
const paths = resolveHermesPluginPaths({ home, env, trackerDir });
|
|
106
|
+
const hadPluginDir = await pathExists(paths.pluginDir);
|
|
107
|
+
if (!hadPluginDir) {
|
|
108
|
+
return { removed: false, skippedReason: "plugin-missing", ...paths };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const yamlText = await fs.readFile(paths.pluginYamlPath, "utf8").catch(() => null);
|
|
112
|
+
const initText = await fs.readFile(paths.pluginInitPath, "utf8").catch(() => null);
|
|
113
|
+
const markerPresent = hasHermesPluginMarker(yamlText) && hasHermesPluginMarker(initText);
|
|
114
|
+
if (!markerPresent) {
|
|
115
|
+
return { removed: false, skippedReason: "unexpected-content", ...paths };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await fs.rm(paths.pluginDir, { recursive: true, force: true }).catch(() => {});
|
|
119
|
+
return { removed: true, ...paths };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildHermesPluginYaml() {
|
|
123
|
+
return readTemplateFile("plugin.yaml");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildHermesPluginInit({ ledgerPath }) {
|
|
127
|
+
const safeLedgerPath = typeof ledgerPath === "string" ? ledgerPath : "";
|
|
128
|
+
return readTemplateFile("__init__.py").replace("__LEDGER_PATH__", safeLedgerPath);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hasHermesPluginMarker(text) {
|
|
132
|
+
return typeof text === "string" && text.includes(HERMES_PLUGIN_MARKER);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readTextStrict(filePath) {
|
|
136
|
+
try {
|
|
137
|
+
return { status: "ok", value: await fs.readFile(filePath, "utf8"), error: null };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err?.code === "ENOENT" || err?.code === "ENOTDIR") {
|
|
140
|
+
return { status: "missing", value: null, error: null };
|
|
141
|
+
}
|
|
142
|
+
return { status: "error", value: null, error: err?.message || String(err) };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function pathExists(targetPath) {
|
|
147
|
+
try {
|
|
148
|
+
await fs.stat(targetPath);
|
|
149
|
+
return true;
|
|
150
|
+
} catch (_err) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readTemplateFile(name) {
|
|
156
|
+
const templatePath = path.join(__dirname, "..", "templates", "hermes-vibeusage-plugin", name);
|
|
157
|
+
return fssync.readFileSync(templatePath, "utf8");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
HERMES_PLUGIN_ID,
|
|
162
|
+
HERMES_PLUGIN_MARKER,
|
|
163
|
+
HERMES_PLUGIN_TEMPLATE_VERSION,
|
|
164
|
+
resolveHermesHome,
|
|
165
|
+
resolveHermesPluginPaths,
|
|
166
|
+
probeHermesPlugin,
|
|
167
|
+
installHermesPlugin,
|
|
168
|
+
removeHermesPlugin,
|
|
169
|
+
buildHermesPluginYaml,
|
|
170
|
+
buildHermesPluginInit,
|
|
171
|
+
hasHermesPluginMarker,
|
|
172
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const fs = require("node:fs/promises");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
const ALLOWED_HERMES_LEDGER_FIELDS = [
|
|
5
|
+
"version",
|
|
6
|
+
"type",
|
|
7
|
+
"source",
|
|
8
|
+
"session_id",
|
|
9
|
+
"platform",
|
|
10
|
+
"model",
|
|
11
|
+
"provider",
|
|
12
|
+
"api_mode",
|
|
13
|
+
"api_call_count",
|
|
14
|
+
"input_tokens",
|
|
15
|
+
"output_tokens",
|
|
16
|
+
"cache_read_tokens",
|
|
17
|
+
"cache_write_tokens",
|
|
18
|
+
"reasoning_tokens",
|
|
19
|
+
"total_tokens",
|
|
20
|
+
"finish_reason",
|
|
21
|
+
"emitted_at",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function resolveHermesUsageLedgerPaths({ trackerDir } = {}) {
|
|
25
|
+
if (!trackerDir) throw new Error("trackerDir is required");
|
|
26
|
+
return {
|
|
27
|
+
ledgerPath: path.join(trackerDir, "hermes.usage.jsonl"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readHermesUsageLedger({ trackerDir, offset = 0 } = {}) {
|
|
32
|
+
const { ledgerPath } = resolveHermesUsageLedgerPaths({ trackerDir });
|
|
33
|
+
const buffer = await fs.readFile(ledgerPath).catch((err) => {
|
|
34
|
+
if (err && err.code === "ENOENT") return null;
|
|
35
|
+
throw err;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!buffer) {
|
|
39
|
+
return { events: [], endOffset: 0 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const startOffset = Math.max(0, Number(offset || 0));
|
|
43
|
+
const fileEndOffset = buffer.length;
|
|
44
|
+
if (startOffset >= fileEndOffset) {
|
|
45
|
+
return { events: [], endOffset: fileEndOffset };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const newlineIndex = buffer.lastIndexOf(0x0a);
|
|
49
|
+
if (newlineIndex < startOffset) {
|
|
50
|
+
return { events: [], endOffset: startOffset };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const endOffset = newlineIndex + 1;
|
|
54
|
+
const raw = buffer.subarray(startOffset, endOffset).toString("utf8");
|
|
55
|
+
const events = raw
|
|
56
|
+
.split(/\r?\n/)
|
|
57
|
+
.map((line) => line.trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((line) => {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(line);
|
|
62
|
+
} catch (_err) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map(stripToAllowedHermesFields)
|
|
68
|
+
.filter(isHermesLedgerEvent);
|
|
69
|
+
|
|
70
|
+
return { events, endOffset };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readLastHermesUsageEvent({ trackerDir } = {}) {
|
|
74
|
+
const { ledgerPath } = resolveHermesUsageLedgerPaths({ trackerDir });
|
|
75
|
+
const text = await fs.readFile(ledgerPath, "utf8").catch((err) => {
|
|
76
|
+
if (err && err.code === "ENOENT") return null;
|
|
77
|
+
throw err;
|
|
78
|
+
});
|
|
79
|
+
if (!text) return null;
|
|
80
|
+
|
|
81
|
+
const lines = text.split(/\r?\n/);
|
|
82
|
+
for (let idx = lines.length - 1; idx >= 0; idx -= 1) {
|
|
83
|
+
const line = String(lines[idx] || "").trim();
|
|
84
|
+
if (!line) continue;
|
|
85
|
+
try {
|
|
86
|
+
const parsed = stripToAllowedHermesFields(JSON.parse(line));
|
|
87
|
+
if (isHermesLedgerEvent(parsed)) return parsed;
|
|
88
|
+
} catch (_err) {
|
|
89
|
+
// ignore malformed line
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stripToAllowedHermesFields(event) {
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const field of ALLOWED_HERMES_LEDGER_FIELDS) {
|
|
98
|
+
if (!Object.prototype.hasOwnProperty.call(event, field)) continue;
|
|
99
|
+
out[field] = event[field];
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isHermesLedgerEvent(event) {
|
|
105
|
+
if (!event || typeof event !== "object") return false;
|
|
106
|
+
if (normalizeString(event.source) !== "hermes") return false;
|
|
107
|
+
const type = normalizeString(event.type);
|
|
108
|
+
if (!type || !["session_start", "usage", "session_end"].includes(type)) return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeString(value) {
|
|
113
|
+
if (typeof value !== "string") return null;
|
|
114
|
+
const trimmed = value.trim();
|
|
115
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
ALLOWED_HERMES_LEDGER_FIELDS,
|
|
120
|
+
resolveHermesUsageLedgerPaths,
|
|
121
|
+
readHermesUsageLedger,
|
|
122
|
+
readLastHermesUsageEvent,
|
|
123
|
+
};
|
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
} = require("../gemini-config");
|
|
10
10
|
const { resolveOpencodeConfigDir } = require("../opencode-config");
|
|
11
11
|
const { resolveOpenclawSessionPluginPaths } = require("../openclaw-session-plugin");
|
|
12
|
+
const { resolveHermesPluginPaths } = require("../hermes-config");
|
|
12
13
|
const { resolveTrackerPaths } = require("../tracker-paths");
|
|
13
14
|
|
|
14
15
|
async function createIntegrationContext({
|
|
@@ -57,6 +58,11 @@ async function createIntegrationContext({
|
|
|
57
58
|
opencode: {
|
|
58
59
|
configDir: opencodeConfigDir,
|
|
59
60
|
},
|
|
61
|
+
hermes: resolveHermesPluginPaths({
|
|
62
|
+
home,
|
|
63
|
+
env,
|
|
64
|
+
trackerDir: resolvedTrackerPaths.trackerDir,
|
|
65
|
+
}),
|
|
60
66
|
openclawSession: resolveOpenclawSessionPluginPaths({
|
|
61
67
|
home,
|
|
62
68
|
trackerDir: resolvedTrackerPaths.trackerDir,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const { isDir } = require("./utils");
|
|
2
|
+
const { probeHermesPlugin, installHermesPlugin, removeHermesPlugin } = require("../hermes-config");
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
name: "hermes",
|
|
6
|
+
summaryLabel: "Hermes Plugin",
|
|
7
|
+
statusLabel: "Hermes plugin",
|
|
8
|
+
async probe(ctx) {
|
|
9
|
+
const hermesHomeExists = await isDir(ctx.hermes.hermesHome);
|
|
10
|
+
if (!hermesHomeExists) {
|
|
11
|
+
return baseProbe(this, {
|
|
12
|
+
status: "not_installed",
|
|
13
|
+
detail: "Hermes home not found",
|
|
14
|
+
initPreviewStatus: "skipped",
|
|
15
|
+
initPreviewDetail: "Hermes not detected",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const state = await probeHermesPlugin({
|
|
20
|
+
home: ctx.home,
|
|
21
|
+
env: ctx.env,
|
|
22
|
+
trackerDir: ctx.trackerPaths.trackerDir,
|
|
23
|
+
});
|
|
24
|
+
return baseProbe(this, state);
|
|
25
|
+
},
|
|
26
|
+
async install(ctx) {
|
|
27
|
+
if (!(await isDir(ctx.hermes.hermesHome))) {
|
|
28
|
+
return action(this, "skipped", false, "Hermes home not found");
|
|
29
|
+
}
|
|
30
|
+
const result = await installHermesPlugin({
|
|
31
|
+
home: ctx.home,
|
|
32
|
+
env: ctx.env,
|
|
33
|
+
trackerDir: ctx.trackerPaths.trackerDir,
|
|
34
|
+
});
|
|
35
|
+
return action(
|
|
36
|
+
this,
|
|
37
|
+
result.changed ? "installed" : "set",
|
|
38
|
+
Boolean(result.changed),
|
|
39
|
+
result.changed ? "Plugin installed" : "Plugin already installed",
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
async uninstall(ctx) {
|
|
43
|
+
if (!(await isDir(ctx.hermes.hermesHome))) {
|
|
44
|
+
return action(this, "skipped", false, "Hermes home not found", {
|
|
45
|
+
skippedReason: "hermes-home-missing",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const result = await removeHermesPlugin({
|
|
49
|
+
home: ctx.home,
|
|
50
|
+
env: ctx.env,
|
|
51
|
+
trackerDir: ctx.trackerPaths.trackerDir,
|
|
52
|
+
});
|
|
53
|
+
if (result.removed) {
|
|
54
|
+
return action(this, "removed", true, result.pluginDir || ctx.hermes.pluginDir);
|
|
55
|
+
}
|
|
56
|
+
if (result.skippedReason === "plugin-missing") {
|
|
57
|
+
return action(this, "unchanged", false, "no change", {
|
|
58
|
+
skippedReason: result.skippedReason,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (result.skippedReason === "unexpected-content") {
|
|
62
|
+
return action(this, "skipped", false, "unexpected content", {
|
|
63
|
+
skippedReason: result.skippedReason,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return action(this, "skipped", false, "Hermes home not found", {
|
|
67
|
+
skippedReason: result.skippedReason || "hermes-home-missing",
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
renderStatusValue(probe) {
|
|
71
|
+
if (probe.status === "ready") return "set";
|
|
72
|
+
if (probe.status === "not_installed") return "unset";
|
|
73
|
+
return probe.status;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function baseProbe(descriptor, values) {
|
|
78
|
+
return {
|
|
79
|
+
name: descriptor.name,
|
|
80
|
+
summaryLabel: descriptor.summaryLabel,
|
|
81
|
+
statusLabel: descriptor.statusLabel,
|
|
82
|
+
configured: false,
|
|
83
|
+
...values,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function action(descriptor, status, changed, detail, extras = {}) {
|
|
88
|
+
return {
|
|
89
|
+
name: descriptor.name,
|
|
90
|
+
label: descriptor.summaryLabel,
|
|
91
|
+
status,
|
|
92
|
+
changed,
|
|
93
|
+
detail,
|
|
94
|
+
...extras,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -4,6 +4,7 @@ const everyCode = require("./every-code");
|
|
|
4
4
|
const claude = require("./claude");
|
|
5
5
|
const gemini = require("./gemini");
|
|
6
6
|
const opencode = require("./opencode");
|
|
7
|
+
const hermes = require("./hermes");
|
|
7
8
|
const openclawSession = require("./openclaw-session");
|
|
8
9
|
|
|
9
10
|
const INTEGRATIONS = [
|
|
@@ -12,6 +13,7 @@ const INTEGRATIONS = [
|
|
|
12
13
|
claude,
|
|
13
14
|
gemini,
|
|
14
15
|
opencode,
|
|
16
|
+
hermes,
|
|
15
17
|
openclawSession,
|
|
16
18
|
];
|
|
17
19
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# VIBEUSAGE_HERMES_PLUGIN
|
|
2
|
+
# Generated by VibeUsage. Do not edit manually.
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
LEDGER_PATH = "__LEDGER_PATH__"
|
|
8
|
+
SOURCE = "hermes"
|
|
9
|
+
VERSION = 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _iso_now():
|
|
13
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _safe_int(value):
|
|
17
|
+
try:
|
|
18
|
+
return max(0, int(value or 0))
|
|
19
|
+
except Exception:
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _append_record(record):
|
|
24
|
+
try:
|
|
25
|
+
os.makedirs(os.path.dirname(LEDGER_PATH), exist_ok=True)
|
|
26
|
+
with open(LEDGER_PATH, "a", encoding="utf-8") as handle:
|
|
27
|
+
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _base_record(record_type, session_id="", platform="", model="", provider=""):
|
|
34
|
+
return {
|
|
35
|
+
"version": VERSION,
|
|
36
|
+
"type": str(record_type or ""),
|
|
37
|
+
"source": SOURCE,
|
|
38
|
+
"session_id": str(session_id or ""),
|
|
39
|
+
"platform": str(platform or ""),
|
|
40
|
+
"model": str(model or ""),
|
|
41
|
+
"provider": str(provider or ""),
|
|
42
|
+
"emitted_at": _iso_now(),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def on_session_start(session_id="", model="", platform="", **_kwargs):
|
|
47
|
+
return _append_record(_base_record("session_start", session_id=session_id, platform=platform, model=model))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def post_api_request(session_id="", platform="", model="", provider="", api_mode="", api_call_count=0, finish_reason="", usage=None, **_kwargs):
|
|
51
|
+
if not isinstance(usage, dict):
|
|
52
|
+
return None
|
|
53
|
+
record = _base_record("usage", session_id=session_id, platform=platform, model=model, provider=provider)
|
|
54
|
+
record.update({
|
|
55
|
+
"api_mode": str(api_mode or ""),
|
|
56
|
+
"api_call_count": _safe_int(api_call_count),
|
|
57
|
+
"input_tokens": _safe_int(usage.get("input_tokens")),
|
|
58
|
+
"output_tokens": _safe_int(usage.get("output_tokens")),
|
|
59
|
+
"cache_read_tokens": _safe_int(usage.get("cache_read_tokens")),
|
|
60
|
+
"cache_write_tokens": _safe_int(usage.get("cache_write_tokens")),
|
|
61
|
+
"reasoning_tokens": _safe_int(usage.get("reasoning_tokens")),
|
|
62
|
+
"total_tokens": _safe_int(usage.get("total_tokens")),
|
|
63
|
+
"finish_reason": str(finish_reason or ""),
|
|
64
|
+
})
|
|
65
|
+
return _append_record(record)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def on_session_end(session_id="", model="", platform="", **_kwargs):
|
|
69
|
+
return _append_record(_base_record("session_end", session_id=session_id, platform=platform, model=model))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def register(ctx):
|
|
73
|
+
ctx.register_hook("on_session_start", on_session_start)
|
|
74
|
+
ctx.register_hook("post_api_request", post_api_request)
|
|
75
|
+
ctx.register_hook("on_session_end", on_session_end)
|