Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Section titled “[Unreleased]”Upgrade notes
Section titled “Upgrade notes”- Schema v17 → v18. One forward-only migration runs automatically on first
load. It adds
llm_calls.turn_id/task_id/api_request_id,tool_calls.turn_id/tool_call_id, and the newllm_call_errorstable (+ index). All five columns are nullable; rows written before the upgrade keep NULL — their turn ids were never captured, and NULL is the honest value. No backfill, no data rewrite, no rollup table.
Added — Turn identity on llm_calls / tool_calls (schema v18)
Section titled “Added — Turn identity on llm_calls / tool_calls (schema v18)”turn_id({session_id}:{task_id}:{8hex}),task_idandapi_request_idare now persisted on everyllm_callsrow, andturn_id+tool_call_idon everytool_callsrow — all four were already on the hook payloads and were simply dropped. Per-turn cost / latency / token totals are now aGROUP BY turn_idquery instead of a timestamp heuristic, and the existingsubagent_edges.parent_turn_idjoins to the parent’s own calls (SELECT … FROM subagent_edges se JOIN llm_calls lc ON lc.turn_id = se.parent_turn_id— filterse.parent_turn_id > '': it is populated on only some edges, so an unqualified count under-reports).- The
subagent_stopproxy row (delegate_task/subagent) carries the parent turn that delegated. Itstool_call_idstays NULL — a delegation has no model-emitted tool call id.
Added — Per-call failure ledger llm_call_errors (schema v18)
Section titled “Added — Per-call failure ledger llm_call_errors (schema v18)”- Failed API attempts are recorded per call, not just as the 404-class aggregate
in
model_unavailable_alerts: theapi_request_errorhook now writes onellm_call_errorsrow for every firing — any status, retryable or not — before the 404-specific branch. Attempt-level by design (N attempts → N rows, no dedupe), so retry storms, 429/5xx/timeout/auth failures, and “how many attempts did this call take” become answerable from the DB. - Columns:
session_id,ts,turn_id,task_id,api_request_id,api_call_count,model,provider,status_code,error_type,error_message(≤500 chars),retry_count,max_retries,retryable,reason,duration_ms+idx_llm_call_errors_session(session_id, ts). - Row counts are not budget inputs: nothing else reads this table, and no aggregate or alert behaviour changed.
Fixed — the api_request_error handler read the wrong payload shape
Section titled “Fixed — the api_request_error handler read the wrong payload shape”- The handler declared top-level
api_kwargs/error_type/error_message/api_start_time, but the emitter (agent/api_request_hooks.py) sends the error text nested aserror={"type", "message"}, the request asrequest={"method", "body"}(model inbody["model"]), and the duration asapi_durationin seconds. Under live bytes those parameters resolved to"", so the model-unavailable alert stored an emptyerror_messagefor a real 404 and looked up the wrong kwarg for the model id. The handler now reads the nested shape first with the flat names kept as a fallback. The 404 alert’s filter, upsert, queueing and injected wording are otherwise unchanged — a live-shaped 404 now stores the real message.
Fixed — v18 review round (turn identity + failure ledger)
Section titled “Fixed — v18 review round (turn identity + failure ledger)”turn_idnow has the indexes its own queries need. v18 shipped turn identity but onlyidx_llm_call_errors_session, so the per-turn rollup (GROUP BY llm_calls.turn_id) and the delegation join (subagent_edges.parent_turn_id = llm_calls.turn_id) fell back to scanning everyllm_callsrow a long-lived DB has accumulated.idx_llm_turn(llm_calls(turn_id)) andidx_tool_turn(tool_calls(turn_id)) are created in the same migration, and both migration tests now assert them by name and by indexed column.- A non-finite
api_durationno longer silences the failure it accompanies.float()/round()raised oninf/nan/ a non-numeric string above the ledger’s own guard, so the outer handler swallowed the entire firing: no ledger row and no 404 alert — the silent-failure class the ledger exists to close. (1e300raised later, at the SQLite int64 bind inside the ledger guard, losing just the row.) The conversion has its ownexcept (TypeError, ValueError, OverflowError)and leavesduration_msNULL. - Unknown
retryableis NULL, and the two surfaces now agree. The handler’sretryablekwarg defaulted toTrue, so a payload that omitted the field recordedretryable = 1— a classification it never made — while the guardif retryable:kept a genuine non-retryable 404 out of the alert path. The default is nowNone(the writer stores NULL) and the guard readsif retryable is not False:; changing the default alone would make an unknown falsy and fire the alert on every unclassified 404.
The retryable and api_duration gaps were both found by a community reviewer.
Known, out of scope: __init__.py:383 (latency_ms = int(api_duration * 1000))
carries the same unguarded-coercion shape as the failure path fixed above. It is
pre-existing on main, sits in the success path, and a non-finite api_duration
there silently loses the llm_calls row and its runs totals. Deliberately not
changed here — this change stays limited to the four requested items.
Fixed — sync_profiles repair hint branches on plugin install state (#132)
Section titled “Fixed — sync_profiles repair hint branches on plugin install state (#132)”The dry-run warning for profiles where the plugin is not enabled now prints the
correct verb per profile: hermes plugins install hermes-telemetry -p <name> --enable
if the plugin is not installed, or hermes plugins enable hermes-telemetry -p <name>
if installed but not enabled. Previously it always printed the enable verb
(which fails for not-installed profiles). JSON output and status collection
unchanged. Tests added for both branches and a mixed case.
Fixed — tool outcome trusts the runtime status (#120, #116)
Section titled “Fixed — tool outcome trusts the runtime status (#120, #116)”post_tool_callnow recordsokfrom thestatuskwarg Hermes core passes ("ok"/"error"/"blocked"/"cancelled"/"timeout", seeagent/tool_executor.py). Anything other than"ok"recordsok=0and keeps its row. Previously_is_tool_oktreated the mere presence of anerrorkey as failure, so every successfulterminalcall (always{"error": null, ...}) was recordedok=0.- Hermes builds that predate
statusfall back to a classifier that approximates core’s derivation: truthyerror, non-zeroterminalexit_code, orsuccess: falsefail; the row is never dropped. No schema change. - Thanks to @cloudsea35 for the original diagnosis and fix direction in #116.
Fixed — throttled hot-path hook error logging
Section titled “Fixed — throttled hot-path hook error logging”pre_tool_call / post_tool_call / pre_llm_call fire on every tool call, and
a failing hook used to log (and lock) once per call — under sustained pressure
(e.g. a transient disk I/O error) the same failure amplified itself into the
log on every call. Hook failures now emit at most once per 300s per
(prefix, exception class) per plugin instance — the throttle state is
module-level, and Hermes keys a plugin manager (with its own imported
submodules) per resolved Hermes home, so a process serving N profiles holds N
copies and the real ceiling is N emits per window — with a
suppressed-occurrence count on the next emit reporting the actual elapsed
window. The interval deliberately differs from
the Hermes core’s 60s hook-timeout suppression window
(_HOOK_TIMEOUT_SUPPRESSION_SECONDS in hermes_cli/plugins_dispatch.py) so
the two never beat against each other, and the per-exception-class key means a
different failure raised inside the window is surfaced immediately rather
than swallowed by the previous one’s suppression.
Fixed — pricing.yaml atomic writes and failed-read caching (#105, #106)
Section titled “Fixed — pricing.yaml atomic writes and failed-read caching (#105, #106)”pricing.yamloverrides could be lost for the rest of a process’s life (#105, #106). Under Hermes multiplex, several plugin instances in one gateway process share onepricing.yaml, and a refresh rewrote it in place, so a concurrent reader could see an empty or partial file. The loader then cached that failed read permanently, dropping every override (including_subscription: trueflat-rate entries) and billing those models at list rate. Writes from the price refresh,pricing drift --applyand the/setupwizard are now atomic (temp file +os.replace, existing permissions kept, symlinkedpricing.yamlfollowed rather than replaced), and a failed read is no longer cached: the file is re-read as soon as it changes, with no reload needed.
Fixed — budget.yaml atomic writes and failed-read caching (#135)
Section titled “Fixed — budget.yaml atomic writes and failed-read caching (#135)”budget.yamlwrites were not atomic, and a failed read silently disabled enforcement until reload (#135). Same bug class as #105/#106, on the budget side — with worse consequences: a partial read meant lost enforcement, not list-rate billing. All three writers (budget set,setup_wizard._write_budget, the dashboard budget edit) now route through an atomic writer (unique temp file + fsync +os.replace, existing mode kept, symlinkedbudget.yamlfollowed rather than replaced), mirroring #134’spricing.yamlfix — and it is the SAME shared primitive, so there is one implementation on the plugin side; the dashboard keeps a duplicated helper because the standalone surface cannot import plugin code (parity test included).load_config()no longer memoizes a failed read: the failure is recorded as the file’s stat signature captured before the read, and the next call retries as soon as the file changes — noreload_config()or restart needed. A missingbudget.yamlstill means budgets are disabled.
0.9.1 - 2026-10-14
Section titled “0.9.1 - 2026-10-14”-
DeepSeek current ids priced at retired V3 rates (#121).
deepseek-flash,deepseek-v4-flashanddeepseek-v4-prohad no built-in entry and fell to the baredeepseekprefix (0.27/1.10 plus a generic 0.10x cache read), overstating cost forcustom:<name>providers, which never get a Hermes core snapshot. They now carry explicit off-peak rates matching Hermes core (Flash 0.15/0.60, cache read 0.003; Pro 0.66/1.98, cache read 0.022; cache write = input), anddeepseek-chat/deepseek-reasonerand the baredeepseekprefix follow Flash.deepseek-v3/deepseek-r1are unchanged. Vendor-qualifieddeepseek/...ids are aggregator ids (Nous / OpenRouter) and intentionally keep the prior 0.27/1.10 fallback, since their authoritative price is the core snapshot. Peak-hour (2x) pricing is tracked in #122. -
Standalone dashboard: two error paths interpolated
err.messageintoinnerHTMLunescaped (model-efficiency mount and the top-level load error); both now go throughescHtml, matching the other error boxes (#124).
Changed
Section titled “Changed”- Runtime dependencies are now upper-bounded below the next major (
watchdog>=3.0,<7,pyyaml>=6.0,<7) so a future breaking release cannot reach users on a plain reinstall (#124).
Documentation
Section titled “Documentation”- Documentation site (#125, #126, #131). A Starlight site now hosts the docs:
hand-written guides (installation, quick start, setup wizard, slash commands,
standalone CLI, dashboard, budgets, pricing, profiles, provider probe), reference
pages (configuration, known limitations, troubleshooting), and Internals and
Changelog pages generated from
ONBOARDING.mdandCHANGELOG.md. The README is slimmed to an entry point that links to each topic’s page. Several stale README claims were corrected along the way, notably that a hard budget breach blocks further tool calls (the in-flight model call still completes and is billed).
0.9.0 - 2026-10-07
Section titled “0.9.0 - 2026-10-07”Upgrade notes
Section titled “Upgrade notes”- Schema v13 → v17. Four forward-only migrations run automatically on first
load:
v14adds thepricing_snapshotstable (#71);v15addspricing_snapshots.resolved_model(#72);v16adds theendpoint_payload_cacheandmodel_efficiency_cachetables (#53);v17is a data-only repair that deletes falseknown_free_modelsrows and thefree_paid_transitionsrows they produced (#117). On an install that was not affected by #89,v17deletes nothing. - Module rename
setup.py→setup_wizard.py(#118). The setup wizard was renamed so setuptools no longer executes it as a build script. Anything that imports it directly (custom scripts, forks, docs) must switch tosetup_wizard; the/setupcommand andhermes telemetry setupare unchanged. - New runtime dependency:
pyyaml>=6.0(#118). It is now declared inpyproject.toml, so pip-based installs pull it in. Hosts that load the plugin from a checkout into an existing Hermes venv need PyYAML available there. - Observation mode is opt-in;
enforcestays the default (#114). Upgrading keeps today’s budget guardrails. Only an explicitplugins.entries.hermes-telemetry.settings.mode: observeturns enforcement off. - Costs now prefer the core-sourced tariff (#94). Where Hermes core has
already resolved a price for a
(provider, model)pair, it outrankspricing.yaml. Totals for models whosepricing.yamlentry disagreed with the core tariff can therefore change after upgrade (see the Changed section).
Added — Core-sourced pricing snapshots (#71, #72)
Section titled “Added — Core-sourced pricing snapshots (#71, #72)”- New
core_pricing.pyseam persists an append-per-change history of the tariffs Hermes core resolves per(provider, model)(viaagent.usage_pricing.get_pricing_entry), with provenance:source,source_url,pricing_version,fetched_at. Capture happens inpost_api_request, is throttled to once per 6 hours per(provider, model)per process, and is fail-open: if core is absent or cannot resolve the model, nothing is recorded and the hook is unaffected. - Dated model ids (e.g.
deepseek/deepseek-v4-pro-20260423) miss the provider/modelscatalog, which lists only canonical names. Capture now retries under the canonical name (a trailing-YYYYMMDDis stripped, including before:free) and stores the snapshot under the raw name, with the canonical name in the newresolved_modelcolumn. - Schema v14 (
pricing_snapshots+idx_pricing_snapshots_model) and v15 (pricing_snapshots.resolved_model).
Added — hermes telemetry pricing backfill (#73)
Section titled “Added — hermes telemetry pricing backfill (#73)”- Dry-run by default;
--applywrites,--jsonfor machine output. Seeds one current pricing snapshot for every(provider, model)inllm_callsthat has none yet, so historical dated ids that never get a live snapshot gain coverage. It is a coverage seed, not a historical reconstruction: rows carry the tariff core resolves today. Idempotent, fail-open, no schema change.
Added — hermes telemetry pricing drift (#75)
Section titled “Added — hermes telemetry pricing drift (#75)”- Dry-run by default; compares
pricing.yamlinput/output rates against the latest core snapshot per model and reports entries beyond--threshold(default 1%).--applymerges the core rates back intopricing.yaml(tagging each repaired entry_source: core-snapshot) and hot-reloads;--modelscopes to one model,--jsonfor machine output. - Skips
_subscription: trueentries and treats_provider_assumedguesses as “no local price” rather than drift. If models inllm_callshave no snapshot, the report points atpricing backfillso a clean run is not a false all-clear. No schema change.
Added — Observation-only telemetry mode (opt-in) (#114)
Section titled “Added — Observation-only telemetry mode (opt-in) (#114)”- New plugin setting
plugins.entries.hermes-telemetry.settings.modewith choicesenforce(default) andobserve. Observation mode keeps collecting and reporting telemetry but does not auto-createbudget.yaml, inject budget notices, block tools viapre_tool_call, or pause cron jobs. Existing installs keep today’s guardrails on upgrade: missingmode, invalid or non-string values, and older Hermes hosts withoutctx.get_configall resolve toenforce. The budget file watcher still runs in both modes so/budgetstays fresh. When observe is active with abudget.yamlpresent,/budgetand the dashboard budget panel plainly say limits are not enforced.
Added — Dashboard EN/RU language toggle (#92)
Section titled “Added — Dashboard EN/RU language toggle (#92)”- Standalone dashboard (
dashboard/index.html) is EN-first with an EN↔RU toggle: EN strings are the source of truth,dashboard/i18n_ru.jsis the RU overlay (one key per line, leaf text only),dashboard/i18n.jsholds the shippedi18n_t/__DYN/__RUlogic shared by the page anddashboard/i18n.test.js. Defaultenwith<html lang="en">,document.documentElement.langsynced on load and toggle, in-place re-render preserving drilldown/filters/scroll, persistence keyhermes_telemetry_lang. Status badge class resolves from the raw value; EN path is identity, RU the only override. Known limitation: the plugin widget (dashboard/dist/index.js) stays English.
Added — profile scope in budget set (#79)
Section titled “Added — profile scope in budget set (#79)”budget setnow acceptsglobal | cron_job | sender | profile, in chat (/budget set <scope> [<id>] <window> <usd>, e.g./budget set profile faro monthly 50) and in the standalone CLI (hermes-telemetry budget set <scope> <window> <usd> [--id <name>]). With an id it writesbudgets.per_<scope>.overrides.<id>; without one, the scope’sdefaultbucket. The id-override form also works forcron_jobandsender. Previouslyprofilewas rejected as “Unknown scope” even though enforcement, forecast, and manual YAML already honored it.
Changed — Core-sourced pricing snapshots are now the primary cost source (#94)
Section titled “Changed — Core-sourced pricing snapshots are now the primary cost source (#94)”estimate_cost()now prefers the tariff Hermes core itself resolved for a(provider, model)pair (already captured inpricing_snapshotsbypost_api_request, read locally from SQLite — no new network calls) overpricing.yaml/_DEFAULT_PRICING. A declared_subscription: trueentry and the:freesuffix rule still outrank the core snapshot; a cold-start pair with no snapshot yet falls back to the existingpricing.yamlchain unchanged.hermes telemetry pricing drift/pricing backfillremain useful for auditingpricing.yamlitself but are no longer required to keep costs accurate. An incomplete core snapshot (missinginputand/oroutput) is ignored rather than priced at$0. SeeONBOARDING.md § Pricing Engine → Lookup priority chain.
Changed — Standalone dashboard caches heavy endpoints (#53)
Section titled “Changed — Standalone dashboard caches heavy endpoints (#53)”- Model efficiency, providers, provider health, and the daily token / daily
model charts are cached in SQLite and refreshed in the background, so the
standalone dashboard stays responsive on large databases; the heavy
model-efficiency fetch is deferred until the Breakdown page renders.
Schema v16 adds
endpoint_payload_cacheandmodel_efficiency_cache.PRAGMA journal_mode=WALis now serialized with schema initialization, removing a lock race between concurrent first connections. Telemetry collection, budgets, and pricing are unchanged.
Fixed — pyyaml declared as a runtime dependency (#113, #118)
Section titled “Fixed — pyyaml declared as a runtime dependency (#113, #118)”pyyamlis imported at runtime by the pricing, budget, and stats loaders but was missing from[project].dependencies, so a cleanpip installbroke on first load. It is now declared (pyyaml>=6.0). Package discovery was also fixed for non-uv build frontends by mapping the repo root tohermes_telemetryexplicitly, and the wizard module was renamed fromsetup.pytosetup_wizard.pyso setuptools no longer tries to exec it as a build script.tests/test_packaging.pyguards all three.
Fixed — /budget status block did not list per-profile budgets (#70, #95)
Section titled “Fixed — /budget status block did not list per-profile budgets (#70, #95)”/budget set profile <id> ...and/budget forecast ... profile <id>already worked, but plain/budgetnever showed per-profile status — onlyglobal,cron_job, andsenderscopes rendered. Addeddb.list_profile_ids()(mirrorslist_cron_job_ids/list_sender_ids) and aProfiles:section in_status_block(), closing the command-surface parity gap betweenset,forecast, andstatusfor theprofilescope. Only profiles seen in runs within the last 30 days appear, matching the existing cron-job/sender window.
Fixed — Date-range label collapsed to “X to X” when only --from was given (#37, #93)
Section titled “Fixed — Date-range label collapsed to “X to X” when only --from was given (#37, #93)”hermes-telemetry stats --from <date>used to auto-fill the missing--towith the current timestamp, so the rendered label read as a same-day range (e.g. “2026-06-16 to 2026-06-16”) instead of an open-ended one._resolve_date_rangenow leavesdate_toasNonewhen--tois omitted, matching the label behavior the/stats --fromslash command already had.stats._date_range_labelrenders that case assince <date>. The DB layer already treated a missingdate_toas an unbounded upper limit, so query results are unchanged — only the label and the JSONdate_tofield (nownullinstead of an auto-filled timestamp) reflect the real input.
Fixed — Paid models recorded as free on zero-token calls, then raising false free→paid alerts (#89, #117)
Section titled “Fixed — Paid models recorded as free on zero-token calls, then raising false free→paid alerts (#89, #117)”post_api_requestadded a model toknown_free_modelswhenever a call cost $0 and the model had any explicit price. A paid model (e.g.deepseek-v4-pro) whose call happened to use zero tokens therefore qualified, and every later real call to it queued a false free→paid alert. Detection now goes through the newpricing.is_free_model(), which is true only for a model priced at input=0 AND output=0.- Schema v17 is a data-only repair of installs that were already affected. It
deletes
known_free_modelsrows unless there is evidence the model was genuinely free: aprovider=''wildcard row, or anllm_callsrow for the same model and provider at $0 that used tokens. It also deletes thefree_paid_transitionsrows those false entries produced. No columns or tables change, and on an unaffected install the migration deletes nothing.
Fixed — pricing drift --apply could pin a stale price across providers (#85)
Section titled “Fixed — pricing drift --apply could pin a stale price across providers (#85)”- When two providers (e.g.
customandnous) share an API and both have snapshots for the same canonical model,--applyused to take the alphabetically-first provider’s snapshot, which could overwrite the current price with an older one (issue #84). Drifted entries are now ordered by snapshot recency, so the most recent snapshot wins; the multi-provider warning is kept and reworded. No schema change.
Fixed — pricing_refresh ignored HERMES_TELEMETRY_HOME (#78)
Section titled “Fixed — pricing_refresh ignored HERMES_TELEMETRY_HOME (#78)”- The auto-refresh resolved
pricing.yamlfrom an import-time constant based onHERMES_HOMEonly, so on a consolidated install it could write to a per-profile file while the cost path read the consolidated one. It now resolvespaths.get_pricing_path()at call time, like every otherpricing.yamlreader and writer.
Fixed — Dashboard ignored an explicit UTC viewer timezone on Python 3.8 (#74)
Section titled “Fixed — Dashboard ignored an explicit UTC viewer timezone on Python 3.8 (#74)”- Without
zoneinfo(Python 3.8),_dashboard_viewer_tzfell back to the server’s local timezone even when the viewer asked forUTC. UTC is now always honored viatimezone.utc; other IANA zones still needzoneinfo.
Fixed — Unbounded DISTINCT JOIN in the model-efficiency query (#82)
Section titled “Fixed — Unbounded DISTINCT JOIN in the model-efficiency query (#82)”- The standalone dashboard’s model-efficiency query joined without bounding the time window, causing CPU spikes on large databases. The join is now limited to the requested window, and the background cache refresh no longer short-circuits itself (supersedes #76).
Fixed — Standalone hermes-telemetry entry point was not executable (#88)
Section titled “Fixed — Standalone hermes-telemetry entry point was not executable (#88)”hermes-telemetryand.githooks/pre-commitwere tracked as mode100644, so the documented standalone install failed withPermission deniedand the pre-commit hook was silently ignored by git. Both are now tracked100755, with a regression test.
Fixed — plugin.yaml did not declare the subagent_start hook (#103)
Section titled “Fixed — plugin.yaml did not declare the subagent_start hook (#103)”register()registerssubagent_startbutplugin.yamldid not list it underprovides_hooks, sohermes plugins validatefailed. It is now declared; no runtime behavior changes.
Internal
Section titled “Internal”- CI: fork PRs now run via
pull_request_target(#86), with an explicit fork-checkout opt-in, read-onlycontents: readpermissions, and a SECURITY note documenting the invariants that keep it safe (#87). Ruff is pinned via.ruff-versionso a new release cannot turn CI red on its own, and*.mdis excluded fromruff format(both landed with #88). - Tests: a pinning test for the dated
:freeslug against the paid base model (#90, issue #54). No behavior change;pricing.pyis untouched. - Docs: corrected the dashboard slot catalogue and recorded the Hermes session
model in
ONBOARDING.mdandCLAUDE.md(#96); corrected the attribution of the v0.8.0 agent-intelligence work to #40 instead of #8 (#109).
0.8.0 - 2026-07-09
Section titled “0.8.0 - 2026-07-09”Fixed — /stats models mislabeled known-free $0 rows as “no price entry”
Section titled “Fixed — /stats models mislabeled known-free $0 rows as “no price entry””- A
$0.00row resolved by the:freesuffix rule (including the dated ids a gateway sends, e.g.tencent/hy3-20260706:free) or a built-in $0 seed was bucketed asno price entry, wrongly implying a lookup miss and prompting the user to run/setup pricing auto. The Notes column consulted onlysubscription_modelsand ignoredis_explicitly_priced, so it could not tell a known-free model from a genuine unknown.stats._models_blocknow splits $0 rows three ways —subscription/free-tier>free tier(:freesuffix or built-in $0 seed) >no price entry— with a matching footer line. Documented the:free-vs-_subscriptiondistinction (and the exact-id requirement for a_subscriptiondeclaration) in the README free-tier notice and ONBOARDING.
Added — Agent intelligence: efficiency scoring, smell detection, burn-rate forecasting (#40)
Section titled “Added — Agent intelligence: efficiency scoring, smell detection, burn-rate forecasting (#40)”Three read-only analytical capabilities over existing telemetry (no new capture, no schema change), each exposed through the slash command, the CLI, and both dashboards.
- Agent Efficiency Score (0-100) —
db.efficiency_runs()scores each completed session from token productivity, error status, and turn count;/stats efficiencyandhermes-telemetry stats efficiency. The error penalty is keyed on the real run statuses (error/interrupted) — the subagent-onlyfailed/cancellednever reach a run, so keying on them scored failed runs as healthy. - AI Smell Detection — new
smell_detector.pywith five heuristics (context rotation, loop trap, tool thrashing, high error rate, massive session);/stats smellsand CLI. Best-effort: a broken detector is logged and skipped. - Burn-rate Forecasting —
budget.burn_rate_projection()learns a 14-day moving-window spend rate and projects window-end spend against the configured limit;/budget forecastand CLI, for theglobal/cron_job/sender/profilescopes. - Dashboards — all three surfaced in both the standalone dashboard
(
/api/efficiency,/api/smells,/api/budget/forecast) and the Hermes plugin (/efficiency,/smells,/forecast). The standalone forecast route, which previously raisedImportErrorunder the standalone loader and never resolved a non-global limit, was reimplemented inline with a correct config-key → engine scope mapping.
- Standalone dashboard now serves Chart.js from a vendored local asset at
dashboard/vendor/chart.umd.min.jsand uses jsDelivr only as a fallback. This removes the main cause ofChart is not definedwhen the CDN is unreachable or filtered.
Added — Mixture-of-Agents (MoA) attribution (#55)
Section titled “Added — Mixture-of-Agents (MoA) attribution (#55)”Hermes added a moa virtual provider: selecting a MoA preset runs N reference
models plus an aggregator. The aggregator is the acting model, but
post_api_request reports provider="moa" and model="<preset>" — neither
priceable — while the usage belongs to the aggregator.
moa.py(new): resolves a preset name to its aggregator’s realprovider/modelviahermes_cli.moa_config.resolve_moa_preset. Fully defensive — never raises, falls back to raw hook values.post_api_requestre-attributes MoA calls to the aggregator’s real provider/model, fixing the spuriousprovider_assumedpricing fallback, and tags the row with the preset name.- Schema v10 (
_migrate_v10):llm_calls.moa_preset+runs.moa_calls. /stats summaryand both dashboards surfacemoa_calls/moa_preset, each noting that reference-model tokens are untracked (cost is a lower bound).- Reference-model cost is deliberately not estimated (no token counts
available), and the one-shot
/moa <prompt>path runs through the auxiliary (no-hook) client, so it is invisible to telemetry by Hermes’ design — both documented as Known Limitations.
Verified against NousResearch/hermes-agent@main: agent/moa_loop.py,
agent/auxiliary_client.py, hermes_cli/moa_config.py.
Added — Attribute async/nested subagent cost to per_cron_job budgets (#49)
Section titled “Added — Attribute async/nested subagent cost to per_cron_job budgets (#49)”Subagent (delegate_task) spend is now attributed to the parent cron job’s
per_cron_job budget, closing the gap where child cost landed only in global
totals and was invisible to per-cron-job budgets.
- New
subagent_edgestable (schema v11) records each parent→child delegation edge, keyed bychild_session_id(the only correlation key present in bothsubagent_startandsubagent_stop). Populated by the newly-registeredsubagent_starthook (fires synchronously before async dispatch) and finalized bysubagent_stop(stopped_at+child_status), with a backfill path ifsubagent_startwas missed. db.spend_by_scope("cron_job", …)resolves the whole delegation subtree via a recursive CTE (seed from cron root sessions, walk edges down), so async (background=true) and nested delegation attribute to the root cron job.globalis unchanged, so the same cost rows are only regrouped — no double counting.estimated_price_sharemade subtree-aware for the same scope so the budget hard→soft degradation stays consistent.- Query-time
unattributed_child_costdiagnostic (no stored state) surfaced in/stats cron; the/budget cronnote updated to reflect the new attribution.
Phase 1 is reporting/aggregation correctness; in-path enforcement of a runaway async child mid-flight is deferred to Phase 2.
Added — Per-profile scoping and Global disclosures in the dashboard plugin (#66)
Section titled “Added — Per-profile scoping and Global disclosures in the dashboard plugin (#66)”The plugin dashboard is now legible about scope: every surface either respects the selected Hermes profile or is explicitly labeled Global. Previously the per-profile selector only scoped Summary/Runs/Requests/Providers/Cron, while Efficiency, AI smells, the last-run and cron-7d widgets, and the budget section silently stayed global.
- Profile-scoped endpoints —
/efficiencyand/smellsnow accept an optional?profile=(filtering onruns.profile; smells qualifies it asr.profileacross itstool_calls→runsjoin), joining the endpoints that already supported it. - Per-profile budget display —
/budgetemits per-profile scope entries (profile:<name>/<window>with ascope_id, override-else-default limits) alongside the global ones whenbudgets.per_profileis configured. Display only; the budget engine already modeled per-profile scopes. - Scope disclosures — a
ScopeBadgeon every surface shows the active scope (the profile name, or “All profiles”) or “Global” for surfaces that cannot be scoped (burn-rate forecast, tier-change and model-unavailable widgets). - Own selectors for off-page widgets — the last-run (
sessions:top) and cron-7d (cron:top) slot widgets render on other shell pages and cannot see the Telemetry tab’s selector, so each carries its own profile selector.
Plugin surface only (the standalone dashboard is a later effort); no schema
change (runs.profile already exists).
[0.7.0] - 2026-06-20
Section titled “[0.7.0] - 2026-06-20”Added — Dashboard widget for model-unavailable alerts
Section titled “Added — Dashboard widget for model-unavailable alerts”Surfaces the 404s captured by the api_request_error hook on the
telemetry dashboard, sibling to the existing free→paid widget:
- New endpoint
GET /model-unavailable?window_hours=72indashboard/plugin_api.py, returning rows frommodel_unavailable_alertsnewestlast_seen_atfirst. Missing-table is treated as empty so the dashboard stays functional on pre-v8 installs. - New
ModelUnavailableWidgetindashboard/dist/index.js, rendered insideTelemetryPagenext toTierTransitionsWidget. Same happy-path contract — renders nothing when the table is empty in the window so the card only appears when there is something to act on. Destructive badge if the latest alert is within 24h, outline otherwise. - 3 new tests in
tests/test_dashboard_plugin_api.py: empty case, recorded alert roundtrip, window filtering.
Rendered inside TelemetryPage rather than via registerSlot for the
same reason as the free→paid widget: the Hermes shell slot catalogue
(sessions:top, cron:top, header-right, analytics:bottom) doesn’t
include an alerts slot, and unknown slot names are silently dropped.
Docs — api_request_error shadowed in production by a stale backup dir
Section titled “Docs — api_request_error shadowed in production by a stale backup dir”Real 404s in production (cron nvidia-free-paid-probe hitting
nvidia/nemotron-3-ultra:free after the free promo ended on 2026-06-18)
never reached the handler shipped in PR #44: no row was written to
model_unavailable_alerts and no warning was injected. Root cause was
not in this repo. A backup directory hermes-telemetry.bak.1781730291
left alongside the active plugin in ~/.hermes/plugins/ declared the same
name: hermes-telemetry in its plugin.yaml. Hermes’ loader indexes
plugins by name; on collision the later-parsed manifest wins silently,
and filesystem order meant the .bak (pre-PR #44, no register_hook
for api_request_error) was loaded instead of the active dir. Resolution
on the server was a one-line mv of the backup out of ~/.hermes/plugins/.
Documented the trap in ONBOARDING.md § Plugin Discovery Gotcha so the
next backup-next-to-plugin scenario gets caught at the doc, not after a
week of silent 404s. Also declared api_request_error in
plugin.yaml:provides_hooks — that field is purely declarative (the
loader does not filter against it), but it should match the registered
hooks for accuracy.
Added — Model-unavailable alert via api_request_error (issue #43)
Section titled “Added — Model-unavailable alert via api_request_error (issue #43)”Surfaces 404s (model removed/deprecated by the provider) the same way the
free→paid alert surfaces price flips: a one-shot in-context warning on the
next pre_llm_call, plus a persisted row for the dashboard.
- New hook handler
api_request_error(registered in__init__.py) filters tostatus_code == 404ANDretryable is False. The Explorer-style “model not found” signal is captured at the source inagent/conversation_loop.py— long before the error is diluted into theRuntimeErrorthat hitscron.scheduler. - Schema v7 adds
model_unavailable_alerts (model, provider, error_code, error_message, first_seen_at, last_seen_at, occurrences)with PK(model, provider). Repeated 404s for the same pair bumpoccurrencesand refreshlast_seen_atviaINSERT ... ON CONFLICT DO UPDATE. - New DB helpers:
record_model_unavailable(),get_model_unavailable(),recent_model_unavailable(window_hours). - New in-process queue
_pending_model_unavailable_alerts[session_id]; the nextpre_llm_callinjects a warning that names the model, provider, status code, and occurrence count, then clears the entry (one-shot per session, same pattern as the free→paid alert). - 11 new tests:
test_db.pycovers upsert + idempotency + provider scoping + window filtering + long-message roundtrip;test_init.pycovers the filter (404 non-retryable only), the queue path, one-shot injection, and occurrence counting against the DB.
Concrete trigger: the nvidia/nemotron-3-ultra:free cutoff at 8 PM ET on
2026-06-18. hermes-agent does NOT silently re-route a 404 — it raises
NotFoundError and the cron job ends. Pre-#43, telemetry recorded nothing
about the failure; the user found out only by greppping agent.log.
Schema note: rebased on top of #42 — this migration is now v8 (the v7 slot belongs to the
provider_assumedcolumns added by #42).
- Provider-aware pricing guard no longer records
cost=0on real paid calls (issue #42). When the only matching price for a call was an OpenRouter-sourced entry served by a different provider (e.g. Nous Portal resellingmoonshotai/kimi-k2.6at the OpenRouter rate), the guard rejected it and the call silently recorded zero spend — the worst failure mode for a cost tracker. The lookup now applies the rate as a best-effort estimate, tags it_provider_assumed, and logs a one-time WARNING per(model, provider)pair advising the user to pin the rate. Source-neutral,_subscription, and_DEFAULT_PRICINGseed entries still take precedence, so the flat-sub and NIM same-id collisions remain correctly priced. SeeONBOARDING.md § Provider-aware lookup guard.
Added — Provider-assumed pricing visibility (issue #42)
Section titled “Added — Provider-assumed pricing visibility (issue #42)”- Calls priced with an assumed rate are now persisted and surfaced, not just
logged:
- DB schema v7 adds
llm_calls.provider_assumedand aruns.provider_assumed_callscounter (db._migrate_v7), mirroring the existingestimatedper-call flag. pricing.is_provider_assumed(model, provider)predicate (parallel tois_explicitly_priced);post_api_requestflags each affected row./stats providersgains anAsm%column; the dashboard (standalone and Hermes plugin) shows anAsm?column in Requests and anAssumedcount in Providers (provider_assumed_calls/provider_assumed_pctin the API).- Assumed cost counts as real spend for budgets — it does not degrade hard verdicts (unlike estimated usage), since the resold-at-same-rate case is usually the correct number.
- DB schema v7 adds
Added — Free→paid model transition widget in /telemetry
Section titled “Added — Free→paid model transition widget in /telemetry”A new TierTransitionsWidget rendered at the top of TelemetryPage
surfaces models that flipped from $0 to a paid charge within the last
72h. Builds on the existing detection in post_api_request (issues
#16/#32) by persisting every flip to a new free_paid_transitions
table, so the dashboard has a historical record beyond the one-shot
in-memory alert. Verified end-to-end against a live Hermes install.
- Schema v6: new
free_paid_transitions(model, provider, detected_at, session_id, first_paid_cost_usd, first_free_seen_at)table. PRIMARY KEY(model, provider)— first flip per pair wins; later paid calls are no-ops viaINSERT OR IGNORE. post_api_requestpersistence: in addition to queueing the one-shot in-context alert, every detected flip now callsdb.record_free_paid_transition(...)so the dashboard sees it on reload.- Read-only endpoint
GET /api/plugins/hermes-telemetry/tier-transitions?window_hours=72returns{ window_hours, rows: [...] }. Missing-table is treated as “nothing flipped yet” so pre-v6 installs keep working. - Widget: badge turns
destructiveif any flip is within 24h; shows up to 5 rows (model · provider · was free for Nd · first charge $X). Hides itself when no flips fall in the window — the page is unchanged on the happy path. - No new shell slot: rendered inside
TelemetryPage, not viaregisterSlot(). The Hermes shell only mounts slots from its catalogue (sessions:top,cron:top,header-right,analytics:bottom); registering an unknown slot name is a silent no-op. CLAUDE.md and ONBOARDING.md now record this rule so it isn’t re-learned the hard way.
[0.6.0] - 2026-06-16
Section titled “[0.6.0] - 2026-06-16”Added — Hermes dashboard plugin surface
Section titled “Added — Hermes dashboard plugin surface”hermes-telemetry now ships as a Hermes dashboard plugin in addition
to the existing standalone HTML dashboard. The Hermes web dashboard
auto-discovers the plugin from the same install path — a git pull
brings both surfaces up to date in lockstep. Verified against
NousResearch/hermes-agent@main (hermes_cli/web_server.py loader and
extending-the-dashboard.md SDK contract).
- Dedicated
/telemetrytab with six sub-views: Summary, Runs, Requests, Providers, Cron, Budgets. Rendered as a single IIFE (dashboard/dist/index.js) using the Hermes Plugin SDK — no build step, no bundler, no new dependencies. - Four shell slots populated via
registerSlot():sessions:top— pinned card with the last run that had real activity (cost, tokens, model). Skips empty / aborted sessions.cron:top— 7-day cron cost +N FAILEDdestructive badge.header-right— compact 24h spend with semáforo against the global daily cap (badge variant flips todestructiveon hard breach).analytics:bottom— daily cost chart (Chart.js loaded from CDN with graceful degradation when unreachable).
- Read-only FastAPI router at
/api/plugins/hermes-telemetry/*:/health,/summary,/token-breakdown,/runs,/requests,/providers,/cron,/session/{id},/budget. Openstelemetry.dbwithPRAGMA query_only=ON; the plugin never writes. - Theme-aware by design: uses SDK components and shadcn-tokenised Tailwind classes, so a user’s installed theme / skin repaints the tab and slots automatically.
Added — tooling
Section titled “Added — tooling”tools/seed_demo_data.py— seeds an isolatedHERMES_HOMEwith ~56 realistic sessions for screenshots / demos. Refuses to run against~/.hermesto protect production data.
Changed
Section titled “Changed”.github/workflows/ci.ymlgains a version-sync check that enforcespyproject.toml == __init__.py == plugin.yaml == dashboard/manifest.json, plusnode --checkover the plugin IIFE so a JS syntax error fails CI..github/workflows/release.ymlextends the tag-vs-pyproject guard to all four version sources — a release tag cannot ship with a staledashboard/manifest.json.- README grows a “Hermes Dashboard Plugin” section with embedded
screenshots (
docs/plugin/01-…through09-…) and a side-by-side comparison of the two dashboard surfaces. - ONBOARDING grows a “Dashboard Plugin Surface” section with verified
source references for the loader contract and the
_safe_plugin_api_relpathsecurity rule (GHSA-5qr3-c538-wm9j).
Decisions / non-goals
Section titled “Decisions / non-goals”- The standalone dashboard (
dashboard/serve.py, port 8765) is untouched — it remains the headless-friendly surface for cron-only deployments and SSH workflows. The two surfaces share the SQLite DB and zero Python code; enforced bytests/test_dashboard_plugin_isolation.py. dashboard/plugin_api.pyis a single self-contained file. The Hermes loader imports it viaimportlib.util.spec_from_file_location, so relative imports are not available at load time.- The
slotsarray inmanifest.jsonis documentation only — the real binding happens in the JS bundle viaregisterSlot().
0.5.1 - 2026-06-16
Section titled “0.5.1 - 2026-06-16”- Free→paid transition detection now handles the id-change case (issue #32):
when a provider drops a
:freesuffix (or renames a promo to its paid base) so the paid call arrives under a different model id than the recorded:freerow.db.is_free_tier_transition(model, provider)matches a stored<id>:freerow both for the bare rename (nemotron-3-ultra:free→nemotron-3-ultra) and for a suffixed paid id at a token boundary (nemotron-3-ultra-550b-a55b). Wired intopost_api_requestalongside the existingis_known_free_modelcheck. :freesuffix pricing rule (issue #32): any model id ending in:free(the OpenRouter free-tier convention, e.g.nvidia/nemotron-3-ultra-550b-a55b:free) now resolves to an explicit$0inpricing._lookup_form, before the prefix scan. This means a:freeid is recorded as known-free (no estimated-price warning) and seeds the free→paid alert automatically — no manualpricing.yamlentry required. A user’s explicit:freeentry still overrides the rule.nvidia/nemotron-3-ultrapaid seed in_DEFAULT_PRICING(OpenRouter rate pending NIM-direct confirmation). Covers both the bare id and the suffixed…-550b-a55bform via prefix, so cost becomes non-zero once the:freepromo ends 2026-06-18 — which is what fires the transition alert.
:freepromo variants of any seeded model were billed at the seeded paid price via prefix match instead of$0(issue #32). Pre-fix,nvidia/nemotron-3-super-120b-a12b:freeresolved to the paid$0.09/$0.45rate, and adding thenvidia/nemotron-3-ultraseed (above) regressednvidia/nemotron-3-ultra-550b-a55b:freefrom$0to the paid rate. The new:freesuffix rule short-circuits before the prefix scan, so free-tier calls correctly cost$0during the promo. Corrects the earlier (0.4.1) claim that:freevariants “resolve to $0 via the unknown-model fallback” — they did not for any model with a seeded paid base.
- Free Nemotron Ultra users: no action required. Calls under any
…nemotron-3-ultra…:freeid resolve to$0automatically and seed the free→paid alert, which fires when the:freepromo ends 2026-06-18 and the gateway starts billing the bare/suffixed paid id. A manual_subscription$0entry inpricing.yamlis still honored (and overrides the rule) but is no longer needed.
0.5.0 - 2026-06-15
Section titled “0.5.0 - 2026-06-15”- Free→paid model transition alert (issue #16, Block A). When a model that
was previously seen at explicit
$0(subscription or zero-price entry inpricing.yaml) starts incurring cost, a one-shot warning is injected into the nextpre_llm_callcontext so the agent can surface it to the user. The alert fires once per session per transition and is cleared immediately after injection. known_free_modelsSQLite table (schema v5) persists every(model, provider)pair observed at explicit$0, enabling cross-session detection.is_explicitly_priced(model, provider)distinguishes genuinely-free models from unknown-model$0fallbacks so only real free-tier models are tracked.- Backward-compatible backfill: on every plugin load,
register()scans all explicitly-$0models frompricing.yamland_DEFAULT_PRICINGand seedsknown_free_modelswith aprovider=''wildcard row. Pre-v5 users who already had subscription models are covered automatically on first upgrade without requiring a prior$0call in the new version. pricing.get_known_free_models()— returns all model names with explicitinput=0.0 AND output=0.0from custom and default pricing; used by the backfill and available for external inspection.db.backfill_known_free_models(models)— inserts(model, provider='')wildcard rows;INSERT OR IGNOREmakes it safe to call on every load.
0.4.1 - 2026-06-14
Section titled “0.4.1 - 2026-06-14”- Provider-aware pricing lookup guard (issue #24). A pricing entry tagged
_source: openrouterinpricing.yamlis now only applied to calls the OpenRouter provider actually served (or calls with no provider, for backward compatibility). This stops an OpenRouter rate from silently costing a call another provider served — e.g. the OpenRouter Qwen price leaking onto a Nous Portal call. Source-less entries (_DEFAULT_PRICING, the prefix table, hand-added overrides) stay provider-neutral. - NVIDIA NIM seed prices in
_DEFAULT_PRICING(issue #12, Phase 1): five Nemotron models (nemotron-3-super-120b-a12b,nemotron-super-49b,nemotron-70b-instruct,nemotron-nano-12b-vl,nemotron-nano-9b). Seeds are source-neutral and live in code, so they survive an OpenRouter refresh and win over a same-id OpenRouter entry forprovider=nvidiacalls.:freepromo variants resolve to$0via the unknown-model fallback and need no entry. _subscription: truepricing tag — flags a flat-rate / subscription model so it resolves to$0without the unknown-model warning, distinct from a missing price. Lets users price e.g. a Nous-served model at$0even when an OpenRouter entry for the same id exists.providerparameter onpricing.estimate_cost()(threaded through_resolve_pricing/_lookup_base/_lookup_form), passed from thepost_api_requesthook. Defaults to"", preserving the previous provider-blind behaviour for existing callers.
Changed
Section titled “Changed”pricing_refresh.pynow emitssubscription_modelsin the YAML_metablock and excludes those models fromestimated_price_models.- Unknown-model pricing warnings are now de-duplicated per
(model, provider)pair instead of per model, so the same id can warn separately under each provider that lacks a price for it.
_SCHEMA_VERSIONwas stuck at3despite the_migrate_v4migration (addscache_read_tokens/cache_write_tokenstoruns), so the recorded schema version never matched the applied migrations. Bumped to4.- Dashboard failed to import on Python 3.8/3.9 due to PEP 604
str | Noneunion syntax — addedfrom __future__ import annotationstodashboard/serve.py.
0.4.0 - 2026-06-09
Section titled “0.4.0 - 2026-06-09”GoogleAISourceinpricing_refresh.py— direct Google AI Studio pricing forprovider=googlecalls (baregemini-*model ids, nogoogle/prefix). Ships a constant table mirroring https://ai.google.dev/gemini-api/docs/pricing with aLAST_VERIFIEDdate for manual quarterly refresh. Registered alongsideOpenRouterSource, so the same auto-refresh cycle now populates pricing for both routing paths. Addresses #9.
Changed
Section titled “Changed”pricing._lookup_basenow resolvesgemini-Xandgoogle/gemini-Xto the same entry symmetrically (issue #2 follow-up). Direct-Google and OpenRouter-routed callers no longer need both forms present in the pricing data to get a hit. Normalization is google-specific — other provider prefixes (anthropic/,meta-llama/,openrouter/) keep their existing semantics and are never stripped.
[0.3.1] - 2026-06-06
Section titled “[0.3.1] - 2026-06-06”- Gemini 3.x and 2.5 family pricing in
_DEFAULT_PRICING(issue #2):gemini-3.5-flash,gemini-3.1-pro-preview,gemini-3.1-flash-lite,gemini-3-flash-preview,gemini-2.5-flash,gemini-2.5-flash-lite. All entries include explicitcache_readmatching Google’s published rates. - Family-specific Gemini prefix entries in
_PREFIX_PRICINGto cover dated variants (e.g.gemini-3-flash-preview-20251217).
Changed
Section titled “Changed”gemini-2.5-proentry now includes explicitcache_read: 0.125.- Direct-Google Gemini lookups (no
google/prefix) now resolve to correct prices — previously fell through to the legacy genericgeminiprefix and were priced as Flash 1.5, underestimating cost by ~6.5x for Gemini 3 Flash.
runsrows are now lazy-created fromrecord_llm_callandend_runwhenon_session_startwas missed (issue #3). Affected any deployment where the plugin was enabled on a gateway with already-running chat-platform sessions: the bot’ssession_idnever received the start hook, so all subsequentUPDATE runs WHERE session_id = ?calls were silent no-ops and/stats//budgetunder-reported. The new private_ensure_run_rowhelper mirrors theINSERT OR IGNOREpattern already used bystart_run, so the happy path is unchanged (no duplicate rows,platform/cron_job_idpreserved).
Removed
Section titled “Removed”- Deprecated Gemini entries from
_DEFAULT_PRICING:gemini-1.5-pro,gemini-1.5-flash,gemini-2.0-flash(removed from Google’s pricing page; 2.0 family sunset 2026-06-01). - Generic
geminicatch-all prefix in_PREFIX_PRICING(was Flash 1.5 pricing for any unknown gemini-* variant — silently mis-priced new models). Unknown Gemini variants now surface asunknown-modelwarnings instead.
0.3.0 - 2026-06-02
Section titled “0.3.0 - 2026-06-02”- First-time setup wizard (
/setupslash command) — interactive and non-interactive modes - Auto-generate pricing from OpenRouter API fetch + built-in defaults (~30 models)
- Manual pricing protection: models with
openrouter/prefix are not overwritten by auto-refresh - YAML serializer fallback (stdlib-only, no PyYAML required)
0.2.0 - 2026-06-01
Section titled “0.2.0 - 2026-06-01”- Dashboard UI (
dashboard/) — local HTTP server, stdlib + Chart.js, port 8765 - Pricing auto-refresh from OpenRouter API (
pricing_refresh.py) - Provider breakdown in
/stats providerscommand - User legend: “Provider key” (nous vs openrouter routing path explained)
- Database persistence layer (
db.py) — SQLite WAL mode - Session + cron job telemetry: tokens, cost, latency, tool calls
- Budget enforcement: soft (warn at 80%) and hard (block at 100%) thresholds
- Estimated price flag (
_estimated_price: true) for models without fixed pricing - Subagent cost reconciliation hook (
subagent_stop) - POC verification scripts (
poc_setup.py,poc_setup_cmd.py)
Changed
Section titled “Changed”- Stats output: provider field reflects routing path (not normalized)
- Negative pricing models: excluded from auto-generated pricing
0.1.0 - 2026-05-31
Section titled “0.1.0 - 2026-05-31”- Initial plugin scaffold (
__init__.py,plugin.yaml) - Telemetry hooks:
on_session_start,pre_api_request,post_api_request,post_tool_call,post_llm_call,on_session_end,on_session_finalize,pre_llm_call,pre_tool_call /statsslash command — aggregate and session-level stats/budgetslash command — budget status and configuration- Pricing table (
pricing.py) — manual model pricing with cache_read/cache_write support - Budget config (
budget.py) — global, per-cron-job, per_sender scopes - Config examples (
config.example.yaml,budget.example.yaml) - pytest suite with conftest, test_db, test_budget, test_pricing, test_setup, test_stats_providers, test_subagent_reconciliation
- MIT License
- README with architecture, usage, screenshots
hermes-telemetry is an independent community plugin for Hermes Agent. It is not affiliated with or endorsed by Nous Research.