vibeusage 0.3.4 → 0.4.0

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
 
@@ -86,9 +86,20 @@ This is useful when you want to copy an install command from the dashboard or le
86
86
  | **Every Code** | Auto-detected | `notify` hook | `~/.code/sessions/**/rollout-*.jsonl` |
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
- | **OpenCode** | Auto-detected | plugin + local parsing | `~/.local/share/opencode/opencode.db` (legacy message files are fallback only) |
89
+ | **OpenCode** | Auto-detected | plugin + local parsing | `~/.local/share/opencode/opencode.db` (SQLite is the only supported local accounting source) |
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:
@@ -261,7 +273,7 @@ npx vibeusage status
261
273
  npx vibeusage doctor
262
274
  ```
263
275
 
264
- If OpenCode support is incomplete, the most common issue is missing `sqlite3` on `PATH`, or a local SQLite query failure.
276
+ VibeUsage reads OpenCode usage only from `opencode.db`, so the most common issues are missing `sqlite3` on `PATH`, a missing database file, or a local SQLite query failure.
265
277
 
266
278
  ### My OpenClaw usage is not showing up. What should I check?
267
279
 
package/README.zh-CN.md CHANGED
@@ -86,7 +86,7 @@ npx --yes vibeusage init --link-code <code>
86
86
  | **Every Code** | 自动检测 | `notify` hook | `~/.code/sessions/**/rollout-*.jsonl` |
87
87
  | **Claude Code** | 自动检测 | `Stop` + `SessionEnd` hooks | 本地 hook 输出 |
88
88
  | **Gemini CLI** | 自动检测 | `SessionEnd` hook | `~/.gemini/tmp/**/chats/session-*.json` |
89
- | **OpenCode** | 自动检测 | plugin + 本地解析 | `~/.local/share/opencode/opencode.db`(旧 message 文件仅作 fallback) |
89
+ | **OpenCode** | 自动检测 | plugin + 本地解析 | `~/.local/share/opencode/opencode.db`(SQLite 是唯一受支持的本地 accounting source) |
90
90
  | **OpenClaw** | 安装后自动检测 | session plugin | 本地 sanitized usage ledger |
91
91
 
92
92
  ### OpenClaw 说明
package/package.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
2
  "name": "vibeusage",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/victorGPT/vibeusage.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/victorGPT/vibeusage/issues"
12
+ },
13
+ "homepage": "https://github.com/victorGPT/vibeusage#readme",
6
14
  "bin": {
7
15
  "tracker": "bin/tracker.js",
8
16
  "vibeusage": "bin/tracker.js",
@@ -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, Kimi, 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
  "",
@@ -486,7 +486,7 @@ try {
486
486
  const originalPath =
487
487
  source === 'every-code'
488
488
  ? codeOriginalPath
489
- : source === 'claude' || source === 'opencode' || source === 'gemini'
489
+ : source === 'claude' || source === 'opencode' || source === 'gemini' || source === 'kimi'
490
490
  ? null
491
491
  : codexOriginalPath;
492
492
  if (originalPath) {
@@ -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(
@@ -8,10 +8,11 @@ const {
8
8
  listRolloutFiles,
9
9
  listClaudeProjectFiles,
10
10
  listGeminiSessionFiles,
11
- listOpencodeMessageFiles,
11
+ listKimiSessionFiles,
12
12
  parseRolloutIncremental,
13
13
  parseClaudeIncremental,
14
14
  parseGeminiIncremental,
15
+ parseKimiIncremental,
15
16
  parseOpencodeIncremental,
16
17
  normalizeHourlyState,
17
18
  getHourlyBucket,
@@ -22,6 +23,7 @@ const {
22
23
  } = require("../lib/rollout");
23
24
  const { drainQueueToCloud } = require("../lib/uploader");
24
25
  const { readOpenclawUsageLedger } = require("../lib/openclaw-usage-ledger");
26
+ const { readHermesUsageLedger } = require("../lib/hermes-usage-ledger");
25
27
  const { collectLocalSubscriptions } = require("../lib/subscriptions");
26
28
  const { createProgress, renderBar, formatNumber, formatBytes } = require("../lib/progress");
27
29
  const { syncHeartbeat } = require("../lib/vibeusage-api");
@@ -71,9 +73,10 @@ async function cmdSync(argv) {
71
73
  const claudeProjectsDir = path.join(home, ".claude", "projects");
72
74
  const geminiHome = process.env.GEMINI_HOME || path.join(home, ".gemini");
73
75
  const geminiTmpDir = path.join(geminiHome, "tmp");
76
+ const kimiHome = process.env.KIMI_HOME || path.join(home, ".kimi");
77
+ const kimiSessionsDir = path.join(kimiHome, "sessions");
74
78
  const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
75
79
  const opencodeHome = process.env.OPENCODE_HOME || path.join(xdgDataHome, "opencode");
76
- const opencodeStorageDir = path.join(opencodeHome, "storage");
77
80
  const opencodeDbPath = path.join(opencodeHome, "opencode.db");
78
81
 
79
82
  const sources = [
@@ -114,6 +117,12 @@ async function cmdSync(argv) {
114
117
  },
115
118
  });
116
119
 
120
+ const hermesResult = await parseHermesUsageLedger({
121
+ trackerDir,
122
+ cursors,
123
+ queuePath,
124
+ });
125
+
117
126
  const openclawResult = opts.fromOpenclaw
118
127
  ? await parseOpenclawSanitizedLedger({
119
128
  trackerDir,
@@ -174,28 +183,38 @@ async function cmdSync(argv) {
174
183
  });
175
184
  }
176
185
 
177
- const opencodeFiles = await listOpencodeMessageFiles(opencodeStorageDir);
178
- let opencodeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
179
- if (progress?.enabled && opencodeFiles.length > 0) {
180
- progress.start(
181
- `Parsing Opencode ${renderBar(0)} 0/${formatNumber(opencodeFiles.length)} files | buckets 0`,
182
- );
186
+ const kimiFiles = await listKimiSessionFiles(kimiSessionsDir);
187
+ let kimiResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
188
+ if (kimiFiles.length > 0) {
189
+ if (progress?.enabled) {
190
+ progress.start(
191
+ `Parsing Kimi ${renderBar(0)} 0/${formatNumber(kimiFiles.length)} files | buckets 0`,
192
+ );
193
+ }
194
+ kimiResult = await parseKimiIncremental({
195
+ sessionFiles: kimiFiles,
196
+ cursors,
197
+ queuePath,
198
+ projectQueuePath,
199
+ onProgress: (p) => {
200
+ if (!progress?.enabled) return;
201
+ const pct = p.total > 0 ? p.index / p.total : 1;
202
+ progress.update(
203
+ `Parsing Kimi ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(
204
+ p.bucketsQueued,
205
+ )}`,
206
+ );
207
+ },
208
+ source: "kimi",
209
+ });
183
210
  }
211
+
212
+ let opencodeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
184
213
  opencodeResult = await parseOpencodeIncremental({
185
- messageFiles: opencodeFiles,
186
214
  opencodeDbPath,
187
215
  cursors,
188
216
  queuePath,
189
217
  projectQueuePath,
190
- onProgress: (p) => {
191
- if (!progress?.enabled) return;
192
- const pct = p.total > 0 ? p.index / p.total : 1;
193
- progress.update(
194
- `Parsing Opencode ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
195
- p.total,
196
- )} files | buckets ${formatNumber(p.bucketsQueued)}`,
197
- );
198
- },
199
218
  source: "opencode",
200
219
  });
201
220
 
@@ -369,15 +388,19 @@ async function cmdSync(argv) {
369
388
  if (!opts.auto) {
370
389
  const totalParsed =
371
390
  parseResult.filesProcessed +
391
+ hermesResult.filesProcessed +
372
392
  openclawResult.filesProcessed +
373
393
  claudeResult.filesProcessed +
374
394
  geminiResult.filesProcessed +
395
+ kimiResult.filesProcessed +
375
396
  opencodeResult.filesProcessed;
376
397
  const totalBuckets =
377
398
  parseResult.bucketsQueued +
399
+ hermesResult.bucketsQueued +
378
400
  openclawResult.bucketsQueued +
379
401
  claudeResult.bucketsQueued +
380
402
  geminiResult.bucketsQueued +
403
+ kimiResult.bucketsQueued +
381
404
  opencodeResult.bucketsQueued;
382
405
  process.stdout.write(
383
406
  [
@@ -436,6 +459,78 @@ function parseArgs(argv) {
436
459
 
437
460
  module.exports = { cmdSync };
438
461
 
462
+ async function parseHermesUsageLedger({ trackerDir, cursors, queuePath }) {
463
+ const ledgerCursor =
464
+ cursors?.hermesLedger && typeof cursors.hermesLedger === "object" ? cursors.hermesLedger : {};
465
+ const offset = Math.max(0, Number(ledgerCursor.offset || 0));
466
+ const { events, endOffset } = await readHermesUsageLedger({ trackerDir, offset });
467
+
468
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
469
+ const touchedBuckets = new Set();
470
+ let eventsAggregated = 0;
471
+
472
+ for (const event of events) {
473
+ if (!event || typeof event !== "object") continue;
474
+ if (event.type !== "usage") continue;
475
+ const bucketStart = toUtcHalfHourStart(event.emitted_at);
476
+ if (!bucketStart) continue;
477
+
478
+ const model =
479
+ typeof event.model === "string" && event.model.trim() ? event.model.trim() : "unknown";
480
+ const source = "hermes";
481
+ const delta = {
482
+ input_tokens: Math.max(0, Number(event.input_tokens || 0)),
483
+ cached_input_tokens: Math.max(
484
+ 0,
485
+ Number(event.cache_read_tokens || 0) + Number(event.cache_write_tokens || 0),
486
+ ),
487
+ output_tokens: Math.max(0, Number(event.output_tokens || 0)),
488
+ reasoning_output_tokens: Math.max(0, Number(event.reasoning_tokens || 0)),
489
+ total_tokens: Math.max(0, Number(event.total_tokens || 0)),
490
+ };
491
+
492
+ if (
493
+ delta.input_tokens === 0 &&
494
+ delta.cached_input_tokens === 0 &&
495
+ delta.output_tokens === 0 &&
496
+ delta.reasoning_output_tokens === 0 &&
497
+ delta.total_tokens === 0
498
+ ) {
499
+ continue;
500
+ }
501
+
502
+ const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
503
+ addTotals(bucket.totals, delta);
504
+ touchedBuckets.add(bucketKey(source, model, bucketStart));
505
+ eventsAggregated += 1;
506
+ }
507
+
508
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
509
+ const lastUsageEvent = [...events].reverse().find((event) => {
510
+ if (!event || event.type !== "usage") return false;
511
+ return Boolean(toUtcHalfHourStart(event.emitted_at));
512
+ });
513
+ hourlyState.updatedAt = new Date().toISOString();
514
+ cursors.hourly = hourlyState;
515
+ cursors.hermesLedger = {
516
+ version: 1,
517
+ offset: endOffset,
518
+ updatedAt: new Date().toISOString(),
519
+ lastEventAt:
520
+ typeof lastUsageEvent?.emitted_at === "string"
521
+ ? lastUsageEvent.emitted_at
522
+ : typeof ledgerCursor.lastEventAt === "string"
523
+ ? ledgerCursor.lastEventAt
524
+ : null,
525
+ };
526
+
527
+ return {
528
+ filesProcessed: endOffset > offset ? 1 : 0,
529
+ eventsAggregated,
530
+ bucketsQueued,
531
+ };
532
+ }
533
+
439
534
  async function parseOpenclawSanitizedLedger({ trackerDir, cursors, queuePath }) {
440
535
  const ledgerCursor =
441
536
  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
+ };