vibeusage 0.6.3 → 0.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeusage",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -50,7 +50,8 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@insforge/sdk": "1.2.2",
53
- "proper-lockfile": "^4.1.2"
53
+ "proper-lockfile": "^4.1.2",
54
+ "yaml": "^2.8.3"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@sourcegraph/scip-typescript": "^0.3.6",
@@ -179,9 +179,12 @@ function runAuditTokensAll({ opts, config }) {
179
179
  anyHardError = true;
180
180
  }
181
181
  if (result.ok && result.exceedsThreshold) anyExceeds = true;
182
- // no-local-sessions is informational, not a hard error; other non-ok states
183
- // (cannot-resolve-user-id, insforge-db-query-failed, etc.) count as errors.
184
- if (!result.ok && result.error !== "no-local-sessions") anyHardError = true;
182
+ // no-local-sessions and audit-not-applicable are informational, not hard
183
+ // errors. The latter means the source has no independent ground-truth to
184
+ // compare against the DB (e.g. hermes plugin-ledger is the same data that
185
+ // already feeds the DB).
186
+ const informationalErrors = new Set(["no-local-sessions", "audit-not-applicable"]);
187
+ if (!result.ok && !informationalErrors.has(result.error)) anyHardError = true;
185
188
  perSource.push(result);
186
189
  }
187
190
 
@@ -207,8 +210,14 @@ function runAuditTokensAll({ opts, config }) {
207
210
  `${r.source.padEnd(12)} ${statusText.padEnd(22)} ${drift.padStart(10)} ${String(r.filesScanned).padStart(6)} ${String(r.usageLines).padStart(6)}\n`,
208
211
  );
