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 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
  [![npm version](https://img.shields.io/npm/v/vibeusage.svg)](https://www.npmjs.com/package/vibeusage)
11
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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[OpenClaw] --> G
144
- G --> H[Local aggregation into 30-min UTC buckets]
145
- H --> I[VibeUsage backend]
146
- I --> J[Dashboard]
147
- I --> K[Project usage]
148
- I --> L[Public profile / leaderboard]
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeusage",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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
  "",
@@ -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(
@@ -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"),
@@ -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)
@@ -0,0 +1,9 @@
1
+ # VIBEUSAGE_HERMES_PLUGIN
2
+ name: vibeusage
3
+ version: "1"
4
+ description: "VibeUsage Hermes usage ledger plugin"
5
+ author: "VibeUsage"
6
+ provides_hooks:
7
+ - "on_session_start"
8
+ - "post_api_request"
9
+ - "on_session_end"