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 +3 -2
- package/src/commands/doctor.js +14 -5
- package/src/commands/sync.js +16 -3
- package/src/lib/hermes-config.js +82 -9
- package/src/lib/hermes-plugins-config.js +266 -0
- package/src/lib/ops/audit-source.js +13 -0
- package/src/lib/ops/sources/hermes.js +1 -0
- package/src/templates/hermes-vibeusage-plugin/__init__.py +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeusage",
|
|
3
|
-
"version": "0.6.
|
|
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",
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
|
183
|
-
//
|
|
184
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
);
|
package/src/commands/sync.js
CHANGED
|
@@ -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(
|
|
538
|
-
cached_input_tokens: Math.max(
|
|
537
|
+
input_tokens: Math.max(
|
|
539
538
|
0,
|
|
540
|
-
Number(event.
|
|
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));
|
package/src/lib/hermes-config.js
CHANGED
|
@@ -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
|
|
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:
|
|
85
|
-
detail:
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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)
|