209
212
  } else {
210
- const statusText =
211
- r.error === "no-local-sessions" ? "no local sessions" : `ERR ${r.error}`;
213
+ let statusText;
214
+ if (r.error === "no-local-sessions") {
215
+ statusText = "no local sessions";
216
+ } else if (r.error === "audit-not-applicable") {
217
+ statusText = "N/A";
218
+ } else {
219
+ statusText = `ERR ${r.error}`;
220
+ }
212
221
  process.stdout.write(
213
222
  `${r.source.padEnd(12)} ${statusText.padEnd(22)} ${"—".padStart(10)} ${"—".padStart(6)} ${"—".padStart(6)}\n`,
214
223
  );
@@ -534,11 +534,11 @@ async function parseHermesUsageLedger({ trackerDir, cursors, queuePath }) {
534
534
  typeof event.model === "string" && event.model.trim() ? event.model.trim() : "unknown";
535
535
  const source = "hermes";
536
536
  const delta = {
537
- input_tokens: Math.max(0, Number(event.input_tokens || 0)),
538
- cached_input_tokens: Math.max(
537
+ input_tokens: Math.max(
539
538
  0,
540
- Number(event.cache_read_tokens || 0) + Number(event.cache_write_tokens || 0),
539
+ Number(event.input_tokens || 0) + Number(event.cache_write_tokens || 0),
541
540
  ),
541
+ cached_input_tokens: Math.max(0, Number(event.cache_read_tokens || 0)),
542
542
  output_tokens: Math.max(0, Number(event.output_tokens || 0)),
543
543
  reasoning_output_tokens: Math.max(0, Number(event.reasoning_tokens || 0)),
544
544
  total_tokens: Math.max(0, Number(event.total_tokens || 0)),
@@ -554,6 +554,19 @@ async function parseHermesUsageLedger({ trackerDir, cursors, queuePath }) {
554
554
  continue;
555
555
  }
556
556
 
557
+ // Fallback: if all fine-grained channels are 0 but total_tokens > 0,
558
+ // route total into output so the event isn't silently dropped.
559
+ // This handles upstream plugins that only report total_tokens.
560
+ if (
561
+ delta.input_tokens === 0 &&
562
+ delta.cached_input_tokens === 0 &&
563
+ delta.output_tokens === 0 &&
564
+ delta.reasoning_output_tokens === 0 &&
565
+ delta.total_tokens > 0
566
+ ) {
567
+ delta.output_tokens = delta.total_tokens;
568
+ }
569
+
557
570
  const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
558
571
  addTotals(bucket.totals, delta);
559
572
  touchedBuckets.add(bucketKey(source, model, bucketStart));
@@ -4,6 +4,11 @@ const fs = require("node:fs/promises");
4
4
  const fssync = require("node:fs");
5
5
 
6
6
  const { ensureDir, writeFileAtomic } = require("./fs");
7
+ const {
8
+ addEnabledPlugin,
9
+ removeEnabledPlugin,
10
+ probeEnabledPlugin,
11
+ } = require("./hermes-plugins-config");
7
12
 
8
13
  const HERMES_PLUGIN_ID = "vibeusage";
9
14
  const HERMES_PLUGIN_MARKER = "VIBEUSAGE_HERMES_PLUGIN";
@@ -78,11 +83,33 @@ async function probeHermesPlugin({ home = os.homedir(), env = process.env, track
78
83
  };
79
84
  }
80
85
 
81
- const configured = yamlState.value === expectedYaml && initState.value === expectedInit;
86
+ const filesMatch = yamlState.value === expectedYaml && initState.value === expectedInit;
87
+
88
+ // Hermes user plugins are opt-in: the hooks only fire if vibeusage is in
89
+ // plugins.enabled. Files-on-disk are necessary but NOT sufficient — without
90
+ // the allow-list entry the ledger stays empty and we silently report 0
91
+ // tokens forever (see hermes_cli/plugins.py:_get_enabled_plugins).
92
+ const enabledState = await probeEnabledPlugin({ home, env, name: HERMES_PLUGIN_ID });
93
+ const isEnabled = enabledState.state === "enabled";
94
+
95
+ if (!filesMatch || !isEnabled) {
96
+ return {
97
+ configured: false,
98
+ status: "drifted",
99
+ detail: "Run vibeusage init to reconcile plugin",
100
+ filesMatch,
101
+ enabled: isEnabled,
102
+ enabledState: enabledState.state,
103
+ ...paths,
104
+ };
105
+ }
106
+
82
107
  return {
83
- configured,
84
- status: configured ? "ready" : "drifted",
85
- detail: configured ? "Plugin installed" : "Run vibeusage init to reconcile plugin",
108
+ configured: true,
109
+ status: "ready",
110
+ detail: "Plugin installed",
111
+ filesMatch: true,
112
+ enabled: true,
86
113
  ...paths,
87
114
  };
88
115
  }
@@ -93,30 +120,76 @@ async function installHermesPlugin({ home = os.homedir(), env = process.env, tra
93
120
  const nextInit = buildHermesPluginInit({ ledgerPath: paths.ledgerPath });
94
121
  const currentYaml = await fs.readFile(paths.pluginYamlPath, "utf8").catch(() => null);
95
122
  const currentInit = await fs.readFile(paths.pluginInitPath, "utf8").catch(() => null);
96
- const changed = currentYaml !== nextYaml || currentInit !== nextInit;
123
+ const filesChanged = currentYaml !== nextYaml || currentInit !== nextInit;
97
124
 
98
125
  await ensureDir(paths.pluginDir);
99
126
  await writeFileAtomic(paths.pluginYamlPath, nextYaml);
100
127
  await writeFileAtomic(paths.pluginInitPath, nextInit);
101
- return { configured: true, changed, ...paths };
128
+
129
+ // Also opt the plugin into Hermes' allow-list. Hermes loads user plugins
130
+ // only when their name appears in plugins.enabled (post v20→v21 migration).
131
+ // Without this step the hooks never fire and the ledger stays empty.
132
+ let enabledChanged = false;
133
+ let enableSkippedReason = null;
134
+ try {
135
+ const enableResult = await addEnabledPlugin({ home, env, name: HERMES_PLUGIN_ID });
136
+ enabledChanged = Boolean(enableResult.changed);
137
+ } catch (err) {
138
+ // Refuse to clobber malformed user config; surface the reason but don't
139
+ // throw — the file install half still has value, and the user can rerun
140
+ // init after fixing config.yaml.
141
+ enableSkippedReason = err && err.code ? err.code : "enable-failed";
142
+ }
143
+
144
+ return {
145
+ configured: enableSkippedReason === null,
146
+ changed: filesChanged || enabledChanged,
147
+ filesChanged,
148
+ enabledChanged,
149
+ enableSkippedReason,
150
+ ...paths,
151
+ };
102
152
  }
103
153
 
104
154
  async function removeHermesPlugin({ home = os.homedir(), env = process.env, trackerDir } = {}) {
105
155
  const paths = resolveHermesPluginPaths({ home, env, trackerDir });
106
156
  const hadPluginDir = await pathExists(paths.pluginDir);
157
+
158
+ // Always try to clean up the allow-list entry, even if the plugin dir is
159
+ // already gone — config.yaml could otherwise be left referencing a plugin
160
+ // that no longer exists.
161
+ let enabledChanged = false;
162
+ try {
163
+ const enableResult = await removeEnabledPlugin({ home, env, name: HERMES_PLUGIN_ID });
164
+ enabledChanged = Boolean(enableResult.changed);
165
+ } catch (_err) {
166
+ // Best-effort; mirrors the behaviour above. Don't block file removal on
167
+ // a malformed config.yaml.
168
+ }
169
+
107
170
  if (!hadPluginDir) {
108
- return { removed: false, skippedReason: "plugin-missing", ...paths };
171
+ return {
172
+ removed: enabledChanged,
173
+ skippedReason: enabledChanged ? null : "plugin-missing",
174
+ enabledChanged,
175
+ ...paths,
176
+ };
109
177
  }
110
178
 
111
179
  const yamlText = await fs.readFile(paths.pluginYamlPath, "utf8").catch(() => null);
112
180
  const initText = await fs.readFile(paths.pluginInitPath, "utf8").catch(() => null);
113
181
  const markerPresent = hasHermesPluginMarker(yamlText) && hasHermesPluginMarker(initText);
114
182
  if (!markerPresent) {
115
- return { removed: false, skippedReason: "unexpected-content", ...paths };
183
+ return {
184
+ removed: false,
185
+ skippedReason: "unexpected-content",
186
+ enabledChanged,
187
+ ...paths,
188
+ };
116
189
  }
117
190
 
118
191
  await fs.rm(paths.pluginDir, { recursive: true, force: true }).catch(() => {});
119
- return { removed: true, ...paths };
192
+ return { removed: true, enabledChanged, ...paths };
120
193
  }
121
194
 
122
195
  function buildHermesPluginYaml() {
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Manage the `plugins.enabled` allow-list inside Hermes' config.yaml.
5
+ *
6
+ * Hermes (>= the v20→v21 migration) loads user plugins on an opt-in basis:
7
+ * a plugin in `~/.hermes/plugins/<name>/` only fires its hooks if its name
8
+ * appears in `plugins.enabled` of `~/.hermes/config.yaml` (see
9
+ * `hermes_cli/plugins.py:_get_enabled_plugins` and the discovery loop at
10
+ * `hermes_cli/plugins.py:641`).
11
+ *
12
+ * `installHermesPlugin` therefore must do TWO things to actually wire up the
13
+ * vibeusage hook:
14
+ * 1. Drop the plugin files into `~/.hermes/plugins/vibeusage/` (handled by
15
+ * `hermes-config.js`).
16
+ * 2. Make sure `vibeusage` is listed under `plugins.enabled` here.
17
+ *
18
+ * Implementation notes:
19
+ * - We use the `yaml` package's `parseDocument` to preserve user comments,
20
+ * anchors, and key order. Plain `yaml.parse` + `yaml.stringify` would lose
21
+ * all of that.
22
+ * - All operations are idempotent. An already-enabled plugin stays as-is and
23
+ * the function reports `changed: false`.
24
+ * - The file is written atomically via writeFileAtomic to avoid leaving the
25
+ * user with a half-written config.yaml on crash.
26
+ */
27
+
28
+ const os = require("node:os");
29
+ const path = require("node:path");
30
+ const fs = require("node:fs/promises");
31
+
32
+ const YAML = require("yaml");
33
+ const { writeFileAtomic } = require("./fs");
34
+
35
+ const HERMES_CONFIG_FILENAME = "config.yaml";
36
+
37
+ function resolveHermesConfigPath({ home = os.homedir(), env = process.env } = {}) {
38
+ const explicit = typeof env.HERMES_HOME === "string" ? env.HERMES_HOME.trim() : "";
39
+ const hermesHome = explicit ? path.resolve(explicit) : path.join(home, ".hermes");
40
+ return path.join(hermesHome, HERMES_CONFIG_FILENAME);
41
+ }
42
+
43
+ /**
44
+ * Probe whether a plugin name is currently enabled in `plugins.enabled`.
45
+ *
46
+ * Returns one of:
47
+ * - { state: "enabled" } — name appears in plugins.enabled
48
+ * - { state: "missing-key" } — config exists but plugins.enabled key is absent
49
+ * - { state: "missing-name" } — plugins.enabled exists but does not contain the name
50
+ * - { state: "config-missing" } — config.yaml does not exist
51
+ * - { state: "config-unreadable", error } — read or parse error
52
+ */
53
+ async function probeEnabledPlugin({ home, env, name } = {}) {
54
+ if (!name || typeof name !== "string") {
55
+ throw new Error("name is required");
56
+ }
57
+ const configPath = resolveHermesConfigPath({ home, env });
58
+
59
+ let raw;
60
+ try {
61
+ raw = await fs.readFile(configPath, "utf8");
62
+ } catch (err) {
63
+ if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
64
+ return { state: "config-missing", configPath };
65
+ }
66
+ return {
67
+ state: "config-unreadable",
68
+ error: err && err.message ? err.message : String(err),
69
+ configPath,
70
+ };
71
+ }
72
+
73
+ let doc;
74
+ try {
75
+ doc = YAML.parseDocument(raw);
76
+ } catch (err) {
77
+ return {
78
+ state: "config-unreadable",
79
+ error: err && err.message ? err.message : String(err),
80
+ configPath,
81
+ };
82
+ }
83
+ if (doc.errors && doc.errors.length > 0) {
84
+ return {
85
+ state: "config-unreadable",
86
+ error: doc.errors[0].message || "config.yaml has YAML errors",
87
+ configPath,
88
+ };
89
+ }
90
+
91
+ const enabled = doc.getIn(["plugins", "enabled"], true);
92
+ if (enabled === undefined) {
93
+ return { state: "missing-key", configPath };
94
+ }
95
+ const list = enabled && typeof enabled.toJSON === "function" ? enabled.toJSON() : enabled;
96
+ if (!Array.isArray(list)) {
97
+ return { state: "missing-key", configPath };
98
+ }
99
+ if (list.includes(name)) {
100
+ return { state: "enabled", configPath };
101
+ }
102
+ return { state: "missing-name", configPath };
103
+ }
104
+
105
+ /**
106
+ * Idempotently add `name` to plugins.enabled. Creates the `plugins:` map and
107
+ * `enabled` sequence if either is missing. Preserves comments and surrounding
108
+ * keys via parseDocument.
109
+ *
110
+ * Returns { changed, configPath, configCreated, alreadyEnabled }.
111
+ */
112
+ async function addEnabledPlugin({ home, env, name } = {}) {
113
+ if (!name || typeof name !== "string") {
114
+ throw new Error("name is required");
115
+ }
116
+ const configPath = resolveHermesConfigPath({ home, env });
117
+
118
+ let raw = "";
119
+ let configCreated = false;
120
+ try {
121
+ raw = await fs.readFile(configPath, "utf8");
122
+ } catch (err) {
123
+ if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
124
+ configCreated = true;
125
+ } else {
126
+ throw err;
127
+ }
128
+ }
129
+
130
+ const doc = raw ? YAML.parseDocument(raw) : new YAML.Document({});
131
+ if (doc.errors && doc.errors.length > 0) {
132
+ const err = new Error(`config.yaml has YAML errors: ${doc.errors[0].message}`);
133
+ err.code = "HERMES_CONFIG_INVALID";
134
+ throw err;
135
+ }
136
+
137
+ // Ensure plugins is a Map.
138
+ let pluginsNode = doc.get("plugins", true);
139
+ if (pluginsNode === undefined || pluginsNode === null) {
140
+ doc.set("plugins", new YAML.YAMLMap());
141
+ pluginsNode = doc.get("plugins", true);
142
+ } else if (!(pluginsNode instanceof YAML.YAMLMap)) {
143
+ // Existing non-map value (string, list, scalar). Refuse so we never silently
144
+ // clobber user content. Caller should escalate to the user.
145
+ const err = new Error(
146
+ "plugins key in config.yaml is not a mapping; refusing to modify",
147
+ );
148
+ err.code = "HERMES_PLUGINS_NOT_MAP";
149
+ throw err;
150
+ }
151
+
152
+ // Ensure plugins.enabled is a Seq.
153
+ let enabledNode = pluginsNode.get("enabled", true);
154
+ if (enabledNode === undefined || enabledNode === null) {
155
+ pluginsNode.set("enabled", new YAML.YAMLSeq());
156
+ enabledNode = pluginsNode.get("enabled", true);
157
+ } else if (!(enabledNode instanceof YAML.YAMLSeq)) {
158
+ const err = new Error(
159
+ "plugins.enabled in config.yaml is not a sequence; refusing to modify",
160
+ );
161
+ err.code = "HERMES_PLUGINS_ENABLED_NOT_SEQ";
162
+ throw err;
163
+ }
164
+
165
+ // Idempotent add.
166
+ const current = enabledNode.toJSON() || [];
167
+ if (Array.isArray(current) && current.includes(name)) {
168
+ return {
169
+ changed: false,
170
+ configPath,
171
+ configCreated: false,
172
+ alreadyEnabled: true,
173
+ };
174
+ }
175
+ enabledNode.add(name);
176
+
177
+ const out = String(doc);
178
+ await writeFileAtomic(configPath, out);
179
+
180
+ return {
181
+ changed: true,
182
+ configPath,
183
+ configCreated,
184
+ alreadyEnabled: false,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Idempotently remove `name` from plugins.enabled. Cleans up empty container
190
+ * keys (`plugins.enabled` if it becomes [], and `plugins` if it becomes {}).
191
+ *
192
+ * Returns { changed, configPath, wasEnabled }.
193
+ */
194
+ async function removeEnabledPlugin({ home, env, name } = {}) {
195
+ if (!name || typeof name !== "string") {
196
+ throw new Error("name is required");
197
+ }
198
+ const configPath = resolveHermesConfigPath({ home, env });
199
+
200
+ let raw;
201
+ try {
202
+ raw = await fs.readFile(configPath, "utf8");
203
+ } catch (err) {
204
+ if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
205
+ return { changed: false, configPath, wasEnabled: false };
206
+ }
207
+ throw err;
208
+ }
209
+
210
+ const doc = YAML.parseDocument(raw);
211
+ if (doc.errors && doc.errors.length > 0) {
212
+ const err = new Error(`config.yaml has YAML errors: ${doc.errors[0].message}`);
213
+ err.code = "HERMES_CONFIG_INVALID";
214
+ throw err;
215
+ }
216
+
217
+ const pluginsNode = doc.get("plugins", true);
218
+ if (!(pluginsNode instanceof YAML.YAMLMap)) {
219
+ return { changed: false, configPath, wasEnabled: false };
220
+ }
221
+ const enabledNode = pluginsNode.get("enabled", true);
222
+ if (!(enabledNode instanceof YAML.YAMLSeq)) {
223
+ return { changed: false, configPath, wasEnabled: false };
224
+ }
225
+
226
+ // Find the index, since YAMLSeq.delete by name doesn't match by string value
227
+ // reliably across all node shapes. Iterating and matching the JSON value is
228
+ // the safe path.
229
+ const items = enabledNode.items;
230
+ let foundIndex = -1;
231
+ for (let i = 0; i < items.length; i += 1) {
232
+ const item = items[i];
233
+ const value = item && typeof item === "object" && "value" in item ? item.value : item;
234
+ if (value === name) {
235
+ foundIndex = i;
236
+ break;
237
+ }
238
+ }
239
+
240
+ if (foundIndex < 0) {
241
+ return { changed: false, configPath, wasEnabled: false };
242
+ }
243
+
244
+ enabledNode.delete(foundIndex);
245
+
246
+ // Clean up empty containers so we don't leave noise in user config.
247
+ if (enabledNode.items.length === 0) {
248
+ pluginsNode.delete("enabled");
249
+ }
250
+ if (pluginsNode.items.length === 0) {
251
+ doc.delete("plugins");
252
+ }
253
+
254
+ const out = String(doc);
255
+ await writeFileAtomic(configPath, out);
256
+
257
+ return { changed: true, configPath, wasEnabled: true };
258
+ }
259
+
260
+ module.exports = {
261
+ HERMES_CONFIG_FILENAME,
262
+ resolveHermesConfigPath,
263
+ probeEnabledPlugin,
264
+ addEnabledPlugin,
265
+ removeEnabledPlugin,
266
+ };
@@ -69,6 +69,19 @@ function runSourceAudit({
69
69
  throw new Error(`strategy.${key} is required`);
70
70
  }
71
71
  }
72
+
73
+ if (strategy.supportsAudit === false) {
74
+ return {
75
+ ok: false,
76
+ error: "audit-not-applicable",
77
+ source: strategy.id,
78
+ message:
79
+ `source=${strategy.id} does not support independent ground-truth audit ` +
80
+ "(data comes from the same plugin-ledger pipeline that feeds the DB)",
81
+ rows: [],
82
+ maxDriftPct: 0,
83
+ };
84
+ }
72
85
  if (!Number.isFinite(days) || days <= 0) {
73
86
  throw new Error(`days must be a positive number, got ${days}`);
74
87
  }
@@ -24,6 +24,7 @@ const path = require("node:path");
24
24
  module.exports = {
25
25
  id: "hermes",
26
26
  displayName: "Hermes Plugin",
27
+ supportsAudit: false,
27
28
  sessionRoot({ home, env }) {
28
29
  const base = (env && env.VIBEUSAGE_HOME) || path.join(home, ".vibeusage");
29
30
  return path.join(base, "tracker");
@@ -54,12 +54,12 @@ def post_api_request(session_id="", platform="", model="", provider="", api_mode
54
54
  record.update({
55
55
  "api_mode": str(api_mode or ""),
56
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")),
57
+ "input_tokens": _safe_int(usage.get("input_tokens") or usage.get("input")),
58
+ "output_tokens": _safe_int(usage.get("output_tokens") or usage.get("output")),
59
+ "cache_read_tokens": _safe_int(usage.get("cache_read_tokens") or usage.get("cache_read")),
60
+ "cache_write_tokens": _safe_int(usage.get("cache_write_tokens") or usage.get("cache_write")),
61
+ "reasoning_tokens": _safe_int(usage.get("reasoning_tokens") or usage.get("reasoning")),
62
+ "total_tokens": _safe_int(usage.get("total_tokens") or usage.get("total")),
63
63
  "finish_reason": str(finish_reason or ""),
64
64
  })
65
65
  return _append_record(record